1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-21 09:38:01 +02:00

CLI: Resolves #10090: Allow deleting notes and notebooks permanently (#10107)

Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
This commit is contained in:
Henry Heino 2024-03-14 11:38:07 -07:00 committed by GitHub
parent 49cd17e520
commit 78b8839ae3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 177 additions and 8 deletions

View File

@ -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

2
.gitignore vendored
View File

@ -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

View File

@ -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();
});
});

View File

@ -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' });
}
}

View File

@ -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);
});
});

View File

@ -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);
}
}

View File

@ -95,4 +95,5 @@ Notyf
unresponded
activeline
Prec
Trashable
ellipsized
Trashable