1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-01-08 13:06:15 +02:00

Cli,Desktop,Mobile: Resolves #9465: Log user actions (deletions) (#9585)

This commit is contained in:
Henry Heino 2024-03-09 02:33:05 -08:00 committed by GitHub
parent 3222b620b9
commit 75cb639ed2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 182 additions and 59 deletions

View File

@ -1061,6 +1061,7 @@ packages/lib/themes/solarizedLight.js
packages/lib/themes/type.js
packages/lib/time.js
packages/lib/types.js
packages/lib/utils/ActionLogger.js
packages/lib/utils/credentialFiles.js
packages/lib/utils/joplinCloud.js
packages/lib/utils/processStartFlags.js

1
.gitignore vendored
View File

@ -1041,6 +1041,7 @@ packages/lib/themes/solarizedLight.js
packages/lib/themes/type.js
packages/lib/time.js
packages/lib/types.js
packages/lib/utils/ActionLogger.js
packages/lib/utils/credentialFiles.js
packages/lib/utils/joplinCloud.js
packages/lib/utils/processStartFlags.js

View File

@ -95,7 +95,7 @@ class Application extends BaseApplication {
let item = null;
if (type === BaseModel.TYPE_NOTE) {
if (!parent) throw new Error(_('No notebook has been specified.'));
item = await ItemClass.loadFolderNoteByField(parent.id, 'title', pattern);
item = await (ItemClass as typeof Note).loadFolderNoteByField(parent.id, 'title', pattern);
} else {
item = await ItemClass.loadByTitle(pattern);
}

View File

@ -28,7 +28,7 @@ class Command extends BaseCommand {
const ok = force ? true : await this.prompt(msg, { booleanAnswerDefault: 'n' });
if (!ok) return;
await Folder.delete(folder.id, { toTrash: true });
await Folder.delete(folder.id, { toTrash: true, sourceDescription: 'rmbook command' });
}
}

View File

@ -33,7 +33,7 @@ class Command extends BaseCommand {
if (!ok) return;
const ids = notes.map(n => n.id);
await Note.batchDelete(ids, { toTrash: true });
await Note.batchDelete(ids, { toTrash: true, sourceDescription: 'rmnote command' });
}
}

View File

@ -85,7 +85,7 @@ class Command extends BaseCommand {
for (let i = 0; i < noteCount; i++) {
const noteId = randomElement(noteIds);
promises.push(Note.delete(noteId));
promises.push(Note.delete(noteId, { sourceDescription: 'command-testing' }));
}
}

View File

@ -25,7 +25,7 @@ export const runtime = (): CommandRuntime => {
const ok = bridge().showConfirmMessageBox(deleteMessage);
if (!ok) return;
await Folder.delete(folderId, { toTrash: true });
await Folder.delete(folderId, { toTrash: true, sourceDescription: 'deleteFolder command' });
},
enabledCondition: '!folderIsReadOnly',
};

View File

@ -13,7 +13,7 @@ export const runtime = (): CommandRuntime => {
execute: async (context: CommandContext, noteIds: string[] = null) => {
if (noteIds === null) noteIds = context.state.selectedNoteIds;
if (!noteIds.length) return;
await Note.batchDelete(noteIds, { toTrash: true });
await Note.batchDelete(noteIds, { toTrash: true, sourceDescription: 'deleteNote command' });
context.dispatch({
type: 'ITEMS_TRASHED',

View File

@ -21,7 +21,9 @@ export const runtime = (): CommandRuntime => {
defaultId: 1,
});
if (ok) await Note.batchDelete(noteIds, { toTrash: false });
if (ok) {
await Note.batchDelete(noteIds, { toTrash: false, sourceDescription: 'permanentlyDeleteNote command' });
}
},
enabledCondition: '(!noteIsReadOnly || inTrash) && someNotesSelected',
};

View File

@ -174,9 +174,10 @@ class ResourceScreenComponent extends React.Component<Props, State> {
if (!ok) {
return;
}
Resource.delete(resource.id)
Resource.delete(resource.id, { sourceDescription: 'ResourceScreen' })
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
.catch((error: Error) => {
console.error(error);
bridge().showErrorMessageBox(error.message);
})
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied

View File

@ -277,7 +277,7 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
this.props.dispatch({ type: 'NOTE_SELECTION_END' });
try {
await Note.batchDelete(noteIds, { toTrash: true });
await Note.batchDelete(noteIds, { toTrash: true, sourceDescription: 'Delete selected notes button' });
} catch (error) {
alert(_n('This note could not be deleted: %s', 'These notes could not be deleted: %s', noteIds.length, error.message));
}

View File

@ -682,7 +682,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
const folderId = note.parent_id;
await Note.delete(note.id, { toTrash: true });
await Note.delete(note.id, { toTrash: true, sourceDescription: 'Delete note button' });
this.props.dispatch({
type: 'NAV_GO',

View File

@ -187,7 +187,7 @@ const SideMenuContentComponent = (props: Props) => {
{
text: _('OK'),
onPress: () => {
void Folder.delete(folder.id, { toTrash: true });
void Folder.delete(folder.id, { toTrash: true, sourceDescription: 'side-menu-content (long-press)' });
},
},
{

View File

@ -4,6 +4,7 @@ import uuid from './uuid';
import time from './time';
import JoplinDatabase, { TableField } from './JoplinDatabase';
import { LoadOptions, SaveOptions } from './models/utils/types';
import ActionLogger, { ItemActionType as ItemActionType } from './utils/ActionLogger';
import { SqlQuery } from './services/database/types';
const Mutex = require('async-mutex').Mutex;
@ -41,6 +42,9 @@ export interface DeleteOptions {
disableReadOnlyCheck?: boolean;
// Used for logging
sourceDescription?: string|ActionLogger;
// Tells whether the deleted item should be moved to the trash. By default
// it is permanently deleted.
toTrash?: boolean;
@ -688,13 +692,17 @@ class BaseModel {
return output;
}
public static delete(id: string) {
public static delete(id: string, options?: DeleteOptions) {
if (!id) throw new Error('Cannot delete object without an ID');
ActionLogger.from(options?.sourceDescription).log(ItemActionType.Delete, id);
return this.db().exec(`DELETE FROM ${this.tableName()} WHERE id = ?`, [id]);
}
public static async batchDelete(ids: string[], options: DeleteOptions = null) {
public static async batchDelete(ids: string[], options?: DeleteOptions) {
if (!ids.length) return;
ActionLogger.from(options?.sourceDescription).log(ItemActionType.Delete, ids);
options = this.modOptions(options);
const idFieldName = options.idFieldName ? options.idFieldName : 'id';
const sql = `DELETE FROM ${this.tableName()} WHERE ${idFieldName} IN ("${ids.join('","')}")`;

View File

@ -12,7 +12,7 @@ import Resource from './models/Resource';
import ItemChange from './models/ItemChange';
import ResourceLocalState from './models/ResourceLocalState';
import MasterKey from './models/MasterKey';
import BaseModel, { ModelType } from './BaseModel';
import BaseModel, { DeleteOptions, ModelType } from './BaseModel';
import time from './time';
import ResourceService from './services/ResourceService';
import EncryptionService from './services/e2ee/EncryptionService';
@ -404,7 +404,7 @@ export default class Synchronizer {
this.logSyncOperation('starting', null, null, `Starting synchronisation to target ${syncTargetId}... supportsAccurateTimestamp = ${this.api().supportsAccurateTimestamp}; supportsMultiPut = ${this.api().supportsMultiPut}} [${synchronizationId}]`);
const handleCannotSyncItem = async (ItemClass: any, syncTargetId: any, item: any, cannotSyncReason: string, itemLocation: any = null) => {
const handleCannotSyncItem = async (ItemClass: typeof BaseItem, syncTargetId: any, item: any, cannotSyncReason: string, itemLocation: any = null) => {
await ItemClass.saveSyncDisabled(syncTargetId, item, cannotSyncReason, itemLocation);
};
@ -1005,7 +1005,14 @@ export default class Synchronizer {
}
const ItemClass = BaseItem.itemClass(local.type_);
await ItemClass.delete(local.id, { trackDeleted: false, changeSource: ItemChange.SOURCE_SYNC });
await ItemClass.delete(
local.id,
{
trackDeleted: false,
changeSource: ItemChange.SOURCE_SYNC,
sourceDescription: 'sync: deleteLocal',
},
);
}
}
@ -1050,7 +1057,14 @@ export default class Synchronizer {
// CONFLICT
await Folder.markNotesAsConflict(item.id);
}
await Folder.delete(item.id, { deleteChildren: false, changeSource: ItemChange.SOURCE_SYNC, trackDeleted: false });
const deletionOptions: DeleteOptions = {
deleteChildren: false,
trackDeleted: false,
changeSource: ItemChange.SOURCE_SYNC,
sourceDescription: 'Sync',
};
await Folder.delete(item.id, deletionOptions);
}
}

View File

@ -72,7 +72,7 @@ export default async (store: any, _next: any, action: any, dispatch: Dispatch) =
for (const noteId of noteIds) {
if (action.id === noteId) continue;
reg.logger().info('Provisional was not modified - deleting it');
await Note.delete(noteId);
await Note.delete(noteId, { sourceDescription: 'reduxSharedMiddleware: Delete provisional note' });
}
}

View File

@ -168,7 +168,7 @@ export default class BaseItem extends BaseModel {
return p[0].length === 32 && p[1] === 'md';
}
public static itemClass(item: any): any {
public static itemClass(item: any): typeof BaseItem {
if (!item) throw new Error('Item cannot be null');
if (typeof item === 'object') {
@ -269,17 +269,17 @@ export default class BaseItem extends BaseModel {
return ItemClass.load(id, options);
}
public static deleteItem(itemType: ModelType, id: string) {
public static deleteItem(itemType: ModelType, id: string, options: DeleteOptions) {
const ItemClass = this.itemClass(itemType);
return ItemClass.delete(id);
return ItemClass.delete(id, options);
}
public static async delete(id: string, options: DeleteOptions = null) {
public static async delete(id: string, options?: DeleteOptions) {
return this.batchDelete([id], options);
}
public static async batchDelete(ids: string[], options: DeleteOptions = null) {
if (!options) options = {};
public static async batchDelete(ids: string[], options: DeleteOptions) {
if (!options) options = { sourceDescription: '' };
let trackDeleted = true;
if (options && options.trackDeleted !== null && options.trackDeleted !== undefined) trackDeleted = options.trackDeleted;

View File

@ -12,6 +12,7 @@ import Logger from '@joplin/utils/Logger';
import syncDebugLog from '../services/synchronizer/syncDebugLog';
import ResourceService from '../services/ResourceService';
import { LoadOptions } from './utils/types';
import ActionLogger from '../utils/ActionLogger';
import { getTrashFolder, getTrashFolderId } from '../services/trash';
const { substrWithEllipsis } = require('../string-utils.js');
@ -107,7 +108,7 @@ export default class Folder extends BaseItem {
}
}
public static async delete(folderId: string, options: DeleteOptions = null) {
public static async delete(folderId: string, options?: DeleteOptions) {
options = {
deleteChildren: true,
...options,
@ -120,9 +121,14 @@ export default class Folder extends BaseItem {
const folder = await Folder.load(folderId);
if (!folder) return; // noop
const actionLogger = ActionLogger.from(options.sourceDescription);
actionLogger.addDescription(`folder title: ${JSON.stringify(folder.title)}`);
options.sourceDescription = actionLogger;
if (options.deleteChildren) {
const childrenDeleteOptions: DeleteOptions = {
disableReadOnlyCheck: options.disableReadOnlyCheck,
sourceDescription: actionLogger,
deleteChildren: true,
toTrash,
};

View File

@ -16,6 +16,7 @@ const { pregQuote, substrWithEllipsis } = require('../string-utils.js');
const { _ } = require('../locale');
import { pull, removeElement, unique } from '../ArrayUtils';
import { LoadOptions, SaveOptions } from './utils/types';
import ActionLogger from '../utils/ActionLogger';
import { getDisplayParentId, getTrashFolderId } from '../services/trash';
const urlUtils = require('../urlUtils.js');
const { isImageMimeType } = require('../resourceUtils');
@ -827,7 +828,7 @@ export default class Note extends BaseItem {
return savedNote;
}
public static async batchDelete(ids: string[], options: DeleteOptions = null) {
public static async batchDelete(ids: string[], options: DeleteOptions = {}) {
if (!ids.length) return;
ids = ids.slice();
@ -871,7 +872,12 @@ export default class Note extends BaseItem {
await this.db().exec({ sql, params });
} else {
await super.batchDelete(processIds, options);
// For now, we intentionally log only permanent batchDeletions.
const actionLogger = ActionLogger.from(options.sourceDescription);
const noteTitles = notes.map(note => note.title);
actionLogger.addDescription(`titles: ${JSON.stringify(noteTitles)}`);
await super.batchDelete(processIds, { ...options, sourceDescription: actionLogger });
}
for (let i = 0; i < processIds.length; i++) {

View File

@ -1,4 +1,4 @@
import BaseModel from '../BaseModel';
import BaseModel, { DeleteOptions } from '../BaseModel';
import BaseItem from './BaseItem';
import ItemChange from './ItemChange';
import NoteResource from './NoteResource';
@ -22,6 +22,7 @@ import { htmlentities } from '@joplin/utils/html';
import { RecognizeResultLine } from '../services/ocr/utils/types';
import eventManager, { EventName } from '../eventManager';
import { unique } from '../array';
import ActionLogger from '../utils/ActionLogger';
import isSqliteSyntaxError from '../services/database/isSqliteSyntaxError';
import { internalUrl, isResourceUrl, isSupportedImageMimeType, resourceFilename, resourceFullPath, resourcePathToId, resourceRelativePath, resourceUrlToId } from './utils/resourceUtils';
@ -311,7 +312,9 @@ export default class Resource extends BaseItem {
return this.db().exec('UPDATE resources set `size` = ? WHERE id = ?', [fileSize, resourceId]);
}
public static async batchDelete(ids: string[], options: any = null) {
public static async batchDelete(ids: string[], options: DeleteOptions = {}) {
const actionLogger = ActionLogger.from(options.sourceDescription);
// For resources, there's not really batch deletion since there's the
// file data to delete too, so each is processed one by one with the
// file data being deleted last since the metadata deletion call may
@ -321,14 +324,21 @@ export default class Resource extends BaseItem {
const resource = await Resource.load(id);
if (!resource) continue;
// Log just for the current item.
const logger = actionLogger.clone();
logger.addDescription(`title: ${resource.title}`);
const path = Resource.fullPath(resource);
await super.batchDelete([id], options);
await super.batchDelete([id], {
...options,
sourceDescription: logger,
});
await this.fsDriver().remove(path);
await NoteResource.deleteByResource(id); // Clean up note/resource relationships
await this.db().exec('DELETE FROM items_normalized WHERE item_id = ?', [id]);
}
await ResourceLocalState.batchDelete(ids);
await ResourceLocalState.batchDelete(ids, { sourceDescription: actionLogger });
}
public static async markForDownload(resourceId: string) {

View File

@ -1,6 +1,7 @@
import BaseModel from '../BaseModel';
import BaseModel, { DeleteOptions } from '../BaseModel';
import { ResourceLocalStateEntity } from '../services/database/types';
import Database from '../database';
import ActionLogger from '../utils/ActionLogger';
export default class ResourceLocalState extends BaseModel {
public static tableName() {
@ -34,9 +35,11 @@ export default class ResourceLocalState extends BaseModel {
return this.db().transactionExecBatch(this.saveQueries(o));
}
public static batchDelete(ids: string[], options: any = null) {
options = options ? { ...options } : {};
public static batchDelete(ids: string[], options: DeleteOptions = {}) {
options = { ...options };
options.idFieldName = 'resource_id';
options.sourceDescription = ActionLogger.from(options.sourceDescription);
options.sourceDescription.addDescription('Delete local resource state');
return super.batchDelete(ids, options);
}
}

View File

@ -1,10 +1,11 @@
import { TagEntity, TagsWithNoteCountEntity } from '../services/database/types';
import BaseModel from '../BaseModel';
import BaseModel, { DeleteOptions } from '../BaseModel';
import BaseItem from './BaseItem';
import NoteTag from './NoteTag';
import Note from './Note';
import { _ } from '../locale';
import ActionLogger from '../utils/ActionLogger';
export default class Tag extends BaseItem {
public static tableName() {
@ -45,14 +46,21 @@ export default class Tag extends BaseItem {
public static async untagAll(tagId: string) {
const noteTags = await NoteTag.modelSelectAll('SELECT id FROM note_tags WHERE tag_id = ?', [tagId]);
for (let i = 0; i < noteTags.length; i++) {
await NoteTag.delete(noteTags[i].id);
await NoteTag.delete(noteTags[i].id, { sourceDescription: 'untagAll/disassociate note' });
}
await Tag.delete(tagId);
await Tag.delete(tagId, { sourceDescription: 'untagAll/delete tag' });
}
public static async delete(id: string, options: any = null) {
if (!options) options = {};
public static async delete(id: string, options: DeleteOptions = {}) {
const actionLogger = ActionLogger.from(options.sourceDescription);
const tagTitle = (await Tag.load(id)).title;
actionLogger.addDescription(`tag title: ${JSON.stringify(tagTitle)}`);
options = {
...options,
sourceDescription: actionLogger,
};
await super.delete(id, options);
@ -93,14 +101,18 @@ export default class Tag extends BaseItem {
}
public static async removeNote(tagId: string, noteId: string) {
const tag = await Tag.load(tagId);
const actionLogger = ActionLogger.from(`Tag/removeNote - tag: ${tag.title}`);
const noteTags = await NoteTag.modelSelectAll('SELECT id FROM note_tags WHERE tag_id = ? and note_id = ?', [tagId, noteId]);
for (let i = 0; i < noteTags.length; i++) {
await NoteTag.delete(noteTags[i].id);
await NoteTag.delete(noteTags[i].id, { sourceDescription: actionLogger.clone() });
}
this.dispatch({
type: 'NOTE_TAG_REMOVE',
item: await Tag.load(tagId),
item: tag,
});
}

View File

@ -10,7 +10,7 @@ export default async (noteIds: string[], folderIds: string[], targetFolderId: st
if (!targetFolder) throw new Error(`No such folder: ${targetFolderId}`);
const defaultDeleteOptions: DeleteOptions = { toTrash: true };
const defaultDeleteOptions: DeleteOptions = { toTrash: true, sourceDescription: 'onFolderDrop' };
if (targetFolder.id !== getTrashFolderId()) {
defaultDeleteOptions.toTrashParentId = targetFolder.id;

View File

@ -45,7 +45,7 @@ export default class AlarmService {
this.logger().info(`Clearing notification for non-existing note. Alarm ${alarmIds[i]}`);
await this.driver().clearNotification(alarmIds[i]);
}
await Alarm.batchDelete(alarmIds);
await Alarm.batchDelete(alarmIds, { sourceDescription: 'AlarmService/garbageCollect' });
}
// When passing a note, make sure it has all the required properties
@ -93,7 +93,7 @@ export default class AlarmService {
if (clearAlarm) {
this.logger().info(`Clearing notification for note ${noteId}`);
await driver.clearNotification(alarm.id);
await Alarm.delete(alarm.id);
await Alarm.delete(alarm.id, { sourceDescription: 'AlarmService/clearAlarm' });
}
if (isDeleted || !Note.needAlarm(note)) return;

View File

@ -24,7 +24,7 @@ export default class MigrationService extends BaseService {
try {
await this.runScript(migration.number);
await Migration.delete(migration.id);
await Migration.delete(migration.id, { sourceDescription: 'MigrationService' });
} catch (error) {
this.logger().error(`Cannot run migration: ${migration.number}`, error);
break;

View File

@ -132,7 +132,7 @@ export default class ResourceService extends BaseService {
await this.setAssociatedResources(note.id, note.body);
}
} else {
await Resource.delete(resourceId);
await Resource.delete(resourceId, { sourceDescription: 'deleteOrphanResources' });
}
}
}

View File

@ -32,7 +32,7 @@ export default async function(request: Request, id: string = null, link: string
}
if (request.method === RequestMethod.DELETE) {
await Folder.delete(id, { toTrash: request.query.permanent !== '1' });
await Folder.delete(id, { toTrash: request.query.permanent !== '1', sourceDescription: 'api/folders DELETE' });
return;
}

View File

@ -511,7 +511,7 @@ export default async function(request: Request, id: string = null, link: string
}
if (request.method === RequestMethod.DELETE) {
await Note.delete(id, { toTrash: request.query.permanent !== '1' });
await Note.delete(id, { toTrash: request.query.permanent !== '1', sourceDescription: 'api/notes DELETE' });
return;
}

View File

@ -36,7 +36,7 @@ export default async function(modelType: number, request: Request, id: string =
if (request.method === 'DELETE' && id) {
const model = await getOneModel();
await ModelClass.delete(model.id);
await ModelClass.delete(model.id, { source: 'API: DELETE method' });
return;
}

View File

@ -204,8 +204,9 @@ export default class ShareService {
// call deleteAllByShareId()
await Folder.updateAllShareIds(ResourceService.instance());
await Folder.delete(folderId, { deleteChildren: false, disableReadOnlyCheck: true });
await Folder.deleteAllByShareId(folder.share_id, { disableReadOnlyCheck: true, trackDeleted: false });
const source = 'ShareService.leaveSharedFolder';
await Folder.delete(folderId, { deleteChildren: false, disableReadOnlyCheck: true, sourceDescription: source });
await Folder.deleteAllByShareId(folder.share_id, { disableReadOnlyCheck: true, trackDeleted: false, sourceDescription: source });
}
// Finds any folder that is associated with a share, but the user no longer

View File

@ -33,7 +33,7 @@ export default class ItemUploader {
this.maxBatchSize_ = v;
}
public async serializeAndUploadItem(ItemClass: any, path: string, local: BaseItemEntity) {
public async serializeAndUploadItem(ItemClass: typeof BaseItem, path: string, local: BaseItemEntity) {
const preUploadItem = this.preUploadedItems_[path];
if (preUploadItem) {
if (this.preUploadedItemUpdatedTimes_[path] !== local.updated_time) {

View File

@ -9,7 +9,7 @@ import { SyncAction, conflictActions } from './types';
const logger = Logger.create('handleConflictAction');
export default async (action: SyncAction, ItemClass: any, remoteExists: boolean, remoteContent: any, local: any, syncTargetId: number, itemIsReadOnly: boolean, dispatch: Dispatch) => {
export default async (action: SyncAction, ItemClass: typeof BaseItem, remoteExists: boolean, remoteContent: any, local: any, syncTargetId: number, itemIsReadOnly: boolean, dispatch: Dispatch) => {
if (!conflictActions.includes(action)) return;
logger.debug(`Handling conflict: ${action}`);
@ -30,6 +30,7 @@ export default async (action: SyncAction, ItemClass: any, remoteExists: boolean,
} else {
await ItemClass.delete(local.id, {
changeSource: ItemChange.SOURCE_SYNC,
sourceDescription: 'sync: handleConflictAction: non-note conflict',
trackDeleted: false,
});
}
@ -84,7 +85,14 @@ export default async (action: SyncAction, ItemClass: any, remoteExists: boolean,
if (local.encryption_applied) dispatch({ type: 'SYNC_GOT_ENCRYPTED_ITEM' });
} else {
// Remote no longer exists (note deleted) so delete local one too
await ItemClass.delete(local.id, { changeSource: ItemChange.SOURCE_SYNC, trackDeleted: false });
await ItemClass.delete(
local.id,
{
changeSource: ItemChange.SOURCE_SYNC,
trackDeleted: false,
sourceDescription: 'sync: handleConflictAction: note/resource conflict',
},
);
}
}
};

View File

@ -4,9 +4,9 @@ import Note from '../../models/Note';
export default async () => {
const result = await BaseItem.allItemsInTrash();
await Note.batchDelete(result.noteIds);
await Note.batchDelete(result.noteIds, { sourceDescription: 'emptyTrash/notes' });
for (const folderId of result.folderIds) {
await Folder.delete(folderId, { deleteChildren: false });
await Folder.delete(folderId, { deleteChildren: false, sourceDescription: 'emptyTrash/folders' });
}
};

View File

@ -20,7 +20,7 @@ const permanentlyDeleteOldItems = async (ttl: number = null) => {
const result = await Folder.trashItemsOlderThan(ttl);
logger.info('Items to permanently delete:', result);
await Note.batchDelete(result.noteIds);
await Note.batchDelete(result.noteIds, { sourceDescription: 'permanentlyDeleteOldItems' });
// We only auto-delete folders if they are empty.
for (const folderId of result.folderIds) {

View File

@ -0,0 +1,44 @@
import Logger from '@joplin/utils/Logger';
export enum ItemActionType {
Delete = 'DeleteAction',
}
const actionTypeToLogger = {
[ItemActionType.Delete]: Logger.create(ItemActionType.Delete),
};
export default class ActionLogger {
private descriptions: string[] = [];
private constructor(private source: string) { }
public clone() {
const clone = new ActionLogger(this.source);
clone.descriptions = [...this.descriptions];
return clone;
}
// addDescription is used to add labels with information that may not be available
// when .log is called. For example, to include the title of a deleted note.
public addDescription(description: string) {
this.descriptions.push(description);
}
public log(action: ItemActionType, itemIds: string|string[]) {
const logger = actionTypeToLogger[action];
logger.info(`${this.source}: ${this.descriptions.join(',')}; Item IDs: ${JSON.stringify(itemIds)}`);
}
public static from(source: ActionLogger|string|undefined) {
if (!source) {
source = 'Unknown source';
}
if (typeof source === 'string') {
return new ActionLogger(source);
}
return source;
}
}

View File

@ -181,8 +181,14 @@ class Logger {
public objectsToString(...object: any[]) {
const output = [];
for (let i = 0; i < object.length; i++) {
output.push(`"${this.objectToString(object[i])}"`);
if (object.length === 1) {
// Quoting when there is only one argument can make the log more difficult to read,
// particularly when formatting is handled elsewhere.
output.push(this.objectToString(object[0]));
} else {
for (let i = 0; i < object.length; i++) {
output.push(`"${this.objectToString(object[i])}"`);
}
}
return output.join(', ');
}