From 78b8839ae3b6e4add95b7833b3b5ba12e6042478 Mon Sep 17 00:00:00 2001 From: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com> Date: Thu, 14 Mar 2024 11:38:07 -0700 Subject: [PATCH] CLI: Resolves #10090: Allow deleting notes and notebooks permanently (#10107) Co-authored-by: Laurent Cozic --- .eslintignore | 2 + .gitignore | 2 + packages/app-cli/app/command-rmbook.test.ts | 81 +++++++++++++++++++++ packages/app-cli/app/command-rmbook.ts | 19 ++++- packages/app-cli/app/command-rmnote.test.ts | 57 +++++++++++++++ packages/app-cli/app/command-rmnote.ts | 21 +++++- packages/tools/cspell/dictionary4.txt | 3 +- 7 files changed, 177 insertions(+), 8 deletions(-) create mode 100644 packages/app-cli/app/command-rmbook.test.ts create mode 100644 packages/app-cli/app/command-rmnote.test.ts diff --git a/.eslintignore b/.eslintignore index cb7cab169..bc631fb73 100644 --- a/.eslintignore +++ b/.eslintignore @@ -110,7 +110,9 @@ packages/app-cli/app/command-mkbook.js packages/app-cli/app/command-mv.js packages/app-cli/app/command-ren.js packages/app-cli/app/command-restore.js +packages/app-cli/app/command-rmbook.test.js packages/app-cli/app/command-rmbook.js +packages/app-cli/app/command-rmnote.test.js packages/app-cli/app/command-rmnote.js packages/app-cli/app/command-set.js packages/app-cli/app/command-settingschema.js diff --git a/.gitignore b/.gitignore index 1ad31a618..56faf73e2 100644 --- a/.gitignore +++ b/.gitignore @@ -90,7 +90,9 @@ packages/app-cli/app/command-mkbook.js packages/app-cli/app/command-mv.js packages/app-cli/app/command-ren.js packages/app-cli/app/command-restore.js +packages/app-cli/app/command-rmbook.test.js packages/app-cli/app/command-rmbook.js +packages/app-cli/app/command-rmnote.test.js packages/app-cli/app/command-rmnote.js packages/app-cli/app/command-set.js packages/app-cli/app/command-settingschema.js diff --git a/packages/app-cli/app/command-rmbook.test.ts b/packages/app-cli/app/command-rmbook.test.ts new file mode 100644 index 000000000..04703d25f --- /dev/null +++ b/packages/app-cli/app/command-rmbook.test.ts @@ -0,0 +1,81 @@ +import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils'; +import { setupCommandForTesting, setupApplication } from './utils/testUtils'; +import Folder from '@joplin/lib/models/Folder'; +import Note from '@joplin/lib/models/Note'; +const Command = require('./command-rmbook'); + +const setUpCommand = () => { + const command = setupCommandForTesting(Command); + const promptMock = jest.fn(() => true); + command.setPrompt(promptMock); + + return { command, promptMock }; +}; + + +describe('command-rmbook', () => { + beforeEach(async () => { + await setupDatabaseAndSynchronizer(1); + await switchClient(1); + await setupApplication(); + }); + + it('should ask before moving to the trash', async () => { + await Folder.save({ title: 'folder1' }); + + const { command, promptMock } = setUpCommand(); + + await command.action({ 'notebook': 'folder1', options: {} }); + + expect(promptMock).toHaveBeenCalledTimes(1); + + const folder1 = await Folder.loadByTitle('folder1'); + expect(folder1.deleted_time).not.toBeFalsy(); + expect((await Note.allItemsInTrash()).folderIds).toHaveLength(1); + }); + + it('cancelling a prompt should prevent deletion', async () => { + await Folder.save({ title: 'folder1' }); + + const { command, promptMock } = setUpCommand(); + promptMock.mockImplementation(() => false); + await command.action({ 'notebook': 'folder1', options: {} }); + + expect((await Note.allItemsInTrash()).folderIds).toHaveLength(0); + }); + + it('should not prompt when the force flag is given', async () => { + const { id: folder1Id } = await Folder.save({ title: 'folder1' }); + const { id: folder2Id } = await Folder.save({ title: 'folder2', parent_id: folder1Id }); + + const { command, promptMock } = setUpCommand(); + await command.action({ 'notebook': 'folder1', options: { force: true } }); + + expect(promptMock).toHaveBeenCalledTimes(0); + + expect((await Note.allItemsInTrash()).folderIds.includes(folder1Id)).toBe(true); + expect((await Note.allItemsInTrash()).folderIds.includes(folder2Id)).toBe(true); + }); + + it('should support permanent deletion', async () => { + const { id: folder1Id } = await Folder.save({ title: 'folder1' }); + const { id: folder2Id } = await Folder.save({ title: 'folder2' }); + + const { command, promptMock } = setUpCommand(); + await command.action({ 'notebook': 'folder1', options: { permanent: true, force: true } }); + expect(promptMock).not.toHaveBeenCalled(); + + // Should be permanently deleted. + expect((await Note.allItemsInTrash()).folderIds.includes(folder1Id)).toBe(false); + expect(await Folder.load(folder1Id, { includeDeleted: true })).toBe(undefined); + + // folder2 should not be deleted + expect(await Folder.load(folder2Id, { includeDeleted: false })).toBeTruthy(); + + // Should prompt before deleting + await command.action({ 'notebook': 'folder2', options: { permanent: true } }); + expect(promptMock).toHaveBeenCalled(); + expect(await Folder.load(folder2Id, { includeDeleted: false })).toBeUndefined(); + }); +}); + diff --git a/packages/app-cli/app/command-rmbook.ts b/packages/app-cli/app/command-rmbook.ts index 97c648e7f..666a72bcd 100644 --- a/packages/app-cli/app/command-rmbook.ts +++ b/packages/app-cli/app/command-rmbook.ts @@ -3,7 +3,7 @@ import app from './app'; import { _ } from '@joplin/lib/locale'; import Folder from '@joplin/lib/models/Folder'; import BaseModel from '@joplin/lib/BaseModel'; -const { substrWithEllipsis } = require('@joplin/lib/string-utils'); +import { substrWithEllipsis } from '@joplin/lib/string-utils'; class Command extends BaseCommand { public override usage() { @@ -15,7 +15,10 @@ class Command extends BaseCommand { } public override options() { - return [['-f, --force', _('Deletes the notebook without asking for confirmation.')]]; + return [ + ['-f, --force', _('Deletes the notebook without asking for confirmation.')], + ['-p, --permanent', _('Permanently deletes the notebook, skipping the trash.')], + ]; } public override async action(args: any) { @@ -24,11 +27,19 @@ class Command extends BaseCommand { const folder = await app().loadItem(BaseModel.TYPE_FOLDER, pattern); if (!folder) throw new Error(_('Cannot find "%s".', pattern)); - const msg = _('Move notebook "%s" to the trash?\n\nAll notes and sub-notebooks within this notebook will also be moved to the trash.', substrWithEllipsis(folder.title, 0, 32)); + + const permanent = args.options?.permanent === true || !!folder.deleted_time; + const ellipsizedFolderTitle = substrWithEllipsis(folder.title, 0, 32); + let msg; + if (permanent) { + msg = _('Permanently delete notebook "%s"?\n\nAll notes and sub-notebooks within this notebook will be permanently deleted.', ellipsizedFolderTitle); + } else { + msg = _('Move notebook "%s" to the trash?\n\nAll notes and sub-notebooks within this notebook will also be moved to the trash.', ellipsizedFolderTitle); + } const ok = force ? true : await this.prompt(msg, { booleanAnswerDefault: 'n' }); if (!ok) return; - await Folder.delete(folder.id, { toTrash: true, sourceDescription: 'rmbook command' }); + await Folder.delete(folder.id, { toTrash: !permanent, sourceDescription: 'rmbook command' }); } } diff --git a/packages/app-cli/app/command-rmnote.test.ts b/packages/app-cli/app/command-rmnote.test.ts new file mode 100644 index 000000000..f22606224 --- /dev/null +++ b/packages/app-cli/app/command-rmnote.test.ts @@ -0,0 +1,57 @@ +import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils'; +import { setupCommandForTesting, setupApplication } from './utils/testUtils'; +import Note from '@joplin/lib/models/Note'; +import Folder from '@joplin/lib/models/Folder'; +import app from './app'; +import { getTrashFolderId } from '@joplin/lib/services/trash'; +const Command = require('./command-rmnote'); + +const setUpCommand = () => { + const command = setupCommandForTesting(Command); + const promptMock = jest.fn(() => true); + command.setPrompt(promptMock); + + return { command, promptMock }; +}; + +const createTestNote = async () => { + const folder = await Folder.save({ title: 'folder' }); + app().switchCurrentFolder(folder); + return await Note.save({ title: 'note1', parent_id: folder.id }); +}; + + +describe('command-rmnote', () => { + beforeEach(async () => { + await setupDatabaseAndSynchronizer(1); + await switchClient(1); + await setupApplication(); + }); + + it('should move to the trash by default, without prompting', async () => { + const { id: noteId } = await createTestNote(); + + const { command, promptMock } = setUpCommand(); + await command.action({ 'note-pattern': 'note1', options: {} }); + expect(promptMock).not.toHaveBeenCalled(); + + expect((await Note.allItemsInTrash()).noteIds.includes(noteId)).toBe(true); + }); + + it('should permanently delete trashed items by default, with prompting', async () => { + const { id: noteId } = await createTestNote(); + const { command, promptMock } = setUpCommand(); + + // Should not prompt when deleting from a folder + await command.action({ 'note-pattern': 'note1', options: {} }); + expect(promptMock).toHaveBeenCalledTimes(0); + + // Should prompt when deleting from trash + app().switchCurrentFolder(await Folder.load(getTrashFolderId())); + await command.action({ 'note-pattern': 'note1', options: {} }); + expect(promptMock).toHaveBeenCalledTimes(1); + + expect(await Note.load(noteId, { includeDeleted: true })).toBe(undefined); + }); +}); + diff --git a/packages/app-cli/app/command-rmnote.ts b/packages/app-cli/app/command-rmnote.ts index d48e7b081..60839fe12 100644 --- a/packages/app-cli/app/command-rmnote.ts +++ b/packages/app-cli/app/command-rmnote.ts @@ -2,7 +2,7 @@ import BaseCommand from './base-command'; import app from './app'; import { _ } from '@joplin/lib/locale'; import Note from '@joplin/lib/models/Note'; -import BaseModel from '@joplin/lib/BaseModel'; +import BaseModel, { DeleteOptions } from '@joplin/lib/BaseModel'; import { NoteEntity } from '@joplin/lib/services/database/types'; class Command extends BaseCommand { @@ -15,7 +15,10 @@ class Command extends BaseCommand { } public override options() { - return [['-f, --force', _('Deletes the notes without asking for confirmation.')]]; + return [ + ['-f, --force', _('Deletes the notes without asking for confirmation.')], + ['-p, --permanent', _('Deletes notes permanently, skipping the trash.')], + ]; } public override async action(args: any) { @@ -30,10 +33,22 @@ class Command extends BaseCommand { ok = await this.prompt(_('%d notes match this pattern. Delete them?', notes.length), { booleanAnswerDefault: 'n' }); } + const permanent = (args.options?.permanent === true) || notes.every(n => !!n.deleted_time); + if (!force && permanent) { + const message = ( + notes.length === 1 ? _('This will permanently delete the note "%s". Continue?', notes[0].title) : _('%d notes will be permanently deleted. Continue?', notes.length) + ); + ok = await this.prompt(message, { booleanAnswerDefault: 'n' }); + } + if (!ok) return; const ids = notes.map(n => n.id); - await Note.batchDelete(ids, { toTrash: true, sourceDescription: 'rmnote command' }); + const options: DeleteOptions = { + toTrash: !permanent, + sourceDescription: 'rmnote', + }; + await Note.batchDelete(ids, options); } } diff --git a/packages/tools/cspell/dictionary4.txt b/packages/tools/cspell/dictionary4.txt index 368dc3ef4..23e180aa6 100644 --- a/packages/tools/cspell/dictionary4.txt +++ b/packages/tools/cspell/dictionary4.txt @@ -95,4 +95,5 @@ Notyf unresponded activeline Prec -Trashable +ellipsized +Trashable \ No newline at end of file