diff --git a/.eslintignore b/.eslintignore index bc931e63e..d9be63d8d 100644 --- a/.eslintignore +++ b/.eslintignore @@ -150,6 +150,8 @@ packages/app-desktop/checkForUpdates.js packages/app-desktop/commands/copyDevCommand.js packages/app-desktop/commands/editProfileConfig.js packages/app-desktop/commands/emptyTrash.js +packages/app-desktop/commands/exportDeletionLog.test.js +packages/app-desktop/commands/exportDeletionLog.js packages/app-desktop/commands/exportFolders.js packages/app-desktop/commands/exportNotes.js packages/app-desktop/commands/focusElement.js diff --git a/.gitignore b/.gitignore index 796ad6f43..df74b3ad2 100644 --- a/.gitignore +++ b/.gitignore @@ -127,6 +127,8 @@ packages/app-desktop/checkForUpdates.js packages/app-desktop/commands/copyDevCommand.js packages/app-desktop/commands/editProfileConfig.js packages/app-desktop/commands/emptyTrash.js +packages/app-desktop/commands/exportDeletionLog.test.js +packages/app-desktop/commands/exportDeletionLog.js packages/app-desktop/commands/exportFolders.js packages/app-desktop/commands/exportNotes.js packages/app-desktop/commands/focusElement.js diff --git a/packages/app-desktop/commands/exportDeletionLog.test.ts b/packages/app-desktop/commands/exportDeletionLog.test.ts new file mode 100644 index 000000000..fcae872f8 --- /dev/null +++ b/packages/app-desktop/commands/exportDeletionLog.test.ts @@ -0,0 +1,102 @@ +import shim from '@joplin/lib/shim'; +import * as exportDeletionLog from './exportDeletionLog'; +import Setting from '@joplin/lib/models/Setting'; +import { AppState, createAppDefaultState } from '../app.reducer'; + +jest.mock('../services/bridge', () => ({ + __esModule: true, + default: () => ({ + openItem: jest.fn, + }), +})); + +const logContentWithDeleteAction = ` +2024-09-17 18:34:28: Running migration: 20 +2024-09-17 18:34:28: DeleteAction: MigrationService: ; Item IDs: 1 +2024-09-17 18:34:28: Running migration: 27 +2024-09-17 18:34:28: DeleteAction: MigrationService: ; Item IDs: 2 +2024-09-17 18:34:28: Running migration: 33 +2024-09-17 18:34:28: SearchEngine: Updating FTS table... +2024-09-17 18:34:28: Updating items_normalized from {"updated_time":0,"id":""} +2024-09-17 18:34:28: SearchEngine: Updated FTS table in 1ms. Inserted: 0. Deleted: 0 +2024-09-17 18:34:28: DeleteAction: MigrationService: ; Item IDs: 3 +2024-09-17 18:34:29: Running migration: 35 +2024-09-17 18:34:29: SearchEngine: Updating FTS table... +2024-09-17 18:34:29: Updating items_normalized from {"updated_time":0,"id":""} +2024-09-17 18:34:29: SearchEngine: Updated FTS table in 1ms. Inserted: 0. Deleted: 0 +2024-09-17 18:34:29: DeleteAction: MigrationService: ; Item IDs: 4 +2024-09-17 18:34:29: Running migration: 42 +2024-09-17 18:34:29: DeleteAction: MigrationService: ; Item IDs: 5 +2024-09-17 18:34:29: App: "syncInfoCache" was changed - setting up encryption related code +`; + +const createFakeLogFile = async (filename: string, content: string) => { + await shim.fsDriver().writeFile(`${Setting.value('profileDir')}/${filename}`, content, 'utf8'); +}; + +describe('exportDeletionLog', () => { + let state: AppState = undefined; + + beforeAll(() => { + state = createAppDefaultState({}, {}); + jest.useFakeTimers(); + jest.setSystemTime(new Date('2024-09-18T12:00:00Z').getTime()); + }); + + it('should get all deletion lines from the log file', async () => { + await createFakeLogFile('log.txt', logContentWithDeleteAction); + + await exportDeletionLog.runtime().execute({ state, dispatch: () => {} }); + const result = await shim.fsDriver().readFile(`${Setting.value('profileDir')}/deletion_log_20240918.txt`, 'utf-8'); + expect(result).toBe( + `2024-09-17 18:34:28: DeleteAction: MigrationService: ; Item IDs: 1 +2024-09-17 18:34:28: DeleteAction: MigrationService: ; Item IDs: 2 +2024-09-17 18:34:28: DeleteAction: MigrationService: ; Item IDs: 3 +2024-09-17 18:34:29: DeleteAction: MigrationService: ; Item IDs: 4 +2024-09-17 18:34:29: DeleteAction: MigrationService: ; Item IDs: 5 +`); + }); + + it('should return a empty file if there is not deletion lines', async () => { + await createFakeLogFile('log.txt', ''); + await exportDeletionLog.runtime().execute({ state, dispatch: () => {} }); + const result = await shim.fsDriver().readFile(`${Setting.value('profileDir')}/deletion_log_20240918.txt`, 'utf-8'); + expect(result).toBe(''); + }); + + it('should ignore logs from log files that are not recent', async () => { + const before = new Date('2024-09-16').getTime(); + const after = new Date('2024-09-17').getTime(); + await createFakeLogFile(`log-${before}.txt`, logContentWithDeleteAction); + await createFakeLogFile(`log-${after}.txt`, ''); + await createFakeLogFile('log.txt', ''); + + await exportDeletionLog.runtime().execute({ state, dispatch: () => {} }); + const result = await shim.fsDriver().readFile(`${Setting.value('profileDir')}/deletion_log_20240918.txt`, 'utf-8'); + expect(result).toBe(''); + }); + + it('should contain the content from the recent log files', async () => { + const rotateLog = new Date('2024-09-17').getTime(); + await createFakeLogFile(`log-${rotateLog}.txt`, '2024-09-17 18:34:29: DeleteAction: MigrationService: ; Item IDs: 4'); + await createFakeLogFile('log.txt', '2024-09-17 18:34:29: DeleteAction: MigrationService: ; Item IDs: 5'); + + await exportDeletionLog.runtime().execute({ state, dispatch: () => {} }); + const result = await shim.fsDriver().readFile(`${Setting.value('profileDir')}/deletion_log_20240918.txt`, 'utf-8'); + expect(result).toBe( + `2024-09-17 18:34:29: DeleteAction: MigrationService: ; Item IDs: 4 +2024-09-17 18:34:29: DeleteAction: MigrationService: ; Item IDs: 5 +`); + }); + + it('should contain the date in the filename', async () => { + await shim.fsDriver().remove(`${Setting.value('profileDir')}/deletion_log_20230111.txt`); + jest.setSystemTime(new Date('2023-01-11T12:00:00Z').getTime()); + await createFakeLogFile('log.txt', ''); + + await exportDeletionLog.runtime().execute({ state, dispatch: () => {} }); + const exists = await shim.fsDriver().exists(`${Setting.value('profileDir')}/deletion_log_20230111.txt`); + expect(exists).toBe(true); + }); + +}); diff --git a/packages/app-desktop/commands/exportDeletionLog.ts b/packages/app-desktop/commands/exportDeletionLog.ts new file mode 100644 index 000000000..90050a2db --- /dev/null +++ b/packages/app-desktop/commands/exportDeletionLog.ts @@ -0,0 +1,52 @@ + +import { CommandRuntime, CommandDeclaration } from '@joplin/lib/services/CommandService'; +import { _ } from '@joplin/lib/locale'; +import shim from '@joplin/lib/shim'; +import Setting from '@joplin/lib/models/Setting'; +import bridge from '../services/bridge'; +import { formatMsToLocal } from '@joplin/utils/time'; + +export const declaration: CommandDeclaration = { + name: 'exportDeletionLog', + label: () => _('Export deletion log'), +}; + +const getDeletionLines = async (filePath: string) => { + const logFile: string = await shim.fsDriver().readFile(`${Setting.value('profileDir')}/${filePath}`); + + const deletionLines = logFile + .split('\n') + .filter(line => line.includes('DeleteAction')); + + if (!deletionLines.length) return ''; + + return `${deletionLines.join('\n')}\n`; +}; + +export const runtime = (): CommandRuntime => { + return { + execute: async () => { + const files = await shim.fsDriver().readDirStats(Setting.value('profileDir')); + // Get all log.txt and log-{timestamp}.txt files but ignore deletion_log.txt + const logFiles = files.filter(f => f.path.match(/^log(-\d+)?\.txt$/gi)); + + const lastOneAndCurrent = logFiles.sort().slice(logFiles.length - 2); + + let allDeletionLines = ''; + for (const file of lastOneAndCurrent) { + + const deletionLines = await getDeletionLines(file.path); + + allDeletionLines += deletionLines; + } + + const fileName = `deletion_log_${formatMsToLocal(Date.now(), 'YYYYMMDD')}.txt`; + + const deletionLogPath = `${Setting.value('profileDir')}/${fileName}`; + + await shim.fsDriver().writeFile(deletionLogPath, allDeletionLines, 'utf8'); + + await bridge().openItem(deletionLogPath); + }, + }; +}; diff --git a/packages/app-desktop/commands/index.ts b/packages/app-desktop/commands/index.ts index 431ef377f..741b15c77 100644 --- a/packages/app-desktop/commands/index.ts +++ b/packages/app-desktop/commands/index.ts @@ -2,6 +2,7 @@ import * as copyDevCommand from './copyDevCommand'; import * as editProfileConfig from './editProfileConfig'; import * as emptyTrash from './emptyTrash'; +import * as exportDeletionLog from './exportDeletionLog'; import * as exportFolders from './exportFolders'; import * as exportNotes from './exportNotes'; import * as focusElement from './focusElement'; @@ -22,6 +23,7 @@ const index: any[] = [ copyDevCommand, editProfileConfig, emptyTrash, + exportDeletionLog, exportFolders, exportNotes, focusElement, diff --git a/packages/app-desktop/gui/MenuBar.tsx b/packages/app-desktop/gui/MenuBar.tsx index 5f39fc904..6218a4aa6 100644 --- a/packages/app-desktop/gui/MenuBar.tsx +++ b/packages/app-desktop/gui/MenuBar.tsx @@ -927,6 +927,7 @@ function useMenu(props: Props) { separator(), syncStatusItem, separator(), + menuItemDic.exportDeletionLog, { id: 'help:toggleDevTools', label: _('Toggle development tools'), diff --git a/packages/app-desktop/gui/menuCommandNames.ts b/packages/app-desktop/gui/menuCommandNames.ts index 2306dfbf9..3c798fa8f 100644 --- a/packages/app-desktop/gui/menuCommandNames.ts +++ b/packages/app-desktop/gui/menuCommandNames.ts @@ -56,6 +56,7 @@ export default function() { 'editor.sortSelectedLines', 'editor.swapLineUp', 'editor.swapLineDown', + 'exportDeletionLog', 'toggleSafeMode', 'showShareNoteDialog', 'showShareFolderDialog', diff --git a/packages/app-mobile/components/screens/ConfigScreen/ConfigScreen.tsx b/packages/app-mobile/components/screens/ConfigScreen/ConfigScreen.tsx index fdb268a2e..4539d43cc 100644 --- a/packages/app-mobile/components/screens/ConfigScreen/ConfigScreen.tsx +++ b/packages/app-mobile/components/screens/ConfigScreen/ConfigScreen.tsx @@ -155,6 +155,10 @@ class ConfigScreenComponent extends BaseScreenComponent { + void NavService.go('Log', { defaultFilter: 'DeleteAction' }); + }; + private manageSharesPress_ = () => { void NavService.go('ShareManager'); }; @@ -543,6 +547,7 @@ class ConfigScreenComponent extends BaseScreenComponent