diff --git a/.eslintignore b/.eslintignore index a26b930da..e136a6b38 100644 --- a/.eslintignore +++ b/.eslintignore @@ -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 diff --git a/.gitignore b/.gitignore index 289f4a4a4..f83699def 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/packages/app-cli/app/app.ts b/packages/app-cli/app/app.ts index ff0938081..a7ead6e4b 100644 --- a/packages/app-cli/app/app.ts +++ b/packages/app-cli/app/app.ts @@ -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); } diff --git a/packages/app-cli/app/command-rmbook.ts b/packages/app-cli/app/command-rmbook.ts index 3d6df9d99..97c648e7f 100644 --- a/packages/app-cli/app/command-rmbook.ts +++ b/packages/app-cli/app/command-rmbook.ts @@ -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' }); } } diff --git a/packages/app-cli/app/command-rmnote.ts b/packages/app-cli/app/command-rmnote.ts index 8c851572b..d48e7b081 100644 --- a/packages/app-cli/app/command-rmnote.ts +++ b/packages/app-cli/app/command-rmnote.ts @@ -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' }); } } diff --git a/packages/app-cli/app/command-testing.ts b/packages/app-cli/app/command-testing.ts index bcbd89048..fc4c0c729 100644 --- a/packages/app-cli/app/command-testing.ts +++ b/packages/app-cli/app/command-testing.ts @@ -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' })); } } diff --git a/packages/app-desktop/gui/MainScreen/commands/deleteFolder.ts b/packages/app-desktop/gui/MainScreen/commands/deleteFolder.ts index 8138e72eb..7a11cf8b3 100644 --- a/packages/app-desktop/gui/MainScreen/commands/deleteFolder.ts +++ b/packages/app-desktop/gui/MainScreen/commands/deleteFolder.ts @@ -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', }; diff --git a/packages/app-desktop/gui/MainScreen/commands/deleteNote.ts b/packages/app-desktop/gui/MainScreen/commands/deleteNote.ts index d08defe80..23735f1cd 100644 --- a/packages/app-desktop/gui/MainScreen/commands/deleteNote.ts +++ b/packages/app-desktop/gui/MainScreen/commands/deleteNote.ts @@ -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', diff --git a/packages/app-desktop/gui/MainScreen/commands/permanentlyDeleteNote.ts b/packages/app-desktop/gui/MainScreen/commands/permanentlyDeleteNote.ts index f90f02389..67782e69e 100644 --- a/packages/app-desktop/gui/MainScreen/commands/permanentlyDeleteNote.ts +++ b/packages/app-desktop/gui/MainScreen/commands/permanentlyDeleteNote.ts @@ -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', }; diff --git a/packages/app-desktop/gui/ResourceScreen.tsx b/packages/app-desktop/gui/ResourceScreen.tsx index 27cdcb33d..2a2056519 100644 --- a/packages/app-desktop/gui/ResourceScreen.tsx +++ b/packages/app-desktop/gui/ResourceScreen.tsx @@ -174,9 +174,10 @@ class ResourceScreenComponent extends React.Component { 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 diff --git a/packages/app-mobile/components/ScreenHeader.tsx b/packages/app-mobile/components/ScreenHeader.tsx index 566d63e74..99662eaf0 100644 --- a/packages/app-mobile/components/ScreenHeader.tsx +++ b/packages/app-mobile/components/ScreenHeader.tsx @@ -277,7 +277,7 @@ class ScreenHeaderComponent extends PureComponent 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', diff --git a/packages/app-mobile/components/side-menu-content.tsx b/packages/app-mobile/components/side-menu-content.tsx index b1464db1c..e937da150 100644 --- a/packages/app-mobile/components/side-menu-content.tsx +++ b/packages/app-mobile/components/side-menu-content.tsx @@ -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)' }); }, }, { diff --git a/packages/lib/BaseModel.ts b/packages/lib/BaseModel.ts index c83b94377..0b2108f9c 100644 --- a/packages/lib/BaseModel.ts +++ b/packages/lib/BaseModel.ts @@ -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('","')}")`; diff --git a/packages/lib/Synchronizer.ts b/packages/lib/Synchronizer.ts index f82d3957d..3ba2ea086 100644 --- a/packages/lib/Synchronizer.ts +++ b/packages/lib/Synchronizer.ts @@ -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); } } diff --git a/packages/lib/components/shared/reduxSharedMiddleware.ts b/packages/lib/components/shared/reduxSharedMiddleware.ts index 9eac52165..86c8dc06f 100644 --- a/packages/lib/components/shared/reduxSharedMiddleware.ts +++ b/packages/lib/components/shared/reduxSharedMiddleware.ts @@ -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' }); } } diff --git a/packages/lib/models/BaseItem.ts b/packages/lib/models/BaseItem.ts index 0b043644e..9e28a1a20 100644 --- a/packages/lib/models/BaseItem.ts +++ b/packages/lib/models/BaseItem.ts @@ -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; diff --git a/packages/lib/models/Folder.ts b/packages/lib/models/Folder.ts index 5e0db0663..9cf706d98 100644 --- a/packages/lib/models/Folder.ts +++ b/packages/lib/models/Folder.ts @@ -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, }; diff --git a/packages/lib/models/Note.ts b/packages/lib/models/Note.ts index 20cb77a2b..1150bcf99 100644 --- a/packages/lib/models/Note.ts +++ b/packages/lib/models/Note.ts @@ -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++) { diff --git a/packages/lib/models/Resource.ts b/packages/lib/models/Resource.ts index aaae097a5..387935a3d 100644 --- a/packages/lib/models/Resource.ts +++ b/packages/lib/models/Resource.ts @@ -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) { diff --git a/packages/lib/models/ResourceLocalState.ts b/packages/lib/models/ResourceLocalState.ts index 18a88d4e9..d87f01218 100644 --- a/packages/lib/models/ResourceLocalState.ts +++ b/packages/lib/models/ResourceLocalState.ts @@ -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); } } diff --git a/packages/lib/models/Tag.ts b/packages/lib/models/Tag.ts index 614f2299b..837e4c232 100644 --- a/packages/lib/models/Tag.ts +++ b/packages/lib/models/Tag.ts @@ -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, }); } diff --git a/packages/lib/models/utils/onFolderDrop.ts b/packages/lib/models/utils/onFolderDrop.ts index bfe71846f..9fe1852b9 100644 --- a/packages/lib/models/utils/onFolderDrop.ts +++ b/packages/lib/models/utils/onFolderDrop.ts @@ -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; diff --git a/packages/lib/services/AlarmService.ts b/packages/lib/services/AlarmService.ts index 25f6c8c44..bef157ea4 100644 --- a/packages/lib/services/AlarmService.ts +++ b/packages/lib/services/AlarmService.ts @@ -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; diff --git a/packages/lib/services/MigrationService.ts b/packages/lib/services/MigrationService.ts index 1a4e4d6f1..0e3fdbdb2 100644 --- a/packages/lib/services/MigrationService.ts +++ b/packages/lib/services/MigrationService.ts @@ -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; diff --git a/packages/lib/services/ResourceService.ts b/packages/lib/services/ResourceService.ts index 1de05b1b7..9cdaf4d62 100644 --- a/packages/lib/services/ResourceService.ts +++ b/packages/lib/services/ResourceService.ts @@ -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' }); } } } diff --git a/packages/lib/services/rest/routes/folders.ts b/packages/lib/services/rest/routes/folders.ts index a17e6d3c3..d66b8cfb1 100644 --- a/packages/lib/services/rest/routes/folders.ts +++ b/packages/lib/services/rest/routes/folders.ts @@ -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; } diff --git a/packages/lib/services/rest/routes/notes.ts b/packages/lib/services/rest/routes/notes.ts index 247fcb478..78338c179 100644 --- a/packages/lib/services/rest/routes/notes.ts +++ b/packages/lib/services/rest/routes/notes.ts @@ -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; } diff --git a/packages/lib/services/rest/utils/defaultAction.ts b/packages/lib/services/rest/utils/defaultAction.ts index 65bed0c6b..d9d93c667 100644 --- a/packages/lib/services/rest/utils/defaultAction.ts +++ b/packages/lib/services/rest/utils/defaultAction.ts @@ -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; } diff --git a/packages/lib/services/share/ShareService.ts b/packages/lib/services/share/ShareService.ts index 480f6f77c..1436100a7 100644 --- a/packages/lib/services/share/ShareService.ts +++ b/packages/lib/services/share/ShareService.ts @@ -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 diff --git a/packages/lib/services/synchronizer/ItemUploader.ts b/packages/lib/services/synchronizer/ItemUploader.ts index 812de937c..53ed1afed 100644 --- a/packages/lib/services/synchronizer/ItemUploader.ts +++ b/packages/lib/services/synchronizer/ItemUploader.ts @@ -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) { diff --git a/packages/lib/services/synchronizer/utils/handleConflictAction.ts b/packages/lib/services/synchronizer/utils/handleConflictAction.ts index f96669065..b151456f5 100644 --- a/packages/lib/services/synchronizer/utils/handleConflictAction.ts +++ b/packages/lib/services/synchronizer/utils/handleConflictAction.ts @@ -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', + }, + ); } } }; diff --git a/packages/lib/services/trash/emptyTrash.ts b/packages/lib/services/trash/emptyTrash.ts index eb381d7bd..e62e1fb96 100644 --- a/packages/lib/services/trash/emptyTrash.ts +++ b/packages/lib/services/trash/emptyTrash.ts @@ -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' }); } }; diff --git a/packages/lib/services/trash/permanentlyDeleteOldItems.ts b/packages/lib/services/trash/permanentlyDeleteOldItems.ts index 89ca21f9b..8fbccc106 100644 --- a/packages/lib/services/trash/permanentlyDeleteOldItems.ts +++ b/packages/lib/services/trash/permanentlyDeleteOldItems.ts @@ -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) { diff --git a/packages/lib/utils/ActionLogger.ts b/packages/lib/utils/ActionLogger.ts new file mode 100644 index 000000000..77d6c12d1 --- /dev/null +++ b/packages/lib/utils/ActionLogger.ts @@ -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; + } +} diff --git a/packages/utils/Logger.ts b/packages/utils/Logger.ts index 7c4b37e7b..2cb780680 100644 --- a/packages/utils/Logger.ts +++ b/packages/utils/Logger.ts @@ -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(', '); }