diff --git a/.eslintignore b/.eslintignore index 3fc5feac7d..33e13ac604 100644 --- a/.eslintignore +++ b/.eslintignore @@ -109,6 +109,7 @@ packages/app-cli/app/command-mkbook.test.js 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.js packages/app-cli/app/command-rmnote.js packages/app-cli/app/command-set.js @@ -117,6 +118,7 @@ packages/app-cli/app/command-sync.js packages/app-cli/app/command-testing.js packages/app-cli/app/command-use.js packages/app-cli/app/command-version.js +packages/app-cli/app/gui/FolderListWidget.js packages/app-cli/app/gui/StatusBarWidget.js packages/app-cli/app/services/plugins/PluginRunner.js packages/app-cli/app/setupCommand.js @@ -143,6 +145,7 @@ packages/app-desktop/bridge.js 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/exportFolders.js packages/app-desktop/commands/exportNotes.js packages/app-desktop/commands/focusElement.js @@ -217,10 +220,13 @@ packages/app-desktop/gui/MainScreen/commands/openItem.js packages/app-desktop/gui/MainScreen/commands/openNote.js packages/app-desktop/gui/MainScreen/commands/openPdfViewer.js packages/app-desktop/gui/MainScreen/commands/openTag.js +packages/app-desktop/gui/MainScreen/commands/permanentlyDeleteNote.js packages/app-desktop/gui/MainScreen/commands/print.js packages/app-desktop/gui/MainScreen/commands/renameFolder.js packages/app-desktop/gui/MainScreen/commands/renameTag.js packages/app-desktop/gui/MainScreen/commands/resetLayout.js +packages/app-desktop/gui/MainScreen/commands/restoreFolder.js +packages/app-desktop/gui/MainScreen/commands/restoreNote.js packages/app-desktop/gui/MainScreen/commands/revealResourceFile.js packages/app-desktop/gui/MainScreen/commands/search.js packages/app-desktop/gui/MainScreen/commands/setTags.js @@ -346,6 +352,7 @@ packages/app-desktop/gui/NoteSearchBar.js packages/app-desktop/gui/NoteStatusBar.js packages/app-desktop/gui/NoteTextViewer.js packages/app-desktop/gui/NoteToolbar/NoteToolbar.js +packages/app-desktop/gui/NotyfContext.js packages/app-desktop/gui/OneDriveLoginScreen.js packages/app-desktop/gui/PasswordInput/PasswordInput.js packages/app-desktop/gui/PdfViewer.js @@ -390,6 +397,7 @@ packages/app-desktop/gui/ToolbarBase.js packages/app-desktop/gui/ToolbarButton/ToolbarButton.js packages/app-desktop/gui/ToolbarButton/styles/index.js packages/app-desktop/gui/ToolbarSpace.js +packages/app-desktop/gui/TrashNotification/TrashNotification.js packages/app-desktop/gui/dialogs.js packages/app-desktop/gui/hooks/useEffectDebugger.js packages/app-desktop/gui/hooks/useImperativeHandlerDebugger.js @@ -682,6 +690,7 @@ packages/lib/components/shared/config/shouldShowMissingPasswordWarning.test.js packages/lib/components/shared/config/shouldShowMissingPasswordWarning.js packages/lib/components/shared/note-screen-shared.js packages/lib/components/shared/reduxSharedMiddleware.js +packages/lib/components/shared/side-menu-shared.test.js packages/lib/components/shared/side-menu-shared.js packages/lib/database-driver-better-sqlite.js packages/lib/database.js @@ -751,6 +760,8 @@ packages/lib/models/settings/FileHandler.js packages/lib/models/settings/settingValidations.js packages/lib/models/utils/isItemId.js packages/lib/models/utils/itemCanBeEncrypted.js +packages/lib/models/utils/onFolderDrop.test.js +packages/lib/models/utils/onFolderDrop.js packages/lib/models/utils/paginatedFeed.js packages/lib/models/utils/paginationToSql.js packages/lib/models/utils/readOnly.js @@ -923,6 +934,7 @@ packages/lib/services/rest/actionApi.desktop.js packages/lib/services/rest/routes/auth.js packages/lib/services/rest/routes/events.test.js packages/lib/services/rest/routes/events.js +packages/lib/services/rest/routes/folders.test.js packages/lib/services/rest/routes/folders.js packages/lib/services/rest/routes/master_keys.js packages/lib/services/rest/routes/notes.test.js @@ -991,6 +1003,14 @@ packages/lib/services/synchronizer/utils/handleSyncStartupOperation.js packages/lib/services/synchronizer/utils/resourceRemotePath.js packages/lib/services/synchronizer/utils/syncDeleteStep.js packages/lib/services/synchronizer/utils/types.js +packages/lib/services/trash/emptyTrash.test.js +packages/lib/services/trash/emptyTrash.js +packages/lib/services/trash/index.test.js +packages/lib/services/trash/index.js +packages/lib/services/trash/permanentlyDeleteOldItems.test.js +packages/lib/services/trash/permanentlyDeleteOldItems.js +packages/lib/services/trash/restoreItems.test.js +packages/lib/services/trash/restoreItems.js packages/lib/shim-init-node.js packages/lib/shim.js packages/lib/string-utils.test.js diff --git a/.gitignore b/.gitignore index 272bd9d200..f280e0272c 100644 --- a/.gitignore +++ b/.gitignore @@ -89,6 +89,7 @@ packages/app-cli/app/command-mkbook.test.js 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.js packages/app-cli/app/command-rmnote.js packages/app-cli/app/command-set.js @@ -97,6 +98,7 @@ packages/app-cli/app/command-sync.js packages/app-cli/app/command-testing.js packages/app-cli/app/command-use.js packages/app-cli/app/command-version.js +packages/app-cli/app/gui/FolderListWidget.js packages/app-cli/app/gui/StatusBarWidget.js packages/app-cli/app/services/plugins/PluginRunner.js packages/app-cli/app/setupCommand.js @@ -123,6 +125,7 @@ packages/app-desktop/bridge.js 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/exportFolders.js packages/app-desktop/commands/exportNotes.js packages/app-desktop/commands/focusElement.js @@ -197,10 +200,13 @@ packages/app-desktop/gui/MainScreen/commands/openItem.js packages/app-desktop/gui/MainScreen/commands/openNote.js packages/app-desktop/gui/MainScreen/commands/openPdfViewer.js packages/app-desktop/gui/MainScreen/commands/openTag.js +packages/app-desktop/gui/MainScreen/commands/permanentlyDeleteNote.js packages/app-desktop/gui/MainScreen/commands/print.js packages/app-desktop/gui/MainScreen/commands/renameFolder.js packages/app-desktop/gui/MainScreen/commands/renameTag.js packages/app-desktop/gui/MainScreen/commands/resetLayout.js +packages/app-desktop/gui/MainScreen/commands/restoreFolder.js +packages/app-desktop/gui/MainScreen/commands/restoreNote.js packages/app-desktop/gui/MainScreen/commands/revealResourceFile.js packages/app-desktop/gui/MainScreen/commands/search.js packages/app-desktop/gui/MainScreen/commands/setTags.js @@ -326,6 +332,7 @@ packages/app-desktop/gui/NoteSearchBar.js packages/app-desktop/gui/NoteStatusBar.js packages/app-desktop/gui/NoteTextViewer.js packages/app-desktop/gui/NoteToolbar/NoteToolbar.js +packages/app-desktop/gui/NotyfContext.js packages/app-desktop/gui/OneDriveLoginScreen.js packages/app-desktop/gui/PasswordInput/PasswordInput.js packages/app-desktop/gui/PdfViewer.js @@ -370,6 +377,7 @@ packages/app-desktop/gui/ToolbarBase.js packages/app-desktop/gui/ToolbarButton/ToolbarButton.js packages/app-desktop/gui/ToolbarButton/styles/index.js packages/app-desktop/gui/ToolbarSpace.js +packages/app-desktop/gui/TrashNotification/TrashNotification.js packages/app-desktop/gui/dialogs.js packages/app-desktop/gui/hooks/useEffectDebugger.js packages/app-desktop/gui/hooks/useImperativeHandlerDebugger.js @@ -662,6 +670,7 @@ packages/lib/components/shared/config/shouldShowMissingPasswordWarning.test.js packages/lib/components/shared/config/shouldShowMissingPasswordWarning.js packages/lib/components/shared/note-screen-shared.js packages/lib/components/shared/reduxSharedMiddleware.js +packages/lib/components/shared/side-menu-shared.test.js packages/lib/components/shared/side-menu-shared.js packages/lib/database-driver-better-sqlite.js packages/lib/database.js @@ -731,6 +740,8 @@ packages/lib/models/settings/FileHandler.js packages/lib/models/settings/settingValidations.js packages/lib/models/utils/isItemId.js packages/lib/models/utils/itemCanBeEncrypted.js +packages/lib/models/utils/onFolderDrop.test.js +packages/lib/models/utils/onFolderDrop.js packages/lib/models/utils/paginatedFeed.js packages/lib/models/utils/paginationToSql.js packages/lib/models/utils/readOnly.js @@ -903,6 +914,7 @@ packages/lib/services/rest/actionApi.desktop.js packages/lib/services/rest/routes/auth.js packages/lib/services/rest/routes/events.test.js packages/lib/services/rest/routes/events.js +packages/lib/services/rest/routes/folders.test.js packages/lib/services/rest/routes/folders.js packages/lib/services/rest/routes/master_keys.js packages/lib/services/rest/routes/notes.test.js @@ -971,6 +983,14 @@ packages/lib/services/synchronizer/utils/handleSyncStartupOperation.js packages/lib/services/synchronizer/utils/resourceRemotePath.js packages/lib/services/synchronizer/utils/syncDeleteStep.js packages/lib/services/synchronizer/utils/types.js +packages/lib/services/trash/emptyTrash.test.js +packages/lib/services/trash/emptyTrash.js +packages/lib/services/trash/index.test.js +packages/lib/services/trash/index.js +packages/lib/services/trash/permanentlyDeleteOldItems.test.js +packages/lib/services/trash/permanentlyDeleteOldItems.js +packages/lib/services/trash/restoreItems.test.js +packages/lib/services/trash/restoreItems.js packages/lib/shim-init-node.js packages/lib/shim.js packages/lib/string-utils.test.js diff --git a/packages/app-cli/app/app-gui.js b/packages/app-cli/app/app-gui.js index f8d81fc46b..93c46fb69b 100644 --- a/packages/app-cli/app/app-gui.js +++ b/packages/app-cli/app/app-gui.js @@ -31,7 +31,7 @@ const WindowWidget = require('tkwidgets/WindowWidget.js'); const NoteWidget = require('./gui/NoteWidget.js'); const ResourceServer = require('./ResourceServer.js'); const NoteMetadataWidget = require('./gui/NoteMetadataWidget.js'); -const FolderListWidget = require('./gui/FolderListWidget.js'); +const FolderListWidget = require('./gui/FolderListWidget').default; const NoteListWidget = require('./gui/NoteListWidget.js'); const StatusBarWidget = require('./gui/StatusBarWidget').default; const ConsoleWidget = require('./gui/ConsoleWidget.js'); diff --git a/packages/app-cli/app/app.js b/packages/app-cli/app/app.js deleted file mode 100644 index 1d5dbb74cb..0000000000 --- a/packages/app-cli/app/app.js +++ /dev/null @@ -1,461 +0,0 @@ -"use strict"; -var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { - function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } - return new (P || (P = Promise))(function (resolve, reject) { - function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } - function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } - function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } - step((generator = generator.apply(thisArg, _arguments || [])).next()); - }); -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const BaseApplication_1 = require("@joplin/lib/BaseApplication"); -const folders_screen_utils_js_1 = require("@joplin/lib/folders-screen-utils.js"); -const ResourceService_1 = require("@joplin/lib/services/ResourceService"); -const BaseModel_1 = require("@joplin/lib/BaseModel"); -const Folder_1 = require("@joplin/lib/models/Folder"); -const BaseItem_1 = require("@joplin/lib/models/BaseItem"); -const Note_1 = require("@joplin/lib/models/Note"); -const Tag_1 = require("@joplin/lib/models/Tag"); -const Setting_1 = require("@joplin/lib/models/Setting"); -const registry_js_1 = require("@joplin/lib/registry.js"); -const path_utils_1 = require("@joplin/lib/path-utils"); -const utils_1 = require("@joplin/utils"); -const locale_1 = require("@joplin/lib/locale"); -const fs_extra_1 = require("fs-extra"); -const RevisionService_1 = require("@joplin/lib/services/RevisionService"); -const shim_1 = require("@joplin/lib/shim"); -const setupCommand_1 = require("./setupCommand"); -const { cliUtils } = require('./cli-utils.js'); -const Cache = require('@joplin/lib/Cache'); -const { splitCommandBatch } = require('@joplin/lib/string-utils'); -class Application extends BaseApplication_1.default { - constructor() { - super(...arguments); - this.commands_ = {}; - this.commandMetadata_ = null; - this.activeCommand_ = null; - this.allCommandsLoaded_ = false; - this.gui_ = null; - this.cache_ = new Cache(); - } - gui() { - return this.gui_; - } - commandStdoutMaxWidth() { - return this.gui().stdoutMaxWidth(); - } - guessTypeAndLoadItem(pattern, options = null) { - return __awaiter(this, void 0, void 0, function* () { - let type = BaseModel_1.default.TYPE_NOTE; - if (pattern.indexOf('/') === 0) { - type = BaseModel_1.default.TYPE_FOLDER; - pattern = pattern.substr(1); - } - return this.loadItem(type, pattern, options); - }); - } - loadItem(type, pattern, options = null) { - return __awaiter(this, void 0, void 0, function* () { - const output = yield this.loadItems(type, pattern, options); - if (output.length > 1) { - // output.sort((a, b) => { return a.user_updated_time < b.user_updated_time ? +1 : -1; }); - // let answers = { 0: _('[Cancel]') }; - // for (let i = 0; i < output.length; i++) { - // answers[i + 1] = output[i].title; - // } - // Not really useful with new UI? - throw new Error((0, locale_1._)('More than one item match "%s". Please narrow down your query.', pattern)); - // let msg = _('More than one item match "%s". Please select one:', pattern); - // const response = await cliUtils.promptMcq(msg, answers); - // if (!response) return null; - // return output[response - 1]; - } - else { - return output.length ? output[0] : null; - } - }); - } - loadItems(type, pattern, options = null) { - return __awaiter(this, void 0, void 0, function* () { - if (type === 'folderOrNote') { - const folders = yield this.loadItems(BaseModel_1.default.TYPE_FOLDER, pattern, options); - if (folders.length) - return folders; - return yield this.loadItems(BaseModel_1.default.TYPE_NOTE, pattern, options); - } - pattern = pattern ? pattern.toString() : ''; - if (type === BaseModel_1.default.TYPE_FOLDER && (pattern === Folder_1.default.conflictFolderTitle() || pattern === Folder_1.default.conflictFolderId())) - return [Folder_1.default.conflictFolder()]; - if (!options) - options = {}; - const parent = options.parent ? options.parent : app().currentFolder(); - const ItemClass = BaseItem_1.default.itemClass(type); - if (type === BaseModel_1.default.TYPE_NOTE && pattern.indexOf('*') >= 0) { - // Handle it as pattern - if (!parent) - throw new Error((0, locale_1._)('No notebook selected.')); - return yield Note_1.default.previews(parent.id, { titlePattern: pattern }); - } - else { - // Single item - let item = null; - if (type === BaseModel_1.default.TYPE_NOTE) { - if (!parent) - throw new Error((0, locale_1._)('No notebook has been specified.')); - item = yield ItemClass.loadFolderNoteByField(parent.id, 'title', pattern); - } - else { - item = yield ItemClass.loadByTitle(pattern); - } - if (item) - return [item]; - item = yield ItemClass.load(pattern); // Load by id - if (item) - return [item]; - if (pattern.length >= 2) { - return yield ItemClass.loadByPartialId(pattern); - } - } - return []; - }); - } - setupCommand(cmd) { - return (0, setupCommand_1.default)(cmd, (t) => this.stdout(t), () => this.store(), () => this.gui()); - } - stdout(text) { - return this.gui().stdout(text); - } - exit(code = 0) { - const _super = Object.create(null, { - exit: { get: () => super.exit } - }); - return __awaiter(this, void 0, void 0, function* () { - const doExit = () => __awaiter(this, void 0, void 0, function* () { - this.gui().exit(); - yield _super.exit.call(this, code); - }); - // Give it a few seconds to cancel otherwise exit anyway - shim_1.default.setTimeout(() => __awaiter(this, void 0, void 0, function* () { - yield doExit(); - }), 5000); - if (yield registry_js_1.reg.syncTarget().syncStarted()) { - this.stdout((0, locale_1._)('Cancelling background synchronisation... Please wait.')); - const sync = yield registry_js_1.reg.syncTarget().synchronizer(); - yield sync.cancel(); - } - yield doExit(); - }); - } - commands(uiType = null) { - if (!this.allCommandsLoaded_) { - // eslint-disable-next-line github/array-foreach -- Old code before rule was applied - (0, fs_extra_1.readdirSync)(__dirname).forEach(path => { - if (path.indexOf('command-') !== 0) - return; - if (path.endsWith('.test.js')) - return; - const ext = (0, path_utils_1.fileExtension)(path); - if (ext !== 'js') - return; - const CommandClass = require(`./${path}`); - let cmd = new CommandClass(); - if (!cmd.enabled()) - return; - cmd = this.setupCommand(cmd); - this.commands_[cmd.name()] = cmd; - }); - this.allCommandsLoaded_ = true; - } - if (uiType !== null) { - const temp = {}; - for (const n in this.commands_) { - if (!this.commands_.hasOwnProperty(n)) - continue; - const c = this.commands_[n]; - if (!c.supportsUi(uiType)) - continue; - temp[n] = c; - } - return temp; - } - return this.commands_; - } - commandNames() { - return __awaiter(this, void 0, void 0, function* () { - const metadata = yield this.commandMetadata(); - const output = []; - for (const n in metadata) { - if (!metadata.hasOwnProperty(n)) - continue; - output.push(n); - } - return output; - }); - } - commandMetadata() { - return __awaiter(this, void 0, void 0, function* () { - if (this.commandMetadata_) - return this.commandMetadata_; - let output = yield this.cache_.getItem('metadata'); - if (output) { - this.commandMetadata_ = output; - return Object.assign({}, this.commandMetadata_); - } - const commands = this.commands(); - output = {}; - for (const n in commands) { - if (!commands.hasOwnProperty(n)) - continue; - const cmd = commands[n]; - output[n] = cmd.metadata(); - } - yield this.cache_.setItem('metadata', output, 1000 * 60 * 60 * 24); - this.commandMetadata_ = output; - return Object.assign({}, this.commandMetadata_); - }); - } - hasGui() { - return this.gui() && !this.gui().isDummy(); - } - findCommandByName(name) { - if (this.commands_[name]) - return this.commands_[name]; - let CommandClass = null; - try { - CommandClass = require(`${__dirname}/command-${name}.js`); - } - catch (error) { - if (error.message && error.message.indexOf('Cannot find module') >= 0) { - const e = new Error((0, locale_1._)('No such command: %s', name)); - e.type = 'notFound'; - throw e; - } - else { - throw error; - } - } - let cmd = new CommandClass(); - cmd = this.setupCommand(cmd); - this.commands_[name] = cmd; - return this.commands_[name]; - } - dummyGui() { - return { - isDummy: () => { - return true; - }, - prompt: (initialText = '', promptString = '', options = null) => { - return cliUtils.prompt(initialText, promptString, options); - }, - showConsole: () => { }, - maximizeConsole: () => { }, - stdout: (text) => { - // eslint-disable-next-line no-console - console.info(text); - }, - fullScreen: () => { }, - exit: () => { }, - showModalOverlay: () => { }, - hideModalOverlay: () => { }, - stdoutMaxWidth: () => { - return 100; - }, - forceRender: () => { }, - termSaveState: () => { }, - termRestoreState: () => { }, - }; - } - execCommand(argv) { - return __awaiter(this, void 0, void 0, function* () { - if (!argv.length) - return this.execCommand(['help']); - // reg.logger().debug('execCommand()', argv); - const commandName = argv[0]; - this.activeCommand_ = this.findCommandByName(commandName); - let outException = null; - try { - if (this.gui().isDummy() && !this.activeCommand_.supportsUi('cli')) - throw new Error((0, locale_1._)('The command "%s" is only available in GUI mode', this.activeCommand_.name())); - const cmdArgs = cliUtils.makeCommandArgs(this.activeCommand_, argv); - yield this.activeCommand_.action(cmdArgs); - } - catch (error) { - outException = error; - } - this.activeCommand_ = null; - if (outException) - throw outException; - }); - } - currentCommand() { - return this.activeCommand_; - } - loadKeymaps() { - return __awaiter(this, void 0, void 0, function* () { - const defaultKeyMap = [ - { keys: [':'], type: 'function', command: 'enter_command_line_mode' }, - { keys: ['TAB'], type: 'function', command: 'focus_next' }, - { keys: ['SHIFT_TAB'], type: 'function', command: 'focus_previous' }, - { keys: ['UP'], type: 'function', command: 'move_up' }, - { keys: ['DOWN'], type: 'function', command: 'move_down' }, - { keys: ['PAGE_UP'], type: 'function', command: 'page_up' }, - { keys: ['PAGE_DOWN'], type: 'function', command: 'page_down' }, - { keys: ['ENTER'], type: 'function', command: 'activate' }, - { keys: ['DELETE', 'BACKSPACE'], type: 'function', command: 'delete' }, - { keys: ['n'], type: 'function', command: 'next_link' }, - { keys: ['b'], type: 'function', command: 'previous_link' }, - { keys: ['o'], type: 'function', command: 'open_link' }, - { keys: [' '], type: 'prompt', command: 'todo toggle $n' }, - { keys: ['tc'], type: 'function', command: 'toggle_console' }, - { keys: ['tm'], type: 'function', command: 'toggle_metadata' }, - { keys: ['ti'], type: 'function', command: 'toggle_ids' }, - { keys: ['/'], type: 'prompt', command: 'search ""', cursorPosition: -2 }, - { keys: ['mn'], type: 'prompt', command: 'mknote ""', cursorPosition: -2 }, - { keys: ['mt'], type: 'prompt', command: 'mktodo ""', cursorPosition: -2 }, - { keys: ['mb'], type: 'prompt', command: 'mkbook ""', cursorPosition: -2 }, - { keys: ['yn'], type: 'prompt', command: 'cp $n ""', cursorPosition: -2 }, - { keys: ['dn'], type: 'prompt', command: 'mv $n ""', cursorPosition: -2 }, - ]; - // Filter the keymap item by command so that items in keymap.json can override - // the default ones. - const itemsByCommand = {}; - for (let i = 0; i < defaultKeyMap.length; i++) { - itemsByCommand[defaultKeyMap[i].command] = defaultKeyMap[i]; - } - const filePath = `${Setting_1.default.value('profileDir')}/keymap.json`; - if (yield (0, fs_extra_1.pathExists)(filePath)) { - try { - let configString = yield (0, fs_extra_1.readFile)(filePath, 'utf-8'); - configString = configString.replace(/^\s*\/\/.*/, ''); // Strip off comments - const keymap = JSON.parse(configString); - for (let keymapIndex = 0; keymapIndex < keymap.length; keymapIndex++) { - const item = keymap[keymapIndex]; - itemsByCommand[item.command] = item; - } - } - catch (error) { - let msg = error.message ? error.message : ''; - msg = `Could not load keymap ${filePath}\n${msg}`; - error.message = msg; - throw error; - } - } - const output = []; - for (const n in itemsByCommand) { - if (!itemsByCommand.hasOwnProperty(n)) - continue; - output.push(itemsByCommand[n]); - } - // Map reserved shortcuts to their equivalent key - // https://github.com/cronvel/terminal-kit/issues/101 - for (let i = 0; i < output.length; i++) { - const newKeys = output[i].keys.map(k => { - k = k.replace(/CTRL_H/g, 'BACKSPACE'); - k = k.replace(/CTRL_I/g, 'TAB'); - k = k.replace(/CTRL_M/g, 'ENTER'); - return k; - }); - output[i].keys = newKeys; - } - return output; - }); - } - commandList(argv) { - return __awaiter(this, void 0, void 0, function* () { - if (argv.length && argv[0] === 'batch') { - const commands = []; - const commandLines = splitCommandBatch(yield (0, fs_extra_1.readFile)(argv[1], 'utf-8')); - for (const commandLine of commandLines) { - if (!commandLine.trim()) - continue; - const splitted = (0, utils_1.splitCommandString)(commandLine.trim()); - commands.push(splitted); - } - return commands; - } - else { - return [argv]; - } - }); - } - // We need this special case here because by the time the `version` command - // runs, the keychain has already been setup. - checkIfKeychainEnabled(argv) { - return argv.indexOf('version') < 0; - } - start(argv) { - const _super = Object.create(null, { - start: { get: () => super.start } - }); - return __awaiter(this, void 0, void 0, function* () { - const keychainEnabled = this.checkIfKeychainEnabled(argv); - argv = yield _super.start.call(this, argv, { keychainEnabled }); - cliUtils.setStdout((object) => { - return this.stdout(object); - }); - this.initRedux(); - // If we have some arguments left at this point, it's a command - // so execute it. - if (argv.length) { - this.gui_ = this.dummyGui(); - this.currentFolder_ = yield Folder_1.default.load(Setting_1.default.value('activeFolderId')); - yield this.applySettingsSideEffects(); - try { - const commands = yield this.commandList(argv); - for (const command of commands) { - yield this.execCommand(command); - } - } - catch (error) { - if (this.showStackTraces_) { - console.error(error); - } - else { - // eslint-disable-next-line no-console - console.info(error.message); - } - process.exit(1); - } - yield Setting_1.default.saveAll(); - // Need to call exit() explicitly, otherwise Node wait for any timeout to complete - // https://stackoverflow.com/questions/18050095 - process.exit(0); - } - else { - // Otherwise open the GUI - const keymap = yield this.loadKeymaps(); - const AppGui = require('./app-gui.js'); - this.gui_ = new AppGui(this, this.store(), keymap); - this.gui_.setLogger(this.logger()); - yield this.gui_.start(); - // Since the settings need to be loaded before the store is created, it will never - // receive the SETTING_UPDATE_ALL even, which mean state.settings will not be - // initialised. So we manually call dispatchUpdateAll() to force an update. - Setting_1.default.dispatchUpdateAll(); - yield (0, folders_screen_utils_js_1.refreshFolders)((action) => this.store().dispatch(action)); - const tags = yield Tag_1.default.allWithNotes(); - ResourceService_1.default.runInBackground(); - RevisionService_1.default.instance().runInBackground(); - this.dispatch({ - type: 'TAG_UPDATE_ALL', - items: tags, - }); - this.store().dispatch({ - type: 'FOLDER_SELECT', - id: Setting_1.default.value('activeFolderId'), - }); - this.startRotatingLogMaintenance(Setting_1.default.value('profileDir')); - } - }); - } -} -let application_ = null; -function app() { - if (application_) - return application_; - application_ = new Application(); - return application_; -} -exports.default = app; -//# sourceMappingURL=app.js.map \ No newline at end of file diff --git a/packages/app-cli/app/app.ts b/packages/app-cli/app/app.ts index 87640039df..ff09380812 100644 --- a/packages/app-cli/app/app.ts +++ b/packages/app-cli/app/app.ts @@ -307,6 +307,7 @@ class Application extends BaseApplication { { keys: ['tc'], type: 'function', command: 'toggle_console' }, { keys: ['tm'], type: 'function', command: 'toggle_metadata' }, { keys: ['ti'], type: 'function', command: 'toggle_ids' }, + { keys: ['r'], type: 'prompt', command: 'restore $n' }, { keys: ['/'], type: 'prompt', command: 'search ""', cursorPosition: -2 }, { keys: ['mn'], type: 'prompt', command: 'mknote ""', cursorPosition: -2 }, { keys: ['mt'], type: 'prompt', command: 'mktodo ""', cursorPosition: -2 }, diff --git a/packages/app-cli/app/command-apidoc.ts b/packages/app-cli/app/command-apidoc.ts index 2b794dc5b1..8f30e7aca3 100644 --- a/packages/app-cli/app/command-apidoc.ts +++ b/packages/app-cli/app/command-apidoc.ts @@ -400,6 +400,11 @@ async function fetchAllNotes() { lines.push('Remove the tag from the note.'); lines.push(''); } + + if (model.type === BaseModel.TYPE_NOTE || model.type === BaseModel.TYPE_FOLDER) { + lines.push(`By default, the ${singular} will be moved **to the trash**. To permanently delete it, add the query parameter \`permanent=1\``); + lines.push(''); + } } { diff --git a/packages/app-cli/app/command-dump.ts b/packages/app-cli/app/command-dump.ts index d3715c91ec..759eb2b60f 100644 --- a/packages/app-cli/app/command-dump.ts +++ b/packages/app-cli/app/command-dump.ts @@ -2,6 +2,7 @@ import BaseCommand from './base-command'; import Folder from '@joplin/lib/models/Folder'; import Note from '@joplin/lib/models/Note'; import Tag from '@joplin/lib/models/Tag'; +import { FolderEntity, NoteEntity } from '@joplin/lib/services/database/types'; class Command extends BaseCommand { public override usage() { @@ -17,7 +18,7 @@ class Command extends BaseCommand { } public override async action() { - let items = []; + let items: (NoteEntity | FolderEntity)[] = []; const folders = await Folder.all(); for (let i = 0; i < folders.length; i++) { const folder = folders[i]; diff --git a/packages/app-cli/app/command-ls.ts b/packages/app-cli/app/command-ls.ts index c6d720c403..0bfe9454bc 100644 --- a/packages/app-cli/app/command-ls.ts +++ b/packages/app-cli/app/command-ls.ts @@ -7,6 +7,7 @@ import Setting from '@joplin/lib/models/Setting'; import Note from '@joplin/lib/models/Note'; const { sprintf } = require('sprintf-js'); import time from '@joplin/lib/time'; +import { NoteEntity } from '@joplin/lib/services/database/types'; const { cliUtils } = require('./cli-utils.js'); class Command extends BaseCommand { @@ -71,7 +72,7 @@ class Command extends BaseCommand { let hasTodos = false; for (let i = 0; i < items.length; i++) { const item = items[i]; - if (item.is_todo) { + if ((item as NoteEntity).is_todo) { hasTodos = true; break; } @@ -103,8 +104,8 @@ class Command extends BaseCommand { } if (hasTodos) { - if (item.is_todo) { - row.push(sprintf('[%s]', item.todo_completed ? 'X' : ' ')); + if ((item as NoteEntity).is_todo) { + row.push(sprintf('[%s]', (item as NoteEntity).todo_completed ? 'X' : ' ')); } else { row.push(' '); } diff --git a/packages/app-cli/app/command-restore.ts b/packages/app-cli/app/command-restore.ts new file mode 100644 index 0000000000..a5391907d3 --- /dev/null +++ b/packages/app-cli/app/command-restore.ts @@ -0,0 +1,26 @@ +import BaseCommand from './base-command'; +import app from './app'; +import { _ } from '@joplin/lib/locale'; +import restoreItems from '@joplin/lib/services/trash/restoreItems'; + +class Command extends BaseCommand { + public override usage() { + return 'restore '; + } + + public override description() { + return _('Restore the items matching .'); + } + + public override async action(args: any) { + const pattern = args['pattern']; + + const items = await app().loadItems('folderOrNote', pattern); + if (!items.length) throw new Error(_('Cannot find "%s".', pattern)); + + const ids = items.map(n => n.id); + await restoreItems(items[0].type_, ids, { useRestoreFolder: true }); + } +} + +module.exports = Command; diff --git a/packages/app-cli/app/command-rmbook.ts b/packages/app-cli/app/command-rmbook.ts index 5120a0d5cc..3d6df9d999 100644 --- a/packages/app-cli/app/command-rmbook.ts +++ b/packages/app-cli/app/command-rmbook.ts @@ -3,6 +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'); class Command extends BaseCommand { public override usage() { @@ -23,10 +24,11 @@ class Command extends BaseCommand { const folder = await app().loadItem(BaseModel.TYPE_FOLDER, pattern); if (!folder) throw new Error(_('Cannot find "%s".', pattern)); - const ok = force ? true : await this.prompt(_('Delete notebook? All notes and sub-notebooks within this notebook will also be deleted.'), { booleanAnswerDefault: 'n' }); + 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 ok = force ? true : await this.prompt(msg, { booleanAnswerDefault: 'n' }); if (!ok) return; - await Folder.delete(folder.id); + await Folder.delete(folder.id, { toTrash: true }); } } diff --git a/packages/app-cli/app/command-rmnote.ts b/packages/app-cli/app/command-rmnote.ts index 5a3011c23f..8c851572b8 100644 --- a/packages/app-cli/app/command-rmnote.ts +++ b/packages/app-cli/app/command-rmnote.ts @@ -3,6 +3,7 @@ import app from './app'; import { _ } from '@joplin/lib/locale'; import Note from '@joplin/lib/models/Note'; import BaseModel from '@joplin/lib/BaseModel'; +import { NoteEntity } from '@joplin/lib/services/database/types'; class Command extends BaseCommand { public override usage() { @@ -21,13 +22,18 @@ class Command extends BaseCommand { const pattern = args['note-pattern']; const force = args.options && args.options.force === true; - const notes = await app().loadItems(BaseModel.TYPE_NOTE, pattern); + const notes: NoteEntity[] = await app().loadItems(BaseModel.TYPE_NOTE, pattern); if (!notes.length) throw new Error(_('Cannot find "%s".', pattern)); - const ok = force ? true : await this.prompt(notes.length > 1 ? _('%d notes match this pattern. Delete them?', notes.length) : _('Delete note?'), { booleanAnswerDefault: 'n' }); + let ok = true; + if (!force && notes.length > 1) { + ok = await this.prompt(_('%d notes match this pattern. Delete them?', notes.length), { booleanAnswerDefault: 'n' }); + } + if (!ok) return; - const ids = notes.map((n: any) => n.id); - await Note.batchDelete(ids); + + const ids = notes.map(n => n.id); + await Note.batchDelete(ids, { toTrash: true }); } } diff --git a/packages/app-cli/app/gui/FolderListWidget.js b/packages/app-cli/app/gui/FolderListWidget.ts similarity index 64% rename from packages/app-cli/app/gui/FolderListWidget.js rename to packages/app-cli/app/gui/FolderListWidget.ts index bd6cb960a0..93998a1630 100644 --- a/packages/app-cli/app/gui/FolderListWidget.js +++ b/packages/app-cli/app/gui/FolderListWidget.ts @@ -1,16 +1,20 @@ -const Folder = require('@joplin/lib/models/Folder').default; -const Tag = require('@joplin/lib/models/Tag').default; -const BaseModel = require('@joplin/lib/BaseModel').default; +import Folder from '@joplin/lib/models/Folder'; +import Tag from '@joplin/lib/models/Tag'; +import BaseModel from '@joplin/lib/BaseModel'; +import Setting from '@joplin/lib/models/Setting'; +import { _ } from '@joplin/lib/locale'; +import { FolderEntity } from '@joplin/lib/services/database/types'; +import { getDisplayParentId, getTrashFolderId } from '@joplin/lib/services/trash'; const ListWidget = require('tkwidgets/ListWidget.js'); -const Setting = require('@joplin/lib/models/Setting').default; -const _ = require('@joplin/lib/locale')._; -class FolderListWidget extends ListWidget { - constructor() { +export default class FolderListWidget extends ListWidget { + + private folders_: FolderEntity[] = []; + + public constructor() { super(); this.tags_ = []; - this.folders_ = []; this.searches_ = []; this.selectedFolderId_ = null; this.selectedTagId_ = null; @@ -21,7 +25,7 @@ class FolderListWidget extends ListWidget { this.trimItemTitle = false; this.showIds = false; - this.itemRenderer = item => { + this.itemRenderer = (item: any) => { const output = []; if (item === '-') { output.push('-'.repeat(this.innerWidth)); @@ -33,13 +37,12 @@ class FolderListWidget extends ListWidget { } output.push(Folder.displayTitle(item)); - if (Setting.value('showNoteCounts')) { + if (Setting.value('showNoteCounts') && !item.deleted_time && item.id !== getTrashFolderId()) { let noteCount = item.note_count; - // Subtract children note_count from parent folder. if (this.folderHasChildren_(this.folders, item.id)) { for (let i = 0; i < this.folders.length; i++) { if (this.folders[i].parent_id === item.id) { - noteCount -= this.folders[i].note_count; + noteCount -= (this.folders[i] as any).note_count; } } } @@ -56,113 +59,121 @@ class FolderListWidget extends ListWidget { }; } - folderDepth(folders, folderId) { + public folderDepth(folders: FolderEntity[], folderId: string) { let output = 0; while (true) { const folder = BaseModel.byId(folders, folderId); - if (!folder || !folder.parent_id) return output; + const folderParentId = getDisplayParentId(folder, folders.find(f => f.id === folder.parent_id)); + if (!folder || !folderParentId) return output; output++; - folderId = folder.parent_id; + folderId = folderParentId; } } - get selectedFolderId() { + public get selectedFolderId() { return this.selectedFolderId_; } - set selectedFolderId(v) { + public set selectedFolderId(v) { this.selectedFolderId_ = v; this.updateIndexFromSelectedItemId(); this.invalidate(); } - get selectedSearchId() { + public get selectedSearchId() { return this.selectedSearchId_; } - set selectedSearchId(v) { + public set selectedSearchId(v) { this.selectedSearchId_ = v; this.updateIndexFromSelectedItemId(); this.invalidate(); } - get selectedTagId() { + public get selectedTagId() { return this.selectedTagId_; } - set selectedTagId(v) { + public set selectedTagId(v) { this.selectedTagId_ = v; this.updateIndexFromSelectedItemId(); this.invalidate(); } - get notesParentType() { + public get notesParentType() { return this.notesParentType_; } - set notesParentType(v) { + public set notesParentType(v) { this.notesParentType_ = v; this.updateIndexFromSelectedItemId(); this.invalidate(); } - get searches() { + public get searches() { return this.searches_; } - set searches(v) { + public set searches(v) { this.searches_ = v; this.updateItems_ = true; this.updateIndexFromSelectedItemId(); this.invalidate(); } - get tags() { + public get tags() { return this.tags_; } - set tags(v) { + public set tags(v) { this.tags_ = v; this.updateItems_ = true; this.updateIndexFromSelectedItemId(); this.invalidate(); } - get folders() { + public get folders() { return this.folders_; } - set folders(v) { + public set folders(v) { this.folders_ = v; this.updateItems_ = true; this.updateIndexFromSelectedItemId(); this.invalidate(); } - toggleShowIds() { + public toggleShowIds() { this.showIds = !this.showIds; this.invalidate(); } - folderHasChildren_(folders, folderId) { + public folderHasChildren_(folders: FolderEntity[], folderId: string) { for (let i = 0; i < folders.length; i++) { const folder = folders[i]; - if (folder.parent_id === folderId) return true; + const folderParentId = getDisplayParentId(folder, folders.find(f => f.id === folder.parent_id)); + if (folderParentId === folderId) return true; } return false; } - render() { + public render() { if (this.updateItems_) { this.logger().debug('Rebuilding items...', this.notesParentType, this.selectedJoplinItemId, this.selectedSearchId); const wasSelectedItemId = this.selectedJoplinItemId; const previousParentType = this.notesParentType; - let newItems = []; - const orderFolders = parentId => { + this.logger().info('FFFFFFFFFFFFF', JSON.stringify(this.folders, null, 4)); + + let newItems: any[] = []; + const orderFolders = (parentId: string) => { + this.logger().info('PARENT', parentId); for (let i = 0; i < this.folders.length; i++) { const f = this.folders[i]; - const folderParentId = f.parent_id ? f.parent_id : ''; + const originalParent = this.folders_.find(f => f.id === f.parent_id); + + const folderParentId = getDisplayParentId(f, originalParent); // f.parent_id ? f.parent_id : ''; + this.logger().info('FFF', f.title, folderParentId); if (folderParentId === parentId) { newItems.push(f); if (this.folderHasChildren_(this.folders, f.id)) orderFolders(f.id); @@ -192,7 +203,7 @@ class FolderListWidget extends ListWidget { super.render(); } - get selectedJoplinItemId() { + public get selectedJoplinItemId() { if (!this.notesParentType) return ''; if (this.notesParentType === 'Folder') return this.selectedFolderId; if (this.notesParentType === 'Tag') return this.selectedTagId; @@ -200,17 +211,15 @@ class FolderListWidget extends ListWidget { throw new Error(`Unknown parent type: ${this.notesParentType}`); } - get selectedJoplinItem() { + public get selectedJoplinItem() { const id = this.selectedJoplinItemId; const index = this.itemIndexByKey('id', id); return this.itemAt(index); } - updateIndexFromSelectedItemId(itemId = null) { + public updateIndexFromSelectedItemId(itemId: string = null) { if (itemId === null) itemId = this.selectedJoplinItemId; const index = this.itemIndexByKey('id', itemId); this.currentIndex = index >= 0 ? index : 0; } } - -module.exports = FolderListWidget; diff --git a/packages/app-desktop/bridge.ts b/packages/app-desktop/bridge.ts index f31bc970ef..09a8fbe6d1 100644 --- a/packages/app-desktop/bridge.ts +++ b/packages/app-desktop/bridge.ts @@ -1,7 +1,7 @@ import ElectronAppWrapper from './ElectronAppWrapper'; import shim from '@joplin/lib/shim'; import { _, setLocale } from '@joplin/lib/locale'; -import { BrowserWindow, nativeTheme, nativeImage, shell } from 'electron'; +import { BrowserWindow, nativeTheme, nativeImage, dialog, shell, MessageBoxSyncOptions } from 'electron'; import { dirname, isUncPath, toSystemSlashes } from '@joplin/lib/path-utils'; import { fileUriToPath } from '@joplin/utils/url'; import { urlDecode } from '@joplin/lib/string-utils'; @@ -23,6 +23,10 @@ interface OpenDialogOptions { filters?: any[]; } +interface MessageDialogOptions extends Omit { + message?: string; +} + export class Bridge { private electronWrapper_: ElectronAppWrapper; @@ -228,7 +232,6 @@ export class Bridge { } public async showSaveDialog(options: any) { - const { dialog } = require('electron'); if (!options) options = {}; if (!('defaultPath' in options) && this.lastSelectedPaths_.file) options.defaultPath = this.lastSelectedPaths_.file; const { filePath } = await dialog.showSaveDialog(this.window(), options); @@ -239,7 +242,6 @@ export class Bridge { } public async showOpenDialog(options: OpenDialogOptions = null) { - const { dialog } = require('electron'); if (!options) options = {}; let fileType = 'file'; if (options.properties && options.properties.includes('openDirectory')) fileType = 'directory'; @@ -253,13 +255,12 @@ export class Bridge { } // Don't use this directly - call one of the showXxxxxxxMessageBox() instead - private showMessageBox_(window: any, options: any): number { - const { dialog } = require('electron'); + private showMessageBox_(window: any, options: MessageDialogOptions): number { if (!window) window = this.window(); - return dialog.showMessageBoxSync(window, options); + return dialog.showMessageBoxSync(window, { message: '', ...options }); } - public showErrorMessageBox(message: string, options: any = null) { + public showErrorMessageBox(message: string, options: MessageDialogOptions = null) { options = { buttons: [_('OK')], ...options, @@ -272,7 +273,7 @@ export class Bridge { }); } - public showConfirmMessageBox(message: string, options: any = null) { + public showConfirmMessageBox(message: string, options: MessageDialogOptions = null) { options = { buttons: [_('OK'), _('Cancel')], ...options, @@ -287,8 +288,8 @@ export class Bridge { } /* returns the index of the clicked button */ - public showMessageBox(message: string, options: any = null) { - if (options === null) options = {}; + public showMessageBox(message: string, options: MessageDialogOptions = null) { + if (options === null) options = { message: '' }; const result = this.showMessageBox_(this.window(), { type: 'question', message: message, diff --git a/packages/app-desktop/commands/emptyTrash.ts b/packages/app-desktop/commands/emptyTrash.ts new file mode 100644 index 0000000000..1a4be0f13e --- /dev/null +++ b/packages/app-desktop/commands/emptyTrash.ts @@ -0,0 +1,24 @@ +import { CommandRuntime, CommandDeclaration } from '@joplin/lib/services/CommandService'; +import { _ } from '@joplin/lib/locale'; +import emptyTrash from '@joplin/lib/services/trash/emptyTrash'; +import bridge from '../services/bridge'; + +export const declaration: CommandDeclaration = { + name: 'emptyTrash', + label: () => _('Empty trash'), +}; + +export const runtime = (): CommandRuntime => { + return { + execute: async () => { + const ok = await bridge().showConfirmMessageBox(_('This will permanently delete all items in the trash. Continue?'), { + buttons: [ + _('Empty trash'), + _('Cancel'), + ], + }); + + if (ok) await emptyTrash(); + }, + }; +}; diff --git a/packages/app-desktop/commands/index.ts b/packages/app-desktop/commands/index.ts index 52f0fa55ac..449a3ddbe7 100644 --- a/packages/app-desktop/commands/index.ts +++ b/packages/app-desktop/commands/index.ts @@ -1,6 +1,7 @@ // AUTO-GENERATED using `gulp buildScriptIndexes` import * as copyDevCommand from './copyDevCommand'; import * as editProfileConfig from './editProfileConfig'; +import * as emptyTrash from './emptyTrash'; import * as exportFolders from './exportFolders'; import * as exportNotes from './exportNotes'; import * as focusElement from './focusElement'; @@ -19,6 +20,7 @@ import * as toggleSafeMode from './toggleSafeMode'; const index: any[] = [ copyDevCommand, editProfileConfig, + emptyTrash, exportFolders, exportNotes, focusElement, diff --git a/packages/app-desktop/gui/MainScreen/MainScreen.tsx b/packages/app-desktop/gui/MainScreen/MainScreen.tsx index bb9e739329..b29b2c2a77 100644 --- a/packages/app-desktop/gui/MainScreen/MainScreen.tsx +++ b/packages/app-desktop/gui/MainScreen/MainScreen.tsx @@ -13,7 +13,7 @@ import Sidebar from '../Sidebar/Sidebar'; import UserWebview from '../../services/plugins/UserWebview'; import UserWebviewDialog from '../../services/plugins/UserWebviewDialog'; import { ContainerType } from '@joplin/lib/services/plugins/WebviewController'; -import { stateUtils } from '@joplin/lib/reducer'; +import { StateLastDeletion, stateUtils } from '@joplin/lib/reducer'; import InteropServiceHelper from '../../InteropServiceHelper'; import { _ } from '@joplin/lib/locale'; import NoteListWrapper from '../NoteListWrapper/NoteListWrapper'; @@ -45,6 +45,8 @@ import restart from '../../services/restart'; const { connect } = require('react-redux'); import PromptDialog from '../PromptDialog'; import NotePropertiesDialog from '../NotePropertiesDialog'; +import TrashNotification from '../TrashNotification/TrashNotification'; + const PluginManager = require('@joplin/lib/services/PluginManager'); const ipcRenderer = require('electron').ipcRenderer; @@ -83,6 +85,9 @@ interface Props { processingShareInvitationResponse: boolean; isResettingLayout: boolean; listRendererId: string; + lastDeletion: StateLastDeletion; + lastDeletionNotificationTime: number; + selectedFolderId: string; mustUpgradeAppMessage: string; } @@ -732,6 +737,7 @@ class MainScreenComponent extends React.Component { themeId={this.props.themeId} listRendererId={this.props.listRendererId} startupPluginsLoaded={this.props.startupPluginsLoaded} + selectedFolderId={this.props.selectedFolderId} />; }, @@ -880,6 +886,12 @@ class MainScreenComponent extends React.Component { + {messageComp} {layoutComp} {pluginDialog} @@ -918,6 +930,9 @@ const mapStateToProps = (state: AppState) => { needApiAuth: state.needApiAuth, isResettingLayout: state.isResettingLayout, listRendererId: state.settings['notes.listRendererId'], + lastDeletion: state.lastDeletion, + lastDeletionNotificationTime: state.lastDeletionNotificationTime, + selectedFolderId: state.selectedFolderId, mustUpgradeAppMessage: state.mustUpgradeAppMessage, }; }; diff --git a/packages/app-desktop/gui/MainScreen/commands/deleteFolder.ts b/packages/app-desktop/gui/MainScreen/commands/deleteFolder.ts index 899031009e..8138e72eba 100644 --- a/packages/app-desktop/gui/MainScreen/commands/deleteFolder.ts +++ b/packages/app-desktop/gui/MainScreen/commands/deleteFolder.ts @@ -17,7 +17,7 @@ export const runtime = (): CommandRuntime => { const folder = await Folder.load(folderId); if (!folder) throw new Error(`No such folder: ${folderId}`); - let deleteMessage = _('Delete notebook "%s"?\n\nAll notes and sub-notebooks within this notebook will also be deleted.', substrWithEllipsis(folder.title, 0, 32)); + let deleteMessage = _('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)); if (folderId === context.state.settings['sync.10.inboxId']) { deleteMessage = _('Delete the Inbox notebook?\n\nIf you delete the inbox notebook, any email that\'s recently been sent to it may be lost.'); } @@ -25,7 +25,7 @@ export const runtime = (): CommandRuntime => { const ok = bridge().showConfirmMessageBox(deleteMessage); if (!ok) return; - await Folder.delete(folderId); + await Folder.delete(folderId, { toTrash: true }); }, enabledCondition: '!folderIsReadOnly', }; diff --git a/packages/app-desktop/gui/MainScreen/commands/deleteNote.ts b/packages/app-desktop/gui/MainScreen/commands/deleteNote.ts index 45aa49aaff..d08defe80e 100644 --- a/packages/app-desktop/gui/MainScreen/commands/deleteNote.ts +++ b/packages/app-desktop/gui/MainScreen/commands/deleteNote.ts @@ -1,7 +1,6 @@ import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService'; import { _ } from '@joplin/lib/locale'; import Note from '@joplin/lib/models/Note'; -import bridge from '../../../services/bridge'; export const declaration: CommandDeclaration = { name: 'deleteNote', @@ -13,20 +12,17 @@ export const runtime = (): CommandRuntime => { return { execute: async (context: CommandContext, noteIds: string[] = null) => { if (noteIds === null) noteIds = context.state.selectedNoteIds; - if (!noteIds.length) return; + await Note.batchDelete(noteIds, { toTrash: true }); - const msg = await Note.deleteMessage(noteIds); - if (!msg) return; - - const ok = bridge().showConfirmMessageBox(msg, { - buttons: [_('Delete'), _('Cancel')], - defaultId: 1, + context.dispatch({ + type: 'ITEMS_TRASHED', + value: { + noteIds, + folderIds: [], + }, }); - - if (!ok) return; - await Note.batchDelete(noteIds); }, - enabledCondition: '!noteIsReadOnly', + enabledCondition: '!noteIsReadOnly && !inTrash && someNotesSelected', }; }; diff --git a/packages/app-desktop/gui/MainScreen/commands/editAlarm.ts b/packages/app-desktop/gui/MainScreen/commands/editAlarm.ts index 9eb05a175c..a48998f500 100644 --- a/packages/app-desktop/gui/MainScreen/commands/editAlarm.ts +++ b/packages/app-desktop/gui/MainScreen/commands/editAlarm.ts @@ -4,6 +4,7 @@ import { _ } from '@joplin/lib/locale'; import { stateUtils } from '@joplin/lib/reducer'; import Note from '@joplin/lib/models/Note'; import time from '@joplin/lib/time'; +import { NoteEntity } from '@joplin/lib/services/database/types'; export const declaration: CommandDeclaration = { name: 'editAlarm', @@ -29,7 +30,7 @@ export const runtime = (comp: any): CommandRuntime => { buttons: ['ok', 'cancel', 'clear'], value: note.todo_due ? new Date(note.todo_due) : defaultDate, onClose: async (answer: any, buttonType: string) => { - let newNote = null; + let newNote: NoteEntity = null; if (buttonType === 'clear') { newNote = { diff --git a/packages/app-desktop/gui/MainScreen/commands/index.ts b/packages/app-desktop/gui/MainScreen/commands/index.ts index 6b00bd76a3..8cb231aa17 100644 --- a/packages/app-desktop/gui/MainScreen/commands/index.ts +++ b/packages/app-desktop/gui/MainScreen/commands/index.ts @@ -20,10 +20,13 @@ import * as openItem from './openItem'; import * as openNote from './openNote'; import * as openPdfViewer from './openPdfViewer'; import * as openTag from './openTag'; +import * as permanentlyDeleteNote from './permanentlyDeleteNote'; import * as print from './print'; import * as renameFolder from './renameFolder'; import * as renameTag from './renameTag'; import * as resetLayout from './resetLayout'; +import * as restoreFolder from './restoreFolder'; +import * as restoreNote from './restoreNote'; import * as revealResourceFile from './revealResourceFile'; import * as search from './search'; import * as setTags from './setTags'; @@ -66,10 +69,13 @@ const index: any[] = [ openNote, openPdfViewer, openTag, + permanentlyDeleteNote, print, renameFolder, renameTag, resetLayout, + restoreFolder, + restoreNote, revealResourceFile, search, setTags, diff --git a/packages/app-desktop/gui/MainScreen/commands/newNote.ts b/packages/app-desktop/gui/MainScreen/commands/newNote.ts index ad263a9f47..049a734c0b 100644 --- a/packages/app-desktop/gui/MainScreen/commands/newNote.ts +++ b/packages/app-desktop/gui/MainScreen/commands/newNote.ts @@ -3,6 +3,8 @@ import { _ } from '@joplin/lib/locale'; import Setting from '@joplin/lib/models/Setting'; import Note from '@joplin/lib/models/Note'; +export const newNoteEnabledConditions = 'oneFolderSelected && !inConflictFolder && !folderIsReadOnly && !folderIsTrash'; + export const declaration: CommandDeclaration = { name: 'newNote', label: () => _('New note'), @@ -36,6 +38,6 @@ export const runtime = (): CommandRuntime => { type: 'NOTE_SORT', }); }, - enabledCondition: 'oneFolderSelected && !inConflictFolder && !folderIsReadOnly', + enabledCondition: newNoteEnabledConditions, }; }; diff --git a/packages/app-desktop/gui/MainScreen/commands/newSubFolder.ts b/packages/app-desktop/gui/MainScreen/commands/newSubFolder.ts index 3d4e214e26..d1a27c38f7 100644 --- a/packages/app-desktop/gui/MainScreen/commands/newSubFolder.ts +++ b/packages/app-desktop/gui/MainScreen/commands/newSubFolder.ts @@ -13,6 +13,6 @@ export const runtime = (): CommandRuntime => { parentId = parentId || context.state.selectedFolderId; return CommandService.instance().execute('newFolder', parentId); }, - enabledCondition: '!folderIsReadOnly', + enabledCondition: '!folderIsReadOnly && !folderIsTrash', }; }; diff --git a/packages/app-desktop/gui/MainScreen/commands/newTodo.ts b/packages/app-desktop/gui/MainScreen/commands/newTodo.ts index 66a54c54d4..a26e8bfb57 100644 --- a/packages/app-desktop/gui/MainScreen/commands/newTodo.ts +++ b/packages/app-desktop/gui/MainScreen/commands/newTodo.ts @@ -1,5 +1,6 @@ import CommandService, { CommandContext, CommandDeclaration, CommandRuntime } from '@joplin/lib/services/CommandService'; import { _ } from '@joplin/lib/locale'; +import { newNoteEnabledConditions } from './newNote'; export const declaration: CommandDeclaration = { name: 'newTodo', @@ -12,6 +13,6 @@ export const runtime = (): CommandRuntime => { execute: async (_context: CommandContext, body = '') => { return CommandService.instance().execute('newNote', body, true); }, - enabledCondition: 'oneFolderSelected && !inConflictFolder && !folderIsReadOnly', + enabledCondition: newNoteEnabledConditions, }; }; diff --git a/packages/app-desktop/gui/MainScreen/commands/permanentlyDeleteNote.ts b/packages/app-desktop/gui/MainScreen/commands/permanentlyDeleteNote.ts new file mode 100644 index 0000000000..f90f023894 --- /dev/null +++ b/packages/app-desktop/gui/MainScreen/commands/permanentlyDeleteNote.ts @@ -0,0 +1,28 @@ +import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService'; +import { _ } from '@joplin/lib/locale'; +import Note from '@joplin/lib/models/Note'; +import bridge from '../../../services/bridge'; + +export const declaration: CommandDeclaration = { + name: 'permanentlyDeleteNote', + label: () => _('Permanently delete note'), + iconName: 'fa-times', +}; + +export const runtime = (): CommandRuntime => { + return { + execute: async (context: CommandContext, noteIds: string[] = null) => { + if (noteIds === null) noteIds = context.state.selectedNoteIds; + if (!noteIds.length) return; + const msg = await Note.permanentlyDeleteMessage(noteIds); + + const ok = bridge().showConfirmMessageBox(msg, { + buttons: [_('Delete'), _('Cancel')], + defaultId: 1, + }); + + if (ok) await Note.batchDelete(noteIds, { toTrash: false }); + }, + enabledCondition: '(!noteIsReadOnly || inTrash) && someNotesSelected', + }; +}; diff --git a/packages/app-desktop/gui/MainScreen/commands/restoreFolder.ts b/packages/app-desktop/gui/MainScreen/commands/restoreFolder.ts new file mode 100644 index 0000000000..c864945318 --- /dev/null +++ b/packages/app-desktop/gui/MainScreen/commands/restoreFolder.ts @@ -0,0 +1,24 @@ +import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService'; +import { _ } from '@joplin/lib/locale'; +import restoreItems from '@joplin/lib/services/trash/restoreItems'; +import Folder from '@joplin/lib/models/Folder'; +import { ModelType } from '@joplin/lib/BaseModel'; + +export const declaration: CommandDeclaration = { + name: 'restoreFolder', + label: () => _('Restore notebook'), + iconName: 'fas fa-trash-restore', +}; + +export const runtime = (): CommandRuntime => { + return { + execute: async (context: CommandContext, folderId: string = null) => { + if (folderId === null) folderId = context.state.selectedFolderId; + + const folder = await Folder.load(folderId); + if (!folder) throw new Error(`No such folder: ${folderId}`); + await restoreItems(ModelType.Folder, [folder]); + }, + enabledCondition: 'folderIsDeleted', + }; +}; diff --git a/packages/app-desktop/gui/MainScreen/commands/restoreNote.ts b/packages/app-desktop/gui/MainScreen/commands/restoreNote.ts new file mode 100644 index 0000000000..9cb006dcce --- /dev/null +++ b/packages/app-desktop/gui/MainScreen/commands/restoreNote.ts @@ -0,0 +1,23 @@ +import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService'; +import { _ } from '@joplin/lib/locale'; +import Note from '@joplin/lib/models/Note'; +import restoreItems from '@joplin/lib/services/trash/restoreItems'; +import { NoteEntity } from '@joplin/lib/services/database/types'; +import { ModelType } from '@joplin/lib/BaseModel'; + +export const declaration: CommandDeclaration = { + name: 'restoreNote', + label: () => _('Restore note'), + iconName: 'fas fa-trash-restore', +}; + +export const runtime = (): CommandRuntime => { + return { + execute: async (context: CommandContext, noteIds: string[] = null) => { + if (noteIds === null) noteIds = context.state.selectedNoteIds; + const notes: NoteEntity[] = await Note.byIds(noteIds, { fields: ['id', 'parent_id'] }); + await restoreItems(ModelType.Note, notes); + }, + enabledCondition: 'allSelectedNotesAreDeleted', + }; +}; diff --git a/packages/app-desktop/gui/MainScreen/commands/setTags.ts b/packages/app-desktop/gui/MainScreen/commands/setTags.ts index 46b004b6fd..f878569004 100644 --- a/packages/app-desktop/gui/MainScreen/commands/setTags.ts +++ b/packages/app-desktop/gui/MainScreen/commands/setTags.ts @@ -66,6 +66,6 @@ export const runtime = (comp: any): CommandRuntime => { }, }); }, - enabledCondition: 'someNotesSelected', + enabledCondition: 'someNotesSelected && !inTrash', }; }; diff --git a/packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.ts b/packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.ts index 653914a232..03d237c07d 100644 --- a/packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.ts +++ b/packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.ts @@ -18,6 +18,6 @@ export const runtime = (comp: any): CommandRuntime => { }, }); }, - enabledCondition: 'joplinServerConnected && someNotesSelected', + enabledCondition: 'joplinServerConnected && someNotesSelected && !noteIsDeleted', }; }; diff --git a/packages/app-desktop/gui/MenuBar.tsx b/packages/app-desktop/gui/MenuBar.tsx index c965069797..fc155c9794 100644 --- a/packages/app-desktop/gui/MenuBar.tsx +++ b/packages/app-desktop/gui/MenuBar.tsx @@ -837,6 +837,8 @@ function useMenu(props: Props) { separator(), menuItemDic.showNoteProperties, menuItemDic.showNoteContentProperties, + separator(), + menuItemDic.permanentlyDeleteNote, ], }, tools: { diff --git a/packages/app-desktop/gui/NoteEditor/utils/index.ts b/packages/app-desktop/gui/NoteEditor/utils/index.ts index 0cf1ff7a10..2b2cfd874e 100644 --- a/packages/app-desktop/gui/NoteEditor/utils/index.ts +++ b/packages/app-desktop/gui/NoteEditor/utils/index.ts @@ -26,9 +26,11 @@ export async function htmlToMarkdown(markupLanguage: number, html: string, origi export async function formNoteToNote(formNote: FormNote): Promise { return { id: formNote.id, - // Should also include parent_id so that the reducer can know in which folder the note should go when saving + // Should also include parent_id and deleted_time so that the reducer + // can know in which folder the note should go when saving. // https://discourse.joplinapp.org/t/experimental-wysiwyg-editor-in-joplin/6915/57?u=laurent parent_id: formNote.parent_id, + deleted_time: formNote.deleted_time, title: formNote.title, body: formNote.body, }; diff --git a/packages/app-desktop/gui/NoteEditor/utils/types.ts b/packages/app-desktop/gui/NoteEditor/utils/types.ts index f15181e080..c57f8e6789 100644 --- a/packages/app-desktop/gui/NoteEditor/utils/types.ts +++ b/packages/app-desktop/gui/NoteEditor/utils/types.ts @@ -134,6 +134,7 @@ export interface FormNote { markup_language: number; user_updated_time: number; encryption_applied: number; + deleted_time: number; hasChanged: boolean; @@ -173,6 +174,7 @@ export function defaultFormNote(): FormNote { return { id: '', parent_id: '', + deleted_time: 0, title: '', body: '', is_todo: 0, diff --git a/packages/app-desktop/gui/NoteEditor/utils/useFormNote.ts b/packages/app-desktop/gui/NoteEditor/utils/useFormNote.ts index 11e5faae86..e346abceb5 100644 --- a/packages/app-desktop/gui/NoteEditor/utils/useFormNote.ts +++ b/packages/app-desktop/gui/NoteEditor/utils/useFormNote.ts @@ -13,6 +13,7 @@ import Note from '@joplin/lib/models/Note'; import { reg } from '@joplin/lib/registry'; import ResourceFetcher from '@joplin/lib/services/ResourceFetcher'; import DecryptionWorker from '@joplin/lib/services/DecryptionWorker'; +import { NoteEntity } from '@joplin/lib/services/database/types'; export interface OnLoadEvent { formNote: FormNote; @@ -77,7 +78,7 @@ export default function useFormNote(dependencies: HookDependencies) { // a new refresh. const [formNoteRefreshScheduled, setFormNoteRefreshScheduled] = useState(0); - async function initNoteState(n: any) { + async function initNoteState(n: NoteEntity) { let originalCss = ''; if (n.markup_language === MarkupToHtml.MARKUP_LANGUAGE_HTML) { @@ -91,6 +92,7 @@ export default function useFormNote(dependencies: HookDependencies) { body: n.body, is_todo: n.is_todo, parent_id: n.parent_id, + deleted_time: n.deleted_time, bodyWillChangeId: 0, bodyChangeId: 0, markup_language: n.markup_language, diff --git a/packages/app-desktop/gui/NoteList/NoteList2.tsx b/packages/app-desktop/gui/NoteList/NoteList2.tsx index 2aace647bf..369d60b208 100644 --- a/packages/app-desktop/gui/NoteList/NoteList2.tsx +++ b/packages/app-desktop/gui/NoteList/NoteList2.tsx @@ -22,6 +22,8 @@ import * as focusElementNoteList from './commands/focusElementNoteList'; import CommandService from '@joplin/lib/services/CommandService'; import useDragAndDrop from './utils/useDragAndDrop'; import usePrevious from '../hooks/usePrevious'; +import { itemIsInTrash } from '@joplin/lib/services/trash'; +import Folder from '@joplin/lib/models/Folder'; const { connect } = require('react-redux'); const commands = { @@ -74,6 +76,7 @@ const NoteList = (props: Props) => { props.uncompletedTodosOnTop, props.showCompletedTodos, props.notes, + props.selectedFolderInTrash, ); const noteItemStyle = useMemo(() => { @@ -136,6 +139,7 @@ const NoteList = (props: Props) => { props.showCompletedTodos, listRenderer.flow, itemsPerLine, + props.selectedFolderInTrash, ); const previousSelectedNoteIds = usePrevious(props.selectedNoteIds, []); @@ -264,7 +268,7 @@ const NoteList = (props: Props) => { }; const mapStateToProps = (state: AppState) => { - const selectedFolder: FolderEntity = state.notesParentType === 'Folder' ? BaseModel.byId(state.folders, state.selectedFolderId) : null; + const selectedFolder: FolderEntity = state.notesParentType === 'Folder' ? Folder.byId(state.folders, state.selectedFolderId) : null; const userId = state.settings['sync.userId']; return { @@ -287,6 +291,7 @@ const mapStateToProps = (state: AppState) => { customCss: state.customCss, focusedField: state.focusedField, parentFolderIsReadOnly: state.notesParentType === 'Folder' && selectedFolder ? itemIsReadOnlySync(ModelType.Folder, ItemChange.SOURCE_UNSPECIFIED, selectedFolder as ItemSlice, userId, state.shareService) : false, + selectedFolderInTrash: itemIsInTrash(selectedFolder), }; }; diff --git a/packages/app-desktop/gui/NoteList/utils/canManuallySortNotes.ts b/packages/app-desktop/gui/NoteList/utils/canManuallySortNotes.ts index 5a9c71a4f7..7589c8556c 100644 --- a/packages/app-desktop/gui/NoteList/utils/canManuallySortNotes.ts +++ b/packages/app-desktop/gui/NoteList/utils/canManuallySortNotes.ts @@ -2,8 +2,9 @@ import { _ } from '@joplin/lib/locale'; import Setting from '@joplin/lib/models/Setting'; import bridge from '../../../services/bridge'; -const canManuallySortNotes = (notesParentType: string, noteSortOrder: string) => { +const canManuallySortNotes = (notesParentType: string, noteSortOrder: string, selectedFolderInTrash: boolean) => { if (notesParentType !== 'Folder') return false; + if (selectedFolderInTrash) return false; if (noteSortOrder !== 'order') { const doIt = bridge().showConfirmMessageBox(_('To manually sort the notes, the sort order must be changed to "%s" in the menu "%s" > "%s"', _('Custom order'), _('View'), _('Sort notes by')), { diff --git a/packages/app-desktop/gui/NoteList/utils/types.ts b/packages/app-desktop/gui/NoteList/utils/types.ts index 9524fdd03a..1a307e7bdd 100644 --- a/packages/app-desktop/gui/NoteList/utils/types.ts +++ b/packages/app-desktop/gui/NoteList/utils/types.ts @@ -29,6 +29,7 @@ export interface Props { focusedField: string; parentFolderIsReadOnly: boolean; listRenderer: ListRenderer; + selectedFolderInTrash: boolean; } export enum BaseBreakpoint { diff --git a/packages/app-desktop/gui/NoteList/utils/useDragAndDrop.ts b/packages/app-desktop/gui/NoteList/utils/useDragAndDrop.ts index 3b1559f889..7bad2eb807 100644 --- a/packages/app-desktop/gui/NoteList/utils/useDragAndDrop.ts +++ b/packages/app-desktop/gui/NoteList/utils/useDragAndDrop.ts @@ -18,6 +18,7 @@ const useDragAndDrop = ( showCompletedTodos: boolean, flow: ItemFlow, itemsPerLine: number, + selectedFolderInTrash: boolean, ) => { const [dragOverTargetNoteIndex, setDragOverTargetNoteIndex] = useState(null); @@ -72,6 +73,7 @@ const useDragAndDrop = ( const onDragOver: DragEventHandler = useCallback(event => { if (notesParentType !== 'Folder') return; + if (selectedFolderInTrash) return; const dt = event.dataTransfer; @@ -81,11 +83,11 @@ const useDragAndDrop = ( if (dragOverTargetNoteIndex === newIndex) return; setDragOverTargetNoteIndex(newIndex); } - }, [notesParentType, dragTargetNoteIndex, dragOverTargetNoteIndex]); + }, [notesParentType, dragTargetNoteIndex, dragOverTargetNoteIndex, selectedFolderInTrash]); const onDrop: DragEventHandler = useCallback(async (event: any) => { // TODO: check that parent type is folder - if (!canManuallySortNotes(notesParentType, noteSortOrder)) return; + if (!canManuallySortNotes(notesParentType, noteSortOrder, selectedFolderInTrash)) return; const dt = event.dataTransfer; setDragOverTargetNoteIndex(null); @@ -94,7 +96,7 @@ const useDragAndDrop = ( const noteIds: string[] = JSON.parse(dt.getData('text/x-jop-note-ids')); await Note.insertNotesAt(selectedFolderId, noteIds, targetNoteIndex, uncompletedTodosOnTop, showCompletedTodos); - }, [notesParentType, dragTargetNoteIndex, noteSortOrder, selectedFolderId, uncompletedTodosOnTop, showCompletedTodos]); + }, [notesParentType, dragTargetNoteIndex, noteSortOrder, selectedFolderId, uncompletedTodosOnTop, showCompletedTodos, selectedFolderInTrash]); return { onDragStart, onDragOver, onDrop, dragOverTargetNoteIndex }; }; diff --git a/packages/app-desktop/gui/NoteList/utils/useMoveNote.ts b/packages/app-desktop/gui/NoteList/utils/useMoveNote.ts index 5c75af8ffb..b1ece9b1c3 100644 --- a/packages/app-desktop/gui/NoteList/utils/useMoveNote.ts +++ b/packages/app-desktop/gui/NoteList/utils/useMoveNote.ts @@ -4,9 +4,9 @@ import { NoteEntity } from '@joplin/lib/services/database/types'; import { useCallback } from 'react'; import canManuallySortNotes from './canManuallySortNotes'; -const useMoveNote = (notesParentType: string, noteSortOrder: string, selectedNoteIds: string[], selectedFolderId: string, uncompletedTodosOnTop: boolean, showCompletedTodos: boolean, notes: NoteEntity[]) => { +const useMoveNote = (notesParentType: string, noteSortOrder: string, selectedNoteIds: string[], selectedFolderId: string, uncompletedTodosOnTop: boolean, showCompletedTodos: boolean, notes: NoteEntity[], selectedFolderInTrash: boolean) => { const moveNote = useCallback((direction: number, inc: number) => { - if (!canManuallySortNotes(notesParentType, noteSortOrder)) return; + if (!canManuallySortNotes(notesParentType, noteSortOrder, selectedFolderInTrash)) return; const noteId = selectedNoteIds[0]; let targetNoteIndex = BaseModel.modelIndexById(notes, noteId); @@ -17,7 +17,7 @@ const useMoveNote = (notesParentType: string, noteSortOrder: string, selectedNot targetNoteIndex -= inc; } void Note.insertNotesAt(selectedFolderId, selectedNoteIds, targetNoteIndex, uncompletedTodosOnTop, showCompletedTodos); - }, [selectedFolderId, noteSortOrder, notes, notesParentType, selectedNoteIds, uncompletedTodosOnTop, showCompletedTodos]); + }, [selectedFolderId, noteSortOrder, notes, notesParentType, selectedNoteIds, uncompletedTodosOnTop, showCompletedTodos, selectedFolderInTrash]); return moveNote; }; diff --git a/packages/app-desktop/gui/NoteList/utils/useOnKeyDown.ts b/packages/app-desktop/gui/NoteList/utils/useOnKeyDown.ts index 5fc12b72bd..8360f7b946 100644 --- a/packages/app-desktop/gui/NoteList/utils/useOnKeyDown.ts +++ b/packages/app-desktop/gui/NoteList/utils/useOnKeyDown.ts @@ -106,7 +106,9 @@ const useOnKeyDown = ( if (noteIds.length && (key === 'Delete' || (key === 'Backspace' && event.metaKey))) { event.preventDefault(); - void CommandService.instance().execute('deleteNote', noteIds); + if (CommandService.instance().isEnabled('deleteNote')) { + void CommandService.instance().execute('deleteNote', noteIds); + } } if (noteIds.length && key === ' ') { diff --git a/packages/app-desktop/gui/NoteListControls/NoteListControls.tsx b/packages/app-desktop/gui/NoteListControls/NoteListControls.tsx index b50b1b2844..e65534eba0 100644 --- a/packages/app-desktop/gui/NoteListControls/NoteListControls.tsx +++ b/packages/app-desktop/gui/NoteListControls/NoteListControls.tsx @@ -11,6 +11,7 @@ import { _ } from '@joplin/lib/locale'; const { connect } = require('react-redux'); import styled from 'styled-components'; import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext'; +import { getTrashFolderId } from '@joplin/lib/services/trash'; import { Breakpoints } from '../NoteList/utils/types'; interface Props { @@ -265,7 +266,7 @@ const mapStateToProps = (state: AppState) => { const whenClauseContext = stateToWhenClauseContext(state); return { - showNewNoteButtons: true, + showNewNoteButtons: state.selectedFolderId !== getTrashFolderId(), newNoteButtonEnabled: CommandService.instance().isEnabled('newNote', whenClauseContext), newTodoButtonEnabled: CommandService.instance().isEnabled('newTodo', whenClauseContext), sortOrderButtonsVisible: state.settings['notes.sortOrder.buttonsVisible'], diff --git a/packages/app-desktop/gui/NoteListWrapper/NoteListWrapper.tsx b/packages/app-desktop/gui/NoteListWrapper/NoteListWrapper.tsx index 1dc6b3a73a..899b15e76c 100644 --- a/packages/app-desktop/gui/NoteListWrapper/NoteListWrapper.tsx +++ b/packages/app-desktop/gui/NoteListWrapper/NoteListWrapper.tsx @@ -10,6 +10,7 @@ import Logger from '@joplin/utils/Logger'; import { _ } from '@joplin/lib/locale'; import { BaseBreakpoint, Breakpoints } from '../NoteList/utils/types'; import { ButtonSize, buttonSizePx } from '../Button/Button'; +import { getTrashFolderId } from '@joplin/lib/services/trash'; const logger = Logger.create('NoteListWrapper'); @@ -20,6 +21,7 @@ interface Props { themeId: number; listRendererId: string; startupPluginsLoaded: boolean; + selectedFolderId: string; } const StyledRoot = styled.div` @@ -31,7 +33,7 @@ const StyledRoot = styled.div` // Even though these calculations mostly concern the NoteListControls component, we do them here // because we need to know the height of that control to calculate the note list height. -const useNoteListControlsBreakpoints = (width: number, newNoteRef: React.MutableRefObject) => { +const useNoteListControlsBreakpoints = (width: number, newNoteRef: React.MutableRefObject, selectedFolderId: string) => { const [dynamicBreakpoints, setDynamicBreakpoints] = useState({ Sm: BaseBreakpoint.Sm, Md: BaseBreakpoint.Md, Lg: BaseBreakpoint.Lg, Xl: BaseBreakpoint.Xl }); const getTextWidth = useCallback((text: string): number => { @@ -47,9 +49,12 @@ const useNoteListControlsBreakpoints = (width: number, newNoteRef: React.Mutable return ctx.measureText(text).width; }, [newNoteRef]); + const showNewNoteButton = selectedFolderId !== getTrashFolderId(); + // Initialize language-specific breakpoints useEffect(() => { if (!newNoteRef.current) return; + if (showNewNoteButton) return; // Use the longest string to calculate the amount of extra width needed const smAdditional = getTextWidth(_('note')) > getTextWidth(_('to-do')) ? getTextWidth(_('note')) : getTextWidth(_('to-do')); @@ -61,7 +66,7 @@ const useNoteListControlsBreakpoints = (width: number, newNoteRef: React.Mutable const Xl = BaseBreakpoint.Xl; setDynamicBreakpoints({ Sm, Md, Lg, Xl }); - }, [newNoteRef, getTextWidth]); + }, [newNoteRef, getTextWidth, showNewNoteButton]); const breakpoint: number = useMemo(() => { // Find largest breakpoint that width is less than @@ -95,7 +100,7 @@ export default function NoteListWrapper(props: Props) { const listRenderer = useListRenderer(props.listRendererId, props.startupPluginsLoaded); const newNoteButtonRef = useRef(null); - const { breakpoint, dynamicBreakpoints, lineCount } = useNoteListControlsBreakpoints(props.size.width, newNoteButtonRef); + const { breakpoint, dynamicBreakpoints, lineCount } = useNoteListControlsBreakpoints(props.size.width, newNoteButtonRef, props.selectedFolderId); const noteListControlsButtonSize = ButtonSize.Small; const noteListControlsPadding = theme.mainPadding; diff --git a/packages/app-desktop/gui/NotePropertiesDialog.tsx b/packages/app-desktop/gui/NotePropertiesDialog.tsx index a096adcb3a..e2877b28d3 100644 --- a/packages/app-desktop/gui/NotePropertiesDialog.tsx +++ b/packages/app-desktop/gui/NotePropertiesDialog.tsx @@ -20,9 +20,21 @@ interface Props { themeId: number; } +interface FormNote { + id: string; + deleted_time: string; + location: string; + markup_language: string; + revisionsLink: string; + source_url: string; + todo_completed?: string; + user_created_time: string; + user_updated_time: string; +} + interface State { editedKey: string; - formNote: any; + formNote: FormNote; editedValue: any; } @@ -50,6 +62,7 @@ class NotePropertiesDialog extends React.Component { id: _('ID'), user_created_time: _('Created'), user_updated_time: _('Updated'), + deleted_time: _('Deleted'), todo_completed: _('Completed'), location: _('Location'), source_url: _('URL'), @@ -64,7 +77,7 @@ class NotePropertiesDialog extends React.Component { public componentDidUpdate() { if (this.state.editedKey === null) { - this.okButton.current.focus(); + if (this.okButton.current) this.okButton.current.focus(); } } @@ -78,6 +91,10 @@ class NotePropertiesDialog extends React.Component { } } + private isReadOnly() { + return this.state.formNote && !!this.state.formNote.deleted_time; + } + public latLongFromLocation(location: string) { const o: any = {}; const l = location.split(','); @@ -92,36 +109,35 @@ class NotePropertiesDialog extends React.Component { } public noteToFormNote(note: NoteEntity) { - const formNote: any = {}; - - formNote.user_updated_time = time.formatMsToLocal(note.user_updated_time); - formNote.user_created_time = time.formatMsToLocal(note.user_created_time); + const formNote: FormNote = { + id: note.id, + user_updated_time: time.formatMsToLocal(note.user_updated_time), + user_created_time: time.formatMsToLocal(note.user_created_time), + source_url: note.source_url, + location: '', + revisionsLink: note.id, + markup_language: Note.markupLanguageToLabel(note.markup_language), + deleted_time: note.deleted_time ? time.formatMsToLocal(note.deleted_time) : '', + }; if (note.todo_completed) { formNote.todo_completed = time.formatMsToLocal(note.todo_completed); } - formNote.source_url = note.source_url; - - formNote.location = ''; if (Number(note.latitude) || Number(note.longitude)) { formNote.location = `${note.latitude}, ${note.longitude}`; } - formNote.revisionsLink = note.id; - formNote.markup_language = Note.markupLanguageToLabel(note.markup_language); - formNote.id = note.id; - return formNote; } - public formNoteToNote(formNote: any) { - const note = { id: formNote.id, ...this.latLongFromLocation(formNote.location) }; + public formNoteToNote(formNote: FormNote) { + const note: NoteEntity = { id: formNote.id, ...this.latLongFromLocation(formNote.location) }; note.user_created_time = time.formatLocalToMs(formNote.user_created_time); note.user_updated_time = time.formatLocalToMs(formNote.user_updated_time); if (formNote.todo_completed) { - note.todo_completed = time.formatMsToLocal(formNote.todo_completed); + note.todo_completed = time.formatLocalToMs(formNote.todo_completed); } note.source_url = formNote.source_url; @@ -218,9 +234,9 @@ class NotePropertiesDialog extends React.Component { if (this.state.editedKey.indexOf('_time') >= 0) { const dt = time.anythingToDateTime(this.state.editedValue, new Date()); - newFormNote[this.state.editedKey] = time.formatMsToLocal(dt.getTime()); + (newFormNote as any)[this.state.editedKey] = time.formatMsToLocal(dt.getTime()); } else { - newFormNote[this.state.editedKey] = this.state.editedValue; + (newFormNote as any)[this.state.editedKey] = this.state.editedValue; } this.setState( @@ -239,7 +255,7 @@ class NotePropertiesDialog extends React.Component { public async cancelProperty() { // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied return new Promise((resolve: Function) => { - this.okButton.current.focus(); + if (this.okButton.current) this.okButton.current.focus(); this.setState({ editedKey: null, editedValue: null, @@ -249,7 +265,7 @@ class NotePropertiesDialog extends React.Component { }); } - public createNoteField(key: string, value: any) { + public createNoteField(key: keyof FormNote, value: any) { const styles = this.styles(this.props.themeId); const theme = themeStyle(this.props.themeId); const labelComp = ; @@ -351,7 +367,7 @@ class NotePropertiesDialog extends React.Component { } } - if (editCompHandler) { + if (editCompHandler && !this.isReadOnly()) { editComp = ( @@ -394,9 +410,9 @@ class NotePropertiesDialog extends React.Component { const noteComps = []; if (formNote) { - for (const key in formNote) { - if (!formNote.hasOwnProperty(key)) continue; - const comp = this.createNoteField(key, formNote[key]); + for (const key of Object.keys(formNote)) { + if (key === 'deleted_time' && !formNote.deleted_time) continue; + const comp = this.createNoteField(key as (keyof FormNote), (formNote as any)[key]); noteComps.push(comp); } } @@ -406,7 +422,7 @@ class NotePropertiesDialog extends React.Component {
{_('Note properties')}
{noteComps}
- +
); diff --git a/packages/app-desktop/gui/NotyfContext.tsx b/packages/app-desktop/gui/NotyfContext.tsx new file mode 100644 index 0000000000..3de8388ef9 --- /dev/null +++ b/packages/app-desktop/gui/NotyfContext.tsx @@ -0,0 +1,11 @@ +// Based on https://github.com/caroso1222/notyf/blob/master/recipes/react.md + +import * as React from 'react'; +import { Notyf } from 'notyf'; + +export default React.createContext( + new Notyf({ + // Set your global Notyf configuration here + duration: 6000, + }), +); diff --git a/packages/app-desktop/gui/Sidebar/Sidebar.tsx b/packages/app-desktop/gui/Sidebar/Sidebar.tsx index 6e46e304a2..8cb7ae3f9e 100644 --- a/packages/app-desktop/gui/Sidebar/Sidebar.tsx +++ b/packages/app-desktop/gui/Sidebar/Sidebar.tsx @@ -15,19 +15,20 @@ import { AppState } from '../../app.reducer'; import { ModelType } from '@joplin/lib/BaseModel'; import BaseModel from '@joplin/lib/BaseModel'; import Folder from '@joplin/lib/models/Folder'; -import Note from '@joplin/lib/models/Note'; import Tag from '@joplin/lib/models/Tag'; import Logger from '@joplin/utils/Logger'; -import { FolderEntity, FolderIcon, FolderIconType } from '@joplin/lib/services/database/types'; +import { FolderEntity, FolderIcon, FolderIconType, TagEntity } from '@joplin/lib/services/database/types'; import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext'; import { store } from '@joplin/lib/reducer'; import PerFolderSortOrderService from '../../services/sortOrder/PerFolderSortOrderService'; import { getFolderCallbackUrl, getTagCallbackUrl } from '@joplin/lib/callbackUrlUtils'; import FolderIconBox from '../FolderIconBox'; +import onFolderDrop from '@joplin/lib/models/utils/onFolderDrop'; import { Theme } from '@joplin/lib/themes/type'; import { RuntimeProps } from './commands/focusElementSideBar'; const { connect } = require('react-redux'); import { renderFolders, renderTags } from '@joplin/lib/components/shared/side-menu-shared'; +import { getTrashFolderIcon, getTrashFolderId } from '@joplin/lib/services/trash'; const { themeStyle } = require('@joplin/lib/theme'); const bridge = require('@electron/remote').require('./bridge').default; const Menu = bridge().Menu; @@ -42,7 +43,7 @@ interface Props { themeId: number; // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied dispatch: Function; - folders: any[]; + folders: FolderEntity[]; collapsedFolderIds: string[]; notesParentType: string; selectedFolderId: string; @@ -51,7 +52,7 @@ interface Props { decryptionWorker: any; resourceFetcher: any; syncReport: any; - tags: any[]; + tags: TagEntity[]; syncStarted: boolean; plugins: PluginStates; folderHeaderIsExpanded: boolean; @@ -97,11 +98,20 @@ function FolderItem(props: any) { const { hasChildren, showFolderIcon, isExpanded, parentId, depth, selected, folderId, folderTitle, folderIcon, anchorRef, noteCount, onFolderDragStart_, onFolderDragOver_, onFolderDrop_, itemContextMenu, folderItem_click, onFolderToggleClick_, shareId } = props; const noteCountComp = noteCount ? {noteCount} : null; - const shareIcon = shareId && !parentId ? : null; + const draggable = ![getTrashFolderId(), Folder.conflictFolderId()].includes(folderId); + + const doRenderFolderIcon = () => { + if (folderId === getTrashFolderId()) { + return renderFolderIcon(getTrashFolderIcon(FolderIconType.FontAwesome)); + } + + if (!showFolderIcon) return null; + return renderFolderIcon(folderIcon); + }; return ( - + - {showFolderIcon ? renderFolderIcon(folderIcon) : null}{folderTitle} + {doRenderFolderIcon()}{folderTitle} {shareIcon} {noteCountComp} @@ -220,20 +230,13 @@ const SidebarComponent = (props: Props) => { try { if (dt.types.indexOf('text/x-jop-note-ids') >= 0) { event.preventDefault(); - if (!folderId) return; - const noteIds = JSON.parse(dt.getData('text/x-jop-note-ids')); - for (let i = 0; i < noteIds.length; i++) { - await Note.moveToFolder(noteIds[i], folderId); - } + await onFolderDrop(noteIds, [], folderId); } else if (dt.types.indexOf('text/x-jop-folder-ids') >= 0) { event.preventDefault(); - const folderIds = JSON.parse(dt.getData('text/x-jop-folder-ids')); - for (let i = 0; i < folderIds.length; i++) { - await Folder.moveToFolder(folderIds[i], folderId); - } + await onFolderDrop([], folderIds, folderId); } } catch (error) { logger.error(error); @@ -296,131 +299,149 @@ const SidebarComponent = (props: Props) => { const menu = new Menu(); + if (itemId === getTrashFolderId()) { + menu.append( + new MenuItem(menuUtils.commandToStatefulMenuItem('emptyTrash')), + ); + menu.popup({ window: bridge().window() }); + return; + } + let item = null; if (itemType === BaseModel.TYPE_FOLDER) { item = BaseModel.byId(props.folders, itemId); } - if (itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied) { - menu.append( - new MenuItem(menuUtils.commandToStatefulMenuItem('newFolder', itemId)), - ); - } + const isDeleted = item ? !!item.deleted_time : false; - if (itemType === BaseModel.TYPE_FOLDER) { - menu.append( - new MenuItem(menuUtils.commandToStatefulMenuItem('deleteFolder', itemId)), - ); - } else { - menu.append( - new MenuItem({ - label: deleteButtonLabel, - click: async () => { - const ok = bridge().showConfirmMessageBox(deleteMessage, { - buttons: [deleteButtonLabel, _('Cancel')], - defaultId: 1, - }); - if (!ok) return; + if (!isDeleted) { + if (itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied) { + menu.append( + new MenuItem(menuUtils.commandToStatefulMenuItem('newFolder', itemId)), + ); + } - if (itemType === BaseModel.TYPE_TAG) { - await Tag.untagAll(itemId); - } else if (itemType === BaseModel.TYPE_SEARCH) { - props.dispatch({ - type: 'SEARCH_DELETE', - id: itemId, - }); - } - }, - }), - ); - } - - if (itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied) { - menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('openFolderDialog', { folderId: itemId }))); - - menu.append(new MenuItem({ type: 'separator' })); - - const exportMenu = new Menu(); - const ioService = InteropService.instance(); - const ioModules = ioService.modules(); - for (let i = 0; i < ioModules.length; i++) { - const module = ioModules[i]; - if (module.type !== 'exporter') continue; - - exportMenu.append( + if (itemType === BaseModel.TYPE_FOLDER) { + menu.append( + new MenuItem(menuUtils.commandToStatefulMenuItem('deleteFolder', itemId)), + ); + } else { + menu.append( new MenuItem({ - label: module.fullLabel(), + label: deleteButtonLabel, click: async () => { - await InteropServiceHelper.export(props.dispatch, module, { sourceFolderIds: [itemId], plugins: pluginsRef.current }); + const ok = bridge().showConfirmMessageBox(deleteMessage, { + buttons: [deleteButtonLabel, _('Cancel')], + defaultId: 1, + }); + if (!ok) return; + + if (itemType === BaseModel.TYPE_TAG) { + await Tag.untagAll(itemId); + } else if (itemType === BaseModel.TYPE_SEARCH) { + props.dispatch({ + type: 'SEARCH_DELETE', + id: itemId, + }); + } }, }), ); } - // We don't display the "Share notebook" menu item for sub-notebooks - // that are within a shared notebook. If user wants to do this, - // they'd have to move the notebook out of the shared notebook - // first. - const whenClause = stateToWhenClauseContext(state, { commandFolderId: itemId }); + if (itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied) { + menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('openFolderDialog', { folderId: itemId }))); - if (CommandService.instance().isEnabled('showShareFolderDialog', whenClause)) { - menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('showShareFolderDialog', itemId))); - } + menu.append(new MenuItem({ type: 'separator' })); - if (CommandService.instance().isEnabled('leaveSharedFolder', whenClause)) { - menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('leaveSharedFolder', itemId))); - } + const exportMenu = new Menu(); + const ioService = InteropService.instance(); + const ioModules = ioService.modules(); + for (let i = 0; i < ioModules.length; i++) { + const module = ioModules[i]; + if (module.type !== 'exporter') continue; - menu.append( - new MenuItem({ - label: _('Export'), - submenu: exportMenu, - }), - ); - if (Setting.value('notes.perFolderSortOrderEnabled')) { - menu.append(new MenuItem({ - ...menuUtils.commandToStatefulMenuItem('togglePerFolderSortOrder', itemId), - type: 'checkbox', - checked: PerFolderSortOrderService.isSet(itemId), - })); - } - } + exportMenu.append( + new MenuItem({ + label: module.fullLabel(), + click: async () => { + await InteropServiceHelper.export(props.dispatch, module, { sourceFolderIds: [itemId], plugins: pluginsRef.current }); + }, + }), + ); + } - if (itemType === BaseModel.TYPE_FOLDER) { - menu.append( - new MenuItem({ - label: _('Copy external link'), - click: () => { - clipboard.writeText(getFolderCallbackUrl(itemId)); - }, - }), - ); - } + // We don't display the "Share notebook" menu item for sub-notebooks + // that are within a shared notebook. If user wants to do this, + // they'd have to move the notebook out of the shared notebook + // first. + const whenClause = stateToWhenClauseContext(state, { commandFolderId: itemId }); - if (itemType === BaseModel.TYPE_TAG) { - menu.append(new MenuItem( - menuUtils.commandToStatefulMenuItem('renameTag', itemId), - )); - menu.append( - new MenuItem({ - label: _('Copy external link'), - click: () => { - clipboard.writeText(getTagCallbackUrl(itemId)); - }, - }), - ); - } + if (CommandService.instance().isEnabled('showShareFolderDialog', whenClause)) { + menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('showShareFolderDialog', itemId))); + } - const pluginViews = pluginUtils.viewsByType(pluginsRef.current, 'menuItem'); + if (CommandService.instance().isEnabled('leaveSharedFolder', whenClause)) { + menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('leaveSharedFolder', itemId))); + } - for (const view of pluginViews) { - const location = view.location; - - if (itemType === ModelType.Tag && location === MenuItemLocation.TagContextMenu || - itemType === ModelType.Folder && location === MenuItemLocation.FolderContextMenu - ) { menu.append( - new MenuItem(menuUtils.commandToStatefulMenuItem(view.commandName, itemId)), + new MenuItem({ + label: _('Export'), + submenu: exportMenu, + }), + ); + if (Setting.value('notes.perFolderSortOrderEnabled')) { + menu.append(new MenuItem({ + ...menuUtils.commandToStatefulMenuItem('togglePerFolderSortOrder', itemId), + type: 'checkbox', + checked: PerFolderSortOrderService.isSet(itemId), + })); + } + } + + if (itemType === BaseModel.TYPE_FOLDER) { + menu.append( + new MenuItem({ + label: _('Copy external link'), + click: () => { + clipboard.writeText(getFolderCallbackUrl(itemId)); + }, + }), + ); + } + + if (itemType === BaseModel.TYPE_TAG) { + menu.append(new MenuItem( + menuUtils.commandToStatefulMenuItem('renameTag', itemId), + )); + menu.append( + new MenuItem({ + label: _('Copy external link'), + click: () => { + clipboard.writeText(getTagCallbackUrl(itemId)); + }, + }), + ); + } + + const pluginViews = pluginUtils.viewsByType(pluginsRef.current, 'menuItem'); + + for (const view of pluginViews) { + const location = view.location; + + if (itemType === ModelType.Tag && location === MenuItemLocation.TagContextMenu || + itemType === ModelType.Folder && location === MenuItemLocation.FolderContextMenu + ) { + menu.append( + new MenuItem(menuUtils.commandToStatefulMenuItem(view.commandName, itemId)), + ); + } + } + } else { + if (itemType === BaseModel.TYPE_FOLDER) { + menu.append( + new MenuItem(menuUtils.commandToStatefulMenuItem('restoreFolder', itemId)), ); } } @@ -494,11 +515,15 @@ const SidebarComponent = (props: Props) => { const isExpanded = props.collapsedFolderIds.indexOf(folder.id) < 0; let noteCount = (folder as any).note_count; + // For now hide the count for folders in the trash because it doesn't work and getting it to + // work would be tricky. + if (folder.deleted_time || folder.id === getTrashFolderId()) noteCount = 0; + // Thunderbird count: Subtract children note_count from parent folder if it expanded. if (isExpanded) { for (let i = 0; i < props.folders.length; i++) { if (props.folders[i].parent_id === folder.id) { - noteCount -= props.folders[i].note_count; + noteCount -= (props.folders[i] as any).note_count; } } } diff --git a/packages/app-desktop/gui/TrashNotification/TrashNotification.tsx b/packages/app-desktop/gui/TrashNotification/TrashNotification.tsx new file mode 100644 index 0000000000..9526af691f --- /dev/null +++ b/packages/app-desktop/gui/TrashNotification/TrashNotification.tsx @@ -0,0 +1,79 @@ +import { useContext, useCallback, useMemo } from 'react'; +import { StateLastDeletion } from '@joplin/lib/reducer'; +import { _, _n } from '@joplin/lib/locale'; +import NotyfContext from '../NotyfContext'; +import { waitForElement } from '@joplin/lib/dom'; +import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect'; +import { htmlentities } from '@joplin/utils/html'; +import restoreItems from '@joplin/lib/services/trash/restoreItems'; +import { ModelType } from '@joplin/lib/BaseModel'; +import { themeStyle } from '@joplin/lib/theme'; +import { Dispatch } from 'redux'; + +interface Props { + lastDeletion: StateLastDeletion; + lastDeletionNotificationTime: number; + themeId: number; + dispatch: Dispatch; +} + +export default (props: Props) => { + const notyfContext = useContext(NotyfContext); + + const theme = useMemo(() => { + return themeStyle(props.themeId); + }, [props.themeId]); + + const notyf = useMemo(() => { + const output = notyfContext; + output.options.types = notyfContext.options.types.map(type => { + if (type.type === 'success') { + type.background = theme.backgroundColor5; + (type.icon as any).color = theme.backgroundColor5; + } + return type; + }); + return output; + }, [notyfContext, theme]); + + const onCancelClick = useCallback(async (event: any) => { + notyf.dismissAll(); + + const lastDeletion: StateLastDeletion = JSON.parse(event.currentTarget.getAttribute('data-lastDeletion')); + + if (lastDeletion.folderIds.length) { + await restoreItems(ModelType.Folder, lastDeletion.folderIds); + } + + if (lastDeletion.noteIds.length) { + await restoreItems(ModelType.Note, lastDeletion.noteIds); + } + }, [notyf]); + + useAsyncEffect(async (event) => { + if (!props.lastDeletion || props.lastDeletion.timestamp <= props.lastDeletionNotificationTime) return; + + props.dispatch({ type: 'DELETION_NOTIFICATION_DONE' }); + + let msg = ''; + + if (props.lastDeletion.folderIds.length) { + msg = _('The notebook and its content was successfully moved to the trash.'); + } else if (props.lastDeletion.noteIds.length) { + msg = _n('The note was successfully moved to the trash.', 'The notes were successfully moved to the trash.', props.lastDeletion.noteIds.length); + } else { + return; + } + + const linkId = `deletion-notification-cancel-${Math.floor(Math.random() * 1000000)}`; + const cancelLabel = _('Cancel'); + + notyf.success(`${msg}
${cancelLabel}`); + + const element: HTMLAnchorElement = await waitForElement(document, linkId); + if (event.cancelled) return; + element.addEventListener('click', onCancelClick); + }, [props.lastDeletion, notyf, props.dispatch]); + + return
; +}; diff --git a/packages/app-desktop/gui/TrashNotification/style.scss b/packages/app-desktop/gui/TrashNotification/style.scss new file mode 100644 index 0000000000..d6add8b988 --- /dev/null +++ b/packages/app-desktop/gui/TrashNotification/style.scss @@ -0,0 +1,27 @@ +body .notyf { + color: var(--joplin-color5); +} + +.notyf__toast { + + > .notyf__wrapper { + + > .notyf__message { + + > .cancel { + color: var(--joplin-color5); + text-decoration: underline; + } + + } + + > .notyf__icon { + + > .notyf__icon--success { + background-color: var(--joplin-color5); + } + + } + + } +} diff --git a/packages/app-desktop/gui/menuCommandNames.ts b/packages/app-desktop/gui/menuCommandNames.ts index a8f99df685..66c4b463cb 100644 --- a/packages/app-desktop/gui/menuCommandNames.ts +++ b/packages/app-desktop/gui/menuCommandNames.ts @@ -20,6 +20,7 @@ export default function() { 'setTags', 'showLocalSearch', 'showNoteContentProperties', + 'permanentlyDeleteNote', 'synchronize', 'textBold', 'textCode', diff --git a/packages/app-desktop/gui/utils/NoteListUtils.ts b/packages/app-desktop/gui/utils/NoteListUtils.ts index 0036d6611b..5d022c20dc 100644 --- a/packages/app-desktop/gui/utils/NoteListUtils.ts +++ b/packages/app-desktop/gui/utils/NoteListUtils.ts @@ -13,12 +13,13 @@ import Note from '@joplin/lib/models/Note'; import Setting from '@joplin/lib/models/Setting'; const { clipboard } = require('electron'); import { Dispatch } from 'redux'; +import { NoteEntity } from '@joplin/lib/services/database/types'; const Menu = bridge().Menu; const MenuItem = bridge().MenuItem; interface ContextMenuProps { - notes: any[]; + notes: NoteEntity[]; dispatch: Dispatch; watchedNoteFiles: string[]; plugins: PluginStates; @@ -32,18 +33,16 @@ export default class NoteListUtils { const menuUtils = new MenuUtils(cmdService); - const notes = noteIds.map(id => BaseModel.byId(props.notes, id)); + const notes: NoteEntity[] = noteIds.map(id => BaseModel.byId(props.notes, id)); const singleNoteId = noteIds.length === 1 ? noteIds[0] : null; - let hasEncrypted = false; - for (let i = 0; i < notes.length; i++) { - if (notes[i].encryption_applied) hasEncrypted = true; - } + const includeDeletedNotes = notes.find(n => !!n.deleted_time); + const includeEncryptedNotes = notes.find(n => !!n.encryption_applied); const menu = new Menu(); - if (!hasEncrypted) { + if (!includeEncryptedNotes && !includeDeletedNotes) { menu.append( new MenuItem(menuUtils.commandToStatefulMenuItem('setTags', noteIds) as any), ); @@ -165,11 +164,25 @@ export default class NoteListUtils { menu.append(exportMenuItem); } - menu.append( - new MenuItem( - menuUtils.commandToStatefulMenuItem('deleteNote', noteIds) as any, - ), - ); + if (includeDeletedNotes) { + menu.append( + new MenuItem( + menuUtils.commandToStatefulMenuItem('restoreNote', noteIds) as any, + ), + ); + + menu.append( + new MenuItem( + menuUtils.commandToStatefulMenuItem('permanentlyDeleteNote', noteIds) as any, + ), + ); + } else { + menu.append( + new MenuItem( + menuUtils.commandToStatefulMenuItem('deleteNote', noteIds) as any, + ), + ); + } const pluginViewInfos = pluginUtils.viewInfosByType(props.plugins, 'menuItem'); diff --git a/packages/app-desktop/index.html b/packages/app-desktop/index.html index a1704e9327..e1b54ed2c9 100644 --- a/packages/app-desktop/index.html +++ b/packages/app-desktop/index.html @@ -15,6 +15,10 @@ + + + + + + diff --git a/packages/app-desktop/package.json b/packages/app-desktop/package.json index 6cc7917360..22cf459e6c 100644 --- a/packages/app-desktop/package.json +++ b/packages/app-desktop/package.json @@ -173,6 +173,7 @@ "node-fetch": "2.6.7", "node-notifier": "10.0.1", "node-rsa": "1.1.1", + "notyf": "3.10.0", "pdfjs-dist": "3.11.174", "pretty-bytes": "5.6.0", "re-resizable": "6.9.11", diff --git a/packages/app-desktop/style.scss b/packages/app-desktop/style.scss index b32defb2b6..a78e0acc27 100644 --- a/packages/app-desktop/style.scss +++ b/packages/app-desktop/style.scss @@ -6,4 +6,5 @@ @use 'gui/Dropdown/style.scss' as dropdown-control; @use 'gui/ShareFolderDialog/style.scss' as share-folder-dialog; @use 'gui/NoteList/style.scss' as note-list; +@use 'gui/TrashNotification/style.scss' as trash-notification; @use 'main.scss' as main; \ No newline at end of file diff --git a/packages/app-mobile/components/ScreenHeader.tsx b/packages/app-mobile/components/ScreenHeader.tsx index a87f214a71..566d63e740 100644 --- a/packages/app-mobile/components/ScreenHeader.tsx +++ b/packages/app-mobile/components/ScreenHeader.tsx @@ -21,6 +21,9 @@ import { FolderEntity } from '@joplin/lib/services/database/types'; import { State } from '@joplin/lib/reducer'; import CustomButton from './CustomButton'; import FolderPicker from './FolderPicker'; +import { getTrashFolderId, itemIsInTrash } from '@joplin/lib/services/trash'; +import restoreItems from '@joplin/lib/services/trash/restoreItems'; +import { ModelType } from '@joplin/lib/BaseModel'; // We need this to suppress the useless warning // https://github.com/oblador/react-native-vector-icons/issues/1465 @@ -50,6 +53,8 @@ export interface MenuOptionType { type DispatchCommandType=(event: { type: string })=> void; interface ScreenHeaderProps { selectedNoteIds: string[]; + selectedFolderId: string; + notesParentType: string; noteSelectionEnabled: boolean; parentComponent: any; showUndoButton: boolean; @@ -269,22 +274,28 @@ class ScreenHeaderComponent extends PureComponent + + + ); + } + function duplicateButton(styles: any, onPress: OnPressCallback, disabled: boolean) { return ( f.id !== getTrashFolderId())} /> ); } else { @@ -591,8 +623,9 @@ class ScreenHeaderComponent extends PureComponent this.backButton_press(), backButtonDisabled); const selectAllButtonComp = !showSelectAllButton ? null : selectAllButton(this.styles(), () => this.selectAllButton_press()); const searchButtonComp = !showSearchButton ? null : searchButton(this.styles(), () => this.searchButton_press()); - const deleteButtonComp = this.props.noteSelectionEnabled ? deleteButton(this.styles(), () => this.deleteButton_press(), headerItemDisabled) : null; - const duplicateButtonComp = this.props.noteSelectionEnabled ? duplicateButton(this.styles(), () => this.duplicateButton_press(), headerItemDisabled) : null; + const deleteButtonComp = !selectedFolderInTrash && this.props.noteSelectionEnabled ? deleteButton(this.styles(), () => this.deleteButton_press(), headerItemDisabled) : null; + const restoreButtonComp = selectedFolderInTrash && this.props.noteSelectionEnabled ? restoreButton(this.styles(), () => this.restoreButton_press(), headerItemDisabled) : null; + const duplicateButtonComp = !selectedFolderInTrash && this.props.noteSelectionEnabled ? duplicateButton(this.styles(), () => this.duplicateButton_press(), headerItemDisabled) : null; const sortButtonComp = !this.props.noteSelectionEnabled && this.props.sortButton_press ? sortButton(this.styles(), () => this.props.sortButton_press()) : null; const windowHeight = Dimensions.get('window').height - 50; @@ -637,6 +670,7 @@ class ScreenHeaderComponent extends PureComponent { hasDisabledEncryptionItems: state.hasDisabledEncryptionItems, noteSelectionEnabled: state.noteSelectionEnabled, selectedNoteIds: state.selectedNoteIds, + selectedFolderId: state.selectedFolderId, + notesParentType: state.notesParentType, showMissingMasterKeyMessage: showMissingMasterKeyMessage(syncInfo, state.notLoadedMasterKeys), hasDisabledSyncItems: state.hasDisabledSyncItems, shouldUpgradeSyncTarget: state.settings['sync.upgradeState'] === Setting.SYNC_UPGRADE_STATE_SHOULD_DO, diff --git a/packages/app-mobile/components/screens/Note.tsx b/packages/app-mobile/components/screens/Note.tsx index c7ae5c77b8..3573fc0112 100644 --- a/packages/app-mobile/components/screens/Note.tsx +++ b/packages/app-mobile/components/screens/Note.tsx @@ -23,7 +23,7 @@ const Clipboard = require('@react-native-community/clipboard').default; const md5 = require('md5'); const { BackButtonService } = require('../../services/back-button.js'); import NavService, { OnNavigateCallback as OnNavigateCallback } from '@joplin/lib/services/NavService'; -import BaseModel from '@joplin/lib/BaseModel'; +import BaseModel, { ModelType } from '@joplin/lib/BaseModel'; import ActionButton from '../ActionButton'; const { fileExtension, safeFileExtension } = require('@joplin/lib/path-utils'); const mimeUtils = require('@joplin/lib/mime-utils.js').mime; @@ -57,6 +57,9 @@ import { join } from 'path'; import { Dispatch } from 'redux'; import { RefObject } from 'react'; import { SelectionRange } from '../NoteEditor/types'; +import { AppState } from '../../utils/types'; +import restoreItems from '@joplin/lib/services/trash/restoreItems'; +import { getDisplayParentTitle } from '@joplin/lib/services/trash'; const urlUtils = require('@joplin/lib/urlUtils'); const emptyArray: any[] = []; @@ -134,7 +137,7 @@ interface Props { } interface State { - note: any; + note: NoteEntity; mode: 'view'|'edit'; readOnly: boolean; folder: FolderEntity|null; @@ -677,12 +680,9 @@ class NoteScreenComponent extends BaseScreenComponent implements B const note = this.state.note; if (!note.id) return; - const ok = await dialogs.confirm(this, _('Delete note?')); - if (!ok) return; - const folderId = note.parent_id; - await Note.delete(note.id); + await Note.delete(note.id, { toTrash: true }); this.props.dispatch({ type: 'NAV_GO', @@ -1220,6 +1220,7 @@ class NoteScreenComponent extends BaseScreenComponent implements B const isTodo = note && !!note.is_todo; const isSaved = note && note.id; const readOnly = this.state.readOnly; + const isDeleted = !!this.state.note.deleted_time; const cacheKey = md5([isTodo, isSaved].join('_')); if (!this.menuOptionsCache_) this.menuOptionsCache_ = {}; @@ -1281,7 +1282,7 @@ class NoteScreenComponent extends BaseScreenComponent implements B }); } - if (isSaved) { + if (isSaved && !isDeleted) { output.push({ title: _('Tags'), onPress: () => { @@ -1289,6 +1290,7 @@ class NoteScreenComponent extends BaseScreenComponent implements B }, }); } + output.push({ title: isTodo ? _('Convert to note') : _('Convert to todo'), onPress: () => { @@ -1296,7 +1298,8 @@ class NoteScreenComponent extends BaseScreenComponent implements B }, disabled: readOnly, }); - if (isSaved) { + + if (isSaved && !isDeleted) { output.push({ title: _('Copy Markdown link'), onPress: () => { @@ -1304,12 +1307,27 @@ class NoteScreenComponent extends BaseScreenComponent implements B }, }); } + output.push({ title: _('Properties'), onPress: () => { this.properties_onPress(); }, }); + + if (isDeleted) { + output.push({ + title: _('Restore'), + onPress: async () => { + await restoreItems(ModelType.Note, [this.state.note.id]); + this.props.dispatch({ + type: 'NAV_GO', + routeName: 'Notes', + }); + }, + }); + } + output.push({ title: _('Delete'), onPress: () => { @@ -1550,6 +1568,7 @@ class NoteScreenComponent extends BaseScreenComponent implements B const renderActionButton = () => { if (this.state.voiceTypingDialogShown) return null; + if (!this.state.note || !!this.state.note.deleted_time) return null; const editButton = { label: _('Edit'), @@ -1615,7 +1634,7 @@ class NoteScreenComponent extends BaseScreenComponent implements B undoButtonDisabled={!this.state.undoRedoButtonState.canUndo && this.state.undoRedoButtonState.canRedo} onUndoButtonPress={this.screenHeader_undoButtonPress} onRedoButtonPress={this.screenHeader_redoButtonPress} - title={this.state.folder ? this.state.folder.title : ''} + title={getDisplayParentTitle(this.state.note, this.state.folder)} /> {titleComp} {bodyComponent} @@ -1635,7 +1654,7 @@ class NoteScreenComponent extends BaseScreenComponent implements B } } -const NoteScreen = connect((state: any) => { +const NoteScreen = connect((state: AppState) => { return { noteId: state.selectedNoteIds.length ? state.selectedNoteIds[0] : null, noteHash: state.selectedNoteHash, diff --git a/packages/app-mobile/components/screens/Notes.tsx b/packages/app-mobile/components/screens/Notes.tsx index 28869f0d48..ae5f705c88 100644 --- a/packages/app-mobile/components/screens/Notes.tsx +++ b/packages/app-mobile/components/screens/Notes.tsx @@ -17,6 +17,7 @@ const { BaseScreenComponent } = require('../base-screen'); const { BackButtonService } = require('../../services/back-button.js'); import { AppState } from '../../utils/types'; import { NoteEntity } from '@joplin/lib/services/database/types'; +import { itemIsInTrash } from '@joplin/lib/services/trash'; const { ALL_NOTES_FILTER_ID } = require('@joplin/lib/reserved-ids.js'); class NotesScreenComponent extends BaseScreenComponent { @@ -237,6 +238,8 @@ class NotesScreenComponent extends BaseScreenComponent { const thisComp = this; const makeActionButtonComp = () => { + if (this.props.notesParentType === 'Folder' && itemIsInTrash(parent)) return null; + const getTargetFolderId = async () => { if (!buttonFolderId && isAllNotes) { return (await Folder.defaultFolder()).id; diff --git a/packages/app-mobile/components/screens/folder.js b/packages/app-mobile/components/screens/folder.js index 3c0b8ef596..9614742429 100644 --- a/packages/app-mobile/components/screens/folder.js +++ b/packages/app-mobile/components/screens/folder.js @@ -10,6 +10,7 @@ const { dialogs } = require('../../utils/dialogs.js'); const { _ } = require('@joplin/lib/locale'); const { default: FolderPicker } = require('../FolderPicker'); const TextInput = require('../TextInput').default; +const { getTrashFolderId } = require('@joplin/lib/services/trash'); class FolderScreenComponent extends BaseScreenComponent { static navigationOptions() { @@ -107,7 +108,7 @@ class FolderScreenComponent extends BaseScreenComponent { f.id !== getTrashFolderId())} selectedFolderId={this.state.folder.parent_id} onValueChange={newValue => this.parent_changeValue(newValue)} mustSelect diff --git a/packages/app-mobile/components/side-menu-content.tsx b/packages/app-mobile/components/side-menu-content.tsx index 14a4f65f8b..b1464db1ce 100644 --- a/packages/app-mobile/components/side-menu-content.tsx +++ b/packages/app-mobile/components/side-menu-content.tsx @@ -9,11 +9,15 @@ import NavService from '@joplin/lib/services/NavService'; import { _ } from '@joplin/lib/locale'; const { themeStyle } = require('./global-style.js'); import { renderFolders } from '@joplin/lib/components/shared/side-menu-shared'; -import { FolderEntity, FolderIcon } from '@joplin/lib/services/database/types'; +import { FolderEntity, FolderIcon, FolderIconType } from '@joplin/lib/services/database/types'; import { AppState } from '../utils/types'; import Setting from '@joplin/lib/models/Setting'; import { reg } from '@joplin/lib/registry'; import { ProfileConfig } from '@joplin/lib/services/profileConfig/types'; +import { getTrashFolderIcon, getTrashFolderId } from '@joplin/lib/services/trash'; +import restoreItems from '@joplin/lib/services/trash/restoreItems'; +import { ModelType } from '@joplin/lib/BaseModel'; +const { substrWithEllipsis } = require('@joplin/lib/string-utils'); // We need this to suppress the useless warning // https://github.com/oblador/react-native-vector-icons/issues/1465 @@ -142,58 +146,96 @@ const SideMenuContentComponent = (props: Props) => { const folder = folderOrAll as FolderEntity; - const generateFolderDeletion = () => { - const folderDeletion = (message: string) => { - Alert.alert('', message, [ - { - text: _('OK'), - onPress: () => { - void Folder.delete(folder.id); + if (folder && folder.id === getTrashFolderId()) return; + + const menuItems: any[] = []; + + if (folder && !!folder.deleted_time) { + menuItems.push({ + text: _('Restore'), + onPress: async () => { + await restoreItems(ModelType.Folder, [folder.id]); + }, + style: 'destructive', + }); + + // Alert.alert( + // '', + // _('Notebook: %s', folder.title), + // [ + // { + // text: _('Restore'), + // onPress: async () => { + // await restoreItems(ModelType.Folder, [folder.id]); + // }, + // style: 'destructive', + // }, + // { + // text: _('Cancel'), + // onPress: () => {}, + // style: 'cancel', + // }, + // ], + // { + // cancelable: false, + // }, + // ); + } else { + const generateFolderDeletion = () => { + const folderDeletion = (message: string) => { + Alert.alert('', message, [ + { + text: _('OK'), + onPress: () => { + void Folder.delete(folder.id, { toTrash: true }); + }, }, - }, - { - text: _('Cancel'), - onPress: () => { }, - style: 'cancel', - }, - ]); + { + text: _('Cancel'), + onPress: () => { }, + style: 'cancel', + }, + ]); + }; + + if (folder.id === props.inboxJopId) { + return folderDeletion( + _('Delete the Inbox notebook?\n\nIf you delete the inbox notebook, any email that\'s recently been sent to it may be lost.'), + ); + } + return folderDeletion(_('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))); }; - if (folder.id === props.inboxJopId) { - return folderDeletion( - _('Delete the Inbox notebook?\n\nIf you delete the inbox notebook, any email that\'s recently been sent to it may be lost.'), - ); - } - return folderDeletion(_('Delete notebook "%s"?\n\nAll notes and sub-notebooks within this notebook will also be deleted.', folder.title)); - }; + menuItems.push({ + text: _('Edit'), + onPress: () => { + props.dispatch({ type: 'SIDE_MENU_CLOSE' }); + + props.dispatch({ + type: 'NAV_GO', + routeName: 'Folder', + folderId: folder.id, + }); + }, + }); + + menuItems.push({ + text: _('Delete'), + onPress: generateFolderDeletion, + style: 'destructive', + }); + } + + menuItems.push({ + text: _('Cancel'), + onPress: () => {}, + style: 'cancel', + }); Alert.alert( '', _('Notebook: %s', folder.title), - [ - { - text: _('Edit'), - onPress: () => { - props.dispatch({ type: 'SIDE_MENU_CLOSE' }); - - props.dispatch({ - type: 'NAV_GO', - routeName: 'Folder', - folderId: folder.id, - }); - }, - }, - { - text: _('Delete'), - onPress: generateFolderDeletion, - style: 'destructive', - }, - { - text: _('Cancel'), - onPress: () => {}, - style: 'cancel', - }, - ], + menuItems, { cancelable: false, }, @@ -308,10 +350,12 @@ const SideMenuContentComponent = (props: Props) => { if (actionDone === 'auth') props.dispatch({ type: 'SIDE_MENU_CLOSE' }); }, [performSync, props.dispatch]); - const renderFolderIcon = (theme: any, folderIcon: FolderIcon) => { + const renderFolderIcon = (folderId: string, theme: any, folderIcon: FolderIcon) => { if (!folderIcon) { if (alwaysShowFolderIcons) { return ; + } else if (folderId === getTrashFolderId()) { + folderIcon = getTrashFolderIcon(FolderIconType.Emoji); } else { return null; } @@ -378,7 +422,7 @@ const SideMenuContentComponent = (props: Props) => { }} > - {renderFolderIcon(theme, folderIcon)} + {renderFolderIcon(folder.id, theme, folderIcon)} {Folder.displayTitle(folder)} diff --git a/packages/app-mobile/root.tsx b/packages/app-mobile/root.tsx index 4427c42cb0..d89ddfcb60 100644 --- a/packages/app-mobile/root.tsx +++ b/packages/app-mobile/root.tsx @@ -121,13 +121,13 @@ import { ReactNode } from 'react'; import { parseShareCache } from '@joplin/lib/services/share/reducer'; import autodetectTheme, { onSystemColorSchemeChange } from './utils/autodetectTheme'; import runOnDeviceFsDriverTests from './utils/fs-driver/runOnDeviceTests'; -import { refreshFolders } from '@joplin/lib/folders-screen-utils'; +import { refreshFolders, scheduleRefreshFolders } from '@joplin/lib/folders-screen-utils'; type SideMenuPosition = 'left' | 'right'; const logger = Logger.create('root'); -let storeDispatch = function(_action: any) {}; +let storeDispatch: any = function(_action: any) {}; const logReducerAction = function(action: any) { if (['SIDE_MENU_OPEN_PERCENT', 'SYNC_REPORT_UPDATE'].indexOf(action.type) >= 0) return; @@ -148,6 +148,7 @@ const generalMiddleware = (store: any) => (next: any) => async (action: any) => const result = next(action); const newState = store.getState(); + let doRefreshFolders = false; await reduxSharedMiddleware(store, next, action, storeDispatch as any); @@ -158,6 +159,10 @@ const generalMiddleware = (store: any) => (next: any) => async (action: any) => SearchEngine.instance().scheduleSyncTables(); } + if (['FOLDER_UPDATE_ONE'].indexOf(action.type) >= 0) { + doRefreshFolders = true; + } + if (['EVENT_NOTE_ALARM_FIELD_CHANGE', 'NOTE_DELETE'].indexOf(action.type) >= 0) { await AlarmService.updateNoteNotification(action.id, action.type === 'NOTE_DELETE'); } @@ -215,6 +220,10 @@ const generalMiddleware = (store: any) => (next: any) => async (action: any) => void ResourceFetcher.instance().autoAddResources(); } + if (doRefreshFolders) { + await scheduleRefreshFolders((action: any) => storeDispatch(action)); + } + return result; }; diff --git a/packages/lib/ArrayUtils.ts b/packages/lib/ArrayUtils.ts index 2823af4451..24026da45f 100644 --- a/packages/lib/ArrayUtils.ts +++ b/packages/lib/ArrayUtils.ts @@ -1,10 +1,10 @@ -export const unique = function(array: any[]) { +export const unique = function(array: T[]): T[] { return array.filter((elem, index, self) => { return index === self.indexOf(elem); }); }; -export const removeElement = function(array: any[], element: any) { +export const removeElement = function(array: T[], element: T): T[] { const index = array.indexOf(element); if (index < 0) return array; const newArray = array.slice(); diff --git a/packages/lib/BaseApplication.ts b/packages/lib/BaseApplication.ts index fc6b472daf..1d8839f7d6 100644 --- a/packages/lib/BaseApplication.ts +++ b/packages/lib/BaseApplication.ts @@ -61,6 +61,7 @@ import RotatingLogs from './RotatingLogs'; import { NoteEntity } from './services/database/types'; import { join } from 'path'; import processStartFlags from './utils/processStartFlags'; +import { setupAutoDeletion } from './services/trash/permanentlyDeleteOldItems'; import determineProfileAndBaseDir from './determineBaseAppDirs'; const appLogger: LoggerWrapper = Logger.create('App'); @@ -438,6 +439,14 @@ export default class BaseApplication { doRefreshFolders = true; } + // If a note gets deleted to the trash or gets restored we refresh the folders so that the + // note count can be updated. + if (this.hasGui() && ['NOTE_UPDATE_ONE'].includes(action.type)) { + if (action.changedFields && action.changedFields.includes('deleted_time')) { + doRefreshFolders = true; + } + } + if (action.type === 'HISTORY_BACKWARD' || action.type === 'HISTORY_FORWARD') { refreshNotes = true; refreshNotesUseSelectedNoteId = true; @@ -822,6 +831,8 @@ export default class BaseApplication { if (!currentFolder) currentFolder = await Folder.defaultFolder(); Setting.setValue('activeFolderId', currentFolder ? currentFolder.id : ''); + await setupAutoDeletion(); + await MigrationService.instance().run(); return argv; diff --git a/packages/lib/BaseModel.ts b/packages/lib/BaseModel.ts index 523d84c19a..c83b94377e 100644 --- a/packages/lib/BaseModel.ts +++ b/packages/lib/BaseModel.ts @@ -40,6 +40,15 @@ export interface DeleteOptions { trackDeleted?: boolean; disableReadOnlyCheck?: boolean; + + // Tells whether the deleted item should be moved to the trash. By default + // it is permanently deleted. + toTrash?: boolean; + + // If the item is to be moved to the trash, tell what should be the new + // parent. By default the item will be moved at the root of the trash. Note + // that caller must ensure that this parent ID is a deleted folder. + toTrashParentId?: string; } class BaseModel { @@ -308,7 +317,7 @@ class BaseModel { return this.modelSelectAll(q.sql, q.params); } - public static async byIds(ids: string[], options: any = null) { + public static async byIds(ids: string[], options: LoadOptions = null) { if (!ids.length) return []; if (!options) options = {}; if (!options.fields) options.fields = '*'; diff --git a/packages/lib/components/shared/side-menu-shared.test.ts b/packages/lib/components/shared/side-menu-shared.test.ts new file mode 100644 index 0000000000..27017e3eee --- /dev/null +++ b/packages/lib/components/shared/side-menu-shared.test.ts @@ -0,0 +1,94 @@ +import { FolderEntity } from '../../services/database/types'; +import { getTrashFolder, getTrashFolderId } from '../../services/trash'; +import { RenderFolderItem, renderFolders } from './side-menu-shared'; + +const renderItem: RenderFolderItem = (folder: FolderEntity, selected: boolean, hasChildren: boolean, depth: number) => { + return [folder.id, selected, hasChildren, depth]; +}; + +describe('side-menu-shared', () => { + + test.each([ + [ + { + collapsedFolderIds: [], + folders: [], + notesParentType: 'Folder', + selectedFolderId: '', + selectedTagId: '', + }, + { + items: [], + order: [], + }, + ], + + [ + { + collapsedFolderIds: [], + folders: [ + { + id: '1', + parent_id: '', + deleted_time: 0, + }, + { + id: '2', + parent_id: '', + deleted_time: 0, + }, + { + id: '3', + parent_id: '1', + deleted_time: 0, + }, + ], + notesParentType: 'Folder', + selectedFolderId: '2', + selectedTagId: '', + }, + { + items: [ + ['1', false, true, 0], + ['3', false, false, 1], + ['2', true, false, 0], + ], + order: ['1', '3', '2'], + }, + ], + + [ + { + collapsedFolderIds: [], + folders: [ + { + id: '1', + parent_id: '', + deleted_time: 0, + }, + { + id: '2', + parent_id: '', + deleted_time: 1000, + }, + getTrashFolder(), + ], + notesParentType: 'Folder', + selectedFolderId: '', + selectedTagId: '', + }, + { + items: [ + ['1', false, false, 0], + [getTrashFolderId(), false, true, 0], + ['2', false, false, 1], + ], + order: ['1', getTrashFolderId(), '2'], + }, + ], + ])('should render folders', (props, expected) => { + const actual = renderFolders(props, renderItem); + expect(actual).toEqual(expected); + }); + +}); diff --git a/packages/lib/components/shared/side-menu-shared.ts b/packages/lib/components/shared/side-menu-shared.ts index 6a8c305129..6a59266143 100644 --- a/packages/lib/components/shared/side-menu-shared.ts +++ b/packages/lib/components/shared/side-menu-shared.ts @@ -1,6 +1,7 @@ import Folder from '../../models/Folder'; import BaseModel from '../../BaseModel'; import { FolderEntity, TagEntity } from '../../services/database/types'; +import { getDisplayParentId, getTrashFolderId } from '../../services/trash'; interface Props { folders: FolderEntity[]; @@ -11,26 +12,33 @@ interface Props { tags?: TagEntity[]; } -type RenderFolderItem = (folder: FolderEntity, selected: boolean, hasChildren: boolean, depth: number)=> any; -type RenderTagItem = (tag: TagEntity, selected: boolean)=> any; +export type RenderFolderItem = (folder: FolderEntity, selected: boolean, hasChildren: boolean, depth: number)=> any; +export type RenderTagItem = (tag: TagEntity, selected: boolean)=> any; function folderHasChildren_(folders: FolderEntity[], folderId: string) { + if (folderId === getTrashFolderId()) { + return !!folders.find(f => !!f.deleted_time); + } + for (let i = 0; i < folders.length; i++) { const folder = folders[i]; - if (folder.parent_id === folderId) return true; + const folderParentId = getDisplayParentId(folder, folders.find(f => f.id === folder.parent_id)); + if (folderParentId === folderId) return true; } + return false; } -function folderIsVisible(folders: FolderEntity[], folderId: string, collapsedFolderIds: string[]) { - if (!collapsedFolderIds || !collapsedFolderIds.length) return true; +function folderIsCollapsed(folders: FolderEntity[], folderId: string, collapsedFolderIds: string[]) { + if (!collapsedFolderIds || !collapsedFolderIds.length) return false; while (true) { - const folder = BaseModel.byId(folders, folderId); + const folder: FolderEntity = BaseModel.byId(folders, folderId); if (!folder) throw new Error(`No folder with id ${folder.id}`); - if (!folder.parent_id) return true; - if (collapsedFolderIds.indexOf(folder.parent_id) >= 0) return false; - folderId = folder.parent_id; + const folderParentId = getDisplayParentId(folder, folders.find(f => f.id === folder.parent_id)); + if (!folderParentId) return false; + if (collapsedFolderIds.indexOf(folderParentId) >= 0) return true; + folderId = folderParentId; } } @@ -38,8 +46,11 @@ function renderFoldersRecursive_(props: Props, renderItem: RenderFolderItem, ite const folders = props.folders; for (let i = 0; i < folders.length; i++) { const folder = folders[i]; - if (!Folder.idsEqual(folder.parent_id, parentId)) continue; - if (!folderIsVisible(props.folders, folder.id, props.collapsedFolderIds)) continue; + + const folderParentId = getDisplayParentId(folder, props.folders.find(f => f.id === folder.parent_id)); + + if (!Folder.idsEqual(folderParentId, parentId)) continue; + if (folderIsCollapsed(props.folders, folder.id, props.collapsedFolderIds)) continue; const hasChildren = folderHasChildren_(folders, folder.id); order.push(folder.id); items.push(renderItem(folder, props.selectedFolderId === folder.id && props.notesParentType === 'Folder', hasChildren, depth)); @@ -75,7 +86,7 @@ export const renderTags = (props: Props, renderItem: RenderTagItem) => { return a.title.toLowerCase() < b.title.toLowerCase() ? -1 : +1; }); const tagItems = []; - const order = []; + const order: string[] = []; for (let i = 0; i < tags.length; i++) { const tag = tags[i]; order.push(tag.id); diff --git a/packages/lib/folders-screen-utils.ts b/packages/lib/folders-screen-utils.ts index bcc351c397..6b63b9bede 100644 --- a/packages/lib/folders-screen-utils.ts +++ b/packages/lib/folders-screen-utils.ts @@ -38,7 +38,10 @@ export const allForDisplay = async (options: FolderLoadOptions = {}) => { export const refreshFolders = async (dispatch: Dispatch) => { refreshCalls_.push(true); try { - const folders = await allForDisplay({ includeConflictFolder: true }); + const folders = await allForDisplay({ + includeConflictFolder: true, + includeTrash: true, + }); dispatch({ type: 'FOLDER_UPDATE_ALL', diff --git a/packages/lib/models/BaseItem.ts b/packages/lib/models/BaseItem.ts index 4643298d75..0b043644e9 100644 --- a/packages/lib/models/BaseItem.ts +++ b/packages/lib/models/BaseItem.ts @@ -13,7 +13,7 @@ import { getEncryptionEnabled } from '../services/synchronizer/syncInfoUtils'; import JoplinError from '../JoplinError'; import { LoadOptions, SaveOptions } from './utils/types'; import { State as ShareState } from '../services/share/reducer'; -import { checkIfItemCanBeAddedToFolder, checkIfItemCanBeChanged, checkIfItemsCanBeChanged, needsReadOnlyChecks } from './utils/readOnly'; +import { checkIfItemCanBeAddedToFolder, checkIfItemCanBeChanged, checkIfItemsCanBeChanged, needsShareReadOnlyChecks } from './utils/readOnly'; const { sprintf } = require('sprintf-js'); const moment = require('moment'); @@ -293,7 +293,7 @@ export default class BaseItem extends BaseModel { }); } - if (needsReadOnlyChecks(this.modelType(), options.changeSource, this.syncShareCache, options.disableReadOnlyCheck)) { + if (needsShareReadOnlyChecks(this.modelType(), options.changeSource, this.syncShareCache, options.disableReadOnlyCheck)) { const previousItems = await this.loadItemsByTypeAndIds(this.modelType(), ids, { fields: ['share_id', 'id'] }); checkIfItemsCanBeChanged(this.modelType(), options.changeSource, previousItems, this.syncShareCache); } @@ -338,6 +338,15 @@ export default class BaseItem extends BaseModel { return r['total']; } + public static async allItemsInTrash() { + const noteRows = await this.db().selectAll('SELECT id FROM notes WHERE deleted_time != 0'); + const folderRows = await this.db().selectAll('SELECT id FROM folders WHERE deleted_time != 0'); + return { + noteIds: noteRows.map(r => r.id), + folderIds: folderRows.map(r => r.id), + }; + } + public static remoteDeletedItem(syncTarget: number, itemId: string) { return this.db().exec('DELETE FROM deleted_items WHERE item_id = ? AND sync_target = ?', [itemId, syncTarget]); } @@ -488,7 +497,7 @@ export default class BaseItem extends BaseModel { // List of keys that won't be encrypted - mostly foreign keys required to link items // with each others and timestamp required for synchronisation. - const keepKeys = ['id', 'note_id', 'tag_id', 'parent_id', 'share_id', 'updated_time', 'type_']; + const keepKeys = ['id', 'note_id', 'tag_id', 'parent_id', 'share_id', 'updated_time', 'deleted_time', 'type_']; const reducedItem: any = {}; for (let i = 0; i < keepKeys.length; i++) { @@ -917,7 +926,7 @@ export default class BaseItem extends BaseModel { const isNew = this.isNew(o, options); - if (needsReadOnlyChecks(this.modelType(), options.changeSource, this.syncShareCache)) { + if (needsShareReadOnlyChecks(this.modelType(), options.changeSource, this.syncShareCache)) { if (!isNew) { const previousItem = await this.loadItemByTypeAndId(this.modelType(), o.id, { fields: ['id', 'share_id'] }); checkIfItemCanBeChanged(this.modelType(), options.changeSource, previousItem, this.syncShareCache); diff --git a/packages/lib/models/Folder.test.ts b/packages/lib/models/Folder.test.ts index 39ecc02155..ed33d2a027 100644 --- a/packages/lib/models/Folder.test.ts +++ b/packages/lib/models/Folder.test.ts @@ -323,4 +323,34 @@ describe('models/Folder', () => { cleanup(); }); + it('should allow deleting a folder to trash', async () => { + const folder1 = await Folder.save({}); + const folder2 = await Folder.save({}); + const note1 = await Note.save({ parent_id: folder1.id }); + const note2 = await Note.save({ parent_id: folder1.id }); + const note3 = await Note.save({ parent_id: folder2.id }); + + const beforeTime = Date.now(); + await Folder.delete(folder1.id, { toTrash: true, deleteChildren: true }); + + expect((await Folder.load(folder1.id)).deleted_time).toBeGreaterThanOrEqual(beforeTime); + expect((await Folder.load(folder2.id)).deleted_time).toBe(0); + expect((await Note.load(note1.id)).deleted_time).toBeGreaterThanOrEqual(beforeTime); + expect((await Note.load(note2.id)).deleted_time).toBeGreaterThanOrEqual(beforeTime); + expect((await Note.load(note3.id)).deleted_time).toBe(0); + }); + + it('should delete and set the parent ID', async () => { + const folder1 = await Folder.save({}); + const folder2 = await Folder.save({}); + + await Folder.delete(folder1.id, { toTrash: true }); + await Folder.delete(folder2.id, { toTrash: true, toTrashParentId: folder1.id }); + + expect((await Folder.load(folder2.id)).parent_id).toBe(folder1.id); + + // But it should not allow moving a folder to itself + await expectThrow(async () => Folder.delete(folder2.id, { toTrash: true, toTrashParentId: folder2.id })); + }); + }); diff --git a/packages/lib/models/Folder.ts b/packages/lib/models/Folder.ts index b62ab9bfff..5e0db06635 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 { getTrashFolder, getTrashFolderId } from '../services/trash'; const { substrWithEllipsis } = require('../string-utils.js'); const logger = Logger.create('models/Folder'); @@ -45,25 +46,30 @@ export default class Folder extends BaseItem { return field in fieldsToLabels ? fieldsToLabels[field] : field; } - public static noteIds(parentId: string, options: any = null) { - options = { includeConflicts: false, ...options }; + public static async notes(parentId: string, options: LoadOptions = null) { + options = { + includeConflicts: false, + ...options, + }; const where = ['parent_id = ?']; if (!options.includeConflicts) { where.push('is_conflict = 0'); } - return this.db() - .selectAll(`SELECT id FROM notes WHERE ${where.join(' AND ')}`, [parentId]) - // eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied - .then((rows: any[]) => { - const output = []; - for (let i = 0; i < rows.length; i++) { - const row = rows[i]; - output.push(row.id); - } - return output; - }); + if (!options.includeDeleted) { + where.push('deleted_time = 0'); + } + + return this.modelSelectAll(`SELECT ${this.selectFields(options)} FROM notes WHERE ${where.join(' AND ')}`, [parentId]); + } + + public static async noteIds(parentId: string, options: LoadOptions = null) { + const notes = await this.notes(parentId, { + fields: ['id'], + ...options, + }); + return notes.map(n => n.id); } public static async subFolderIds(parentId: string) { @@ -81,6 +87,11 @@ export default class Folder extends BaseItem { return this.db().exec(query); } + public static byId(items: FolderEntity[], id: string) { + if (id === getTrashFolderId()) return getTrashFolder(); + return super.byId(items, id); + } + public static async deleteAllByShareId(shareId: string, deleteOptions: DeleteOptions = null) { const tableNameToClasses: Record = { 'folders': Folder, @@ -102,12 +113,18 @@ export default class Folder extends BaseItem { ...options, }; + if (folderId === getTrashFolderId()) throw new Error('The trash folder cannot be deleted'); + + const toTrash = !!options.toTrash; + const folder = await Folder.load(folderId); if (!folder) return; // noop if (options.deleteChildren) { const childrenDeleteOptions: DeleteOptions = { disableReadOnlyCheck: options.disableReadOnlyCheck, + deleteChildren: true, + toTrash, }; const noteIds = await Folder.noteIds(folderId); @@ -119,7 +136,14 @@ export default class Folder extends BaseItem { } } - await super.delete(folderId, options); + if (toTrash) { + const newFolder: FolderEntity = { id: folderId, deleted_time: Date.now() }; + if ('toTrashParentId' in options) newFolder.parent_id = options.toTrashParentId; + if (options.toTrashParentId === newFolder.id) throw new Error('Parent ID cannot be the same as ID'); + await this.save(newFolder); + } else { + await super.delete(folderId, options); + } this.dispatch({ type: 'FOLDER_DELETE', @@ -136,13 +160,15 @@ export default class Folder extends BaseItem { } public static conflictFolder(): FolderEntity { + const now = Date.now(); + return { type_: this.TYPE_FOLDER, id: this.conflictFolderId(), parent_id: '', title: this.conflictFolderTitle(), - updated_time: time.unixMs(), - user_updated_time: time.unixMs(), + updated_time: now, + user_updated_time: now, share_id: '', is_shared: 0, }; @@ -150,19 +176,28 @@ export default class Folder extends BaseItem { // Calculates note counts for all folders and adds the note_count attribute to each folder // Note: this only calculates the overall number of nodes for this folder and all its descendants - public static async addNoteCounts(folders: any[], includeCompletedTodos = true) { - const foldersById: any = {}; + public static async addNoteCounts(folders: FolderEntity[], includeCompletedTodos = true) { + // This is old code so we keep it, but we should never ever add properties to objects from + // the database. Eventually we should refactor this. + interface FolderEntityWithNoteCount extends FolderEntity { + note_count?: number; + } + + const foldersById: Record = {}; for (const f of folders) { foldersById[f.id] = f; if (this.conflictFolderId() === f.id) { - f.note_count = await Note.conflictedCount(); + foldersById[f.id].note_count = await Note.conflictedCount(); } else { - f.note_count = 0; + foldersById[f.id].note_count = 0; } } - const where = ['is_conflict = 0']; + const where = [ + 'is_conflict = 0', + 'notes.deleted_time = 0', + ]; if (!includeCompletedTodos) where.push('(notes.is_todo = 0 OR notes.todo_completed = 0)'); const sql = ` @@ -172,9 +207,14 @@ export default class Folder extends BaseItem { GROUP BY folders.id `; - const noteCounts = await this.db().selectAll(sql); + interface NoteCount { + folder_id: string; + note_count: number; + } + + const noteCounts: NoteCount[] = await this.db().selectAll(sql); // eslint-disable-next-line github/array-foreach -- Old code before rule was applied - noteCounts.forEach((noteCount: any) => { + noteCounts.forEach((noteCount) => { let parentId = noteCount.folder_id; do { const folder = foldersById[parentId]; @@ -251,23 +291,41 @@ export default class Folder extends BaseItem { } public static async all(options: FolderLoadOptions = null) { - const output = await super.all(options); + let output: FolderEntity[] = await super.all(options); + + if (options && options.includeDeleted === false) { + output = output.filter(f => !f.deleted_time); + } + + if (options && options.includeTrash) { + output.push(getTrashFolder()); + } + if (options && options.includeConflictFolder) { const conflictCount = await Note.conflictedCount(); if (conflictCount) output.push(this.conflictFolder()); } + return output; } - public static async childrenIds(folderId: string) { - const folders = await this.db().selectAll('SELECT id FROM folders WHERE parent_id = ?', [folderId]); + public static async childrenIds(folderId: string, options: LoadOptions = null) { + options = { ...options }; + + const where = ['parent_id = ?']; + + if (!options.includeDeleted) { + where.push('deleted_time = 0'); + } + + const folders = await this.db().selectAll(`SELECT id FROM folders WHERE ${where.join(' AND ')}`, [folderId]); let output: string[] = []; for (let i = 0; i < folders.length; i++) { const f = folders[i]; output.push(f.id); - const subChildrenIds = await this.childrenIds(f.id); + const subChildrenIds = await this.childrenIds(f.id, options); output = output.concat(subChildrenIds); } @@ -593,7 +651,11 @@ export default class Folder extends BaseItem { } public static async allAsTree(folders: FolderEntity[] = null, options: any = null) { - const all = folders ? folders : await this.all(options); + interface FolderWithNotes extends FolderEntity { + notes?: NoteEntity[]; + } + + const all: FolderWithNotes[] = folders ? folders : await this.all(options); if (options && options.includeNotes) { for (const folder of all) { @@ -722,8 +784,13 @@ export default class Folder extends BaseItem { return output; } + public static async loadByTitleAndParent(title: string, parentId: string, options: LoadOptions = null): Promise { + return await this.modelSelectOne(`SELECT ${this.selectFields(options)} FROM folders WHERE title = ? and parent_id = ?`, [title, parentId]); + } + public static load(id: string, options: LoadOptions = null): Promise { if (id === this.conflictFolderId()) return Promise.resolve(this.conflictFolder()); + if (id === getTrashFolderId()) return Promise.resolve(getTrashFolder()); return super.load(id, options); } @@ -815,7 +882,7 @@ export default class Folder extends BaseItem { // Ensures that any folder added to the state has all the required // properties, in particular "share_id" and "parent_id', which are // required in various parts of the code. - if (!('share_id' in savedFolder) || !('parent_id' in savedFolder)) { + if (!('share_id' in savedFolder) || !('parent_id' in savedFolder) || !('deleted_time' in savedFolder)) { savedFolder = await this.load(savedFolder.id); } @@ -827,6 +894,20 @@ export default class Folder extends BaseItem { return savedFolder; } + public static async trashItemsOlderThan(ttl: number) { + const cutOffTime = Date.now() - ttl; + + const getItemIds = async (table: string, cutOffTime: number): Promise => { + const items = await this.db().selectAll(`SELECT id from ${table} WHERE deleted_time > 0 AND deleted_time < ?`, [cutOffTime]); + return items.map(i => i.id); + }; + + return { + noteIds: await getItemIds('notes', cutOffTime), + folderIds: await getItemIds('folders', cutOffTime), + }; + } + public static serializeIcon(icon: FolderIcon): string { return icon ? JSON.stringify(icon) : ''; } diff --git a/packages/lib/models/Note.test.ts b/packages/lib/models/Note.test.ts index e69a731483..0fe4710599 100644 --- a/packages/lib/models/Note.test.ts +++ b/packages/lib/models/Note.test.ts @@ -2,7 +2,7 @@ import Setting from './Setting'; import BaseModel from '../BaseModel'; import shim from '../shim'; import markdownUtils from '../markdownUtils'; -import { sortedIds, createNTestNotes, expectThrow, setupDatabaseAndSynchronizer, switchClient, checkThrowAsync, supportDir, expectNotThrow, simulateReadOnlyShareEnv } from '../testing/test-utils'; +import { sortedIds, createNTestNotes, expectThrow, setupDatabaseAndSynchronizer, switchClient, checkThrowAsync, supportDir, expectNotThrow, simulateReadOnlyShareEnv, msleep, db } from '../testing/test-utils'; import Folder from './Folder'; import Note from './Note'; import Tag from './Tag'; @@ -12,6 +12,8 @@ import { ResourceEntity } from '../services/database/types'; import { toForwardSlashes } from '../path-utils'; import * as ArrayUtils from '../ArrayUtils'; import { ErrorCode } from '../errors'; +import SearchEngine from '../services/search/SearchEngine'; +import { getTrashFolderId } from '../services/trash'; async function allItems() { const folders = await Folder.all(); @@ -497,4 +499,86 @@ describe('models/Note', () => { cleanup(); })); + it('should delete a note to trash', async () => { + const folder = await Folder.save({}); + const note1 = await Note.save({ title: 'note1', parent_id: folder.id }); + const note2 = await Note.save({ title: 'note2', parent_id: folder.id }); + + const previousUpdatedTime = note1.updated_time; + + await msleep(1); + + await Note.delete(note1.id, { toTrash: true }); + + { + const n1 = await Note.load(note1.id); + expect(n1.deleted_time).toBeGreaterThan(0); + expect(n1.updated_time).toBeGreaterThan(previousUpdatedTime); + expect(n1.deleted_time).toBe(n1.updated_time); + + const n2 = await Note.load(note2.id); + expect(n2.deleted_time).toBe(0); + } + + { + const previews = await Note.previews(folder.id); + expect(previews.length).toBe(1); + expect(previews.find(n => n.id === note2.id)).toBeTruthy(); + } + + { + const engine = new SearchEngine(); + engine.setDb(db()); + await engine.syncTables(); + + const results = await engine.search('note*'); + expect(results.length).toBe(1); + expect(results[0].id).toBe(note2.id); + } + }); + + it('should return the notes from the trash', async () => { + const folder = await Folder.save({}); + const note1 = await Note.save({ title: 'note1', parent_id: folder.id }); + const note2 = await Note.save({ title: 'note2', parent_id: folder.id }); + const note3 = await Note.save({ title: 'note3', parent_id: folder.id }); + + await Note.delete(note1.id, { toTrash: true }); + await Note.delete(note2.id, { toTrash: true }); + + const folderNotes = await Note.previews(folder.id); + const trashNotes = await Note.previews(getTrashFolderId()); + + expect(folderNotes.map(f => f.id).sort()).toEqual([note3.id]); + expect(trashNotes.map(f => f.id).sort()).toEqual([note1.id, note2.id].sort()); + }); + + it('should handle folders within the trash', async () => { + const folder1 = await Folder.save({ title: 'folder1 ' }); + const folder2 = await Folder.save({ title: 'folder2 ' }); + const note1 = await Note.save({ title: 'note1', parent_id: folder1.id }); + const note2 = await Note.save({ title: 'note2', parent_id: folder1.id }); + await Note.save({ title: 'note3', parent_id: folder2.id }); + const note4 = await Note.save({ title: 'note4', parent_id: folder2.id }); + + await Folder.delete(folder1.id, { toTrash: true, deleteChildren: true }); + await Note.delete(note4.id, { toTrash: true }); + + // Note 4 should be at the root of the trash since its associated folder + // has not been deleted. + { + const trashNotes = await Note.previews(getTrashFolderId()); + expect(trashNotes.length).toBe(1); + expect(trashNotes[0].id).toBe(note4.id); + } + + // Note 1 and 2 should be within a "folder1" sub-folder within the trash + // since that folder has been deleted too. + { + const trashNotes = await Note.previews(folder1.id); + expect(trashNotes.length).toBe(2); + expect(trashNotes.map(n => n.id).sort()).toEqual([note1.id, note2.id].sort()); + } + }); + }); diff --git a/packages/lib/models/Note.ts b/packages/lib/models/Note.ts index 8424ad38b6..20cb77a2ba 100644 --- a/packages/lib/models/Note.ts +++ b/packages/lib/models/Note.ts @@ -1,11 +1,12 @@ import BaseModel, { DeleteOptions, ModelType } from '../BaseModel'; import BaseItem from './BaseItem'; +import type FolderClass from './Folder'; import ItemChange from './ItemChange'; import Setting from './Setting'; import shim from '../shim'; import time from '../time'; import markdownUtils from '../markdownUtils'; -import { NoteEntity } from '../services/database/types'; +import { FolderEntity, NoteEntity } from '../services/database/types'; import Tag from './Tag'; const { sprintf } = require('sprintf-js'); import Resource from './Resource'; @@ -13,8 +14,9 @@ import syncDebugLog from '../services/synchronizer/syncDebugLog'; import { toFileProtocolPath, toForwardSlashes } from '../path-utils'; const { pregQuote, substrWithEllipsis } = require('../string-utils.js'); const { _ } = require('../locale'); -import { pull, unique } from '../ArrayUtils'; +import { pull, removeElement, unique } from '../ArrayUtils'; import { LoadOptions, SaveOptions } from './utils/types'; +import { getDisplayParentId, getTrashFolderId } from '../services/trash'; const urlUtils = require('../urlUtils.js'); const { isImageMimeType } = require('../resourceUtils'); const { MarkupToHtml } = require('@joplin/renderer'); @@ -33,7 +35,7 @@ interface PreviewsOptions { anywherePattern?: string; itemTypes?: string[]; limit?: number; - includeDeleted?: boolean; + titlePattern?: string; } export default class Note extends BaseItem { @@ -328,7 +330,7 @@ export default class Note extends BaseItem { public static previewFields(options: any = null) { options = { includeTimestamps: true, ...options }; - const output = ['id', 'title', 'is_todo', 'todo_completed', 'todo_due', 'parent_id', 'encryption_applied', 'order', 'markup_language', 'is_conflict', 'is_shared', 'share_id']; + const output = ['id', 'title', 'is_todo', 'todo_completed', 'todo_due', 'parent_id', 'encryption_applied', 'order', 'markup_language', 'is_conflict', 'is_shared', 'share_id', 'deleted_time']; if (options.includeTimestamps) { output.push('updated_time'); @@ -358,7 +360,7 @@ export default class Note extends BaseItem { return results.length ? results[0] : null; } - public static async previews(parentId: string, options: any = null) { + public static async previews(parentId: string, options: PreviewsOptions = null) { // Note: ordering logic must be duplicated in sortNotes(), which is used // to sort already loaded notes. @@ -370,16 +372,43 @@ export default class Note extends BaseItem { if (!options.uncompletedTodosOnTop) options.uncompletedTodosOnTop = false; if (!('showCompletedTodos' in options)) options.showCompletedTodos = true; - const Folder = BaseItem.getClass('Folder'); + const autoAddedFields: string[] = []; + if (Array.isArray(options.fields)) { + options.fields = options.fields.slice(); + // These fields are required for the rest of the function to work + if (!options.fields.includes('deleted_time')) { + autoAddedFields.push('deleted_time'); + options.fields.push('deleted_time'); + } + if (!options.fields.includes('parent_id')) { + autoAddedFields.push('parent_id'); + options.fields.push('parent_id'); + } + if (!options.fields.includes('id')) { + autoAddedFields.push('id'); + options.fields.push('id'); + } + } + + const Folder: typeof FolderClass = BaseItem.getClass('Folder'); + + const parentFolder: FolderEntity = await Folder.load(parentId, { fields: ['id', 'deleted_time'] }); + const parentInTrash = parentFolder ? !!parentFolder.deleted_time : false; + const withinTrash = parentId === getTrashFolderId() || parentInTrash; // Conflicts are always displayed regardless of options, since otherwise // it's confusing to have conflicts but with an empty conflict folder. - if (parentId === Folder.conflictFolderId()) options.showCompletedTodos = true; + // For a similar reason we want to show all notes that have been deleted + // in the trash. + if (parentId === Folder.conflictFolderId() || withinTrash) options.showCompletedTodos = true; if (parentId === Folder.conflictFolderId()) { options.conditions.push('is_conflict = 1'); + } else if (withinTrash) { + options.conditions.push('deleted_time > 0'); } else { options.conditions.push('is_conflict = 0'); + options.conditions.push('deleted_time = 0'); if (parentId && parentId !== ALL_NOTES_FILTER_ID) { options.conditions.push('parent_id = ?'); options.conditionsParams.push(parentId); @@ -407,11 +436,11 @@ export default class Note extends BaseItem { options.conditions.push('todo_completed <= 0'); } - if (options.uncompletedTodosOnTop && hasTodos) { + if (!withinTrash && options.uncompletedTodosOnTop && hasTodos) { let cond = options.conditions.slice(); cond.push('is_todo = 1'); cond.push('(todo_completed <= 0 OR todo_completed IS NULL)'); - let tempOptions = { ...options }; + let tempOptions: PreviewsOptions = { ...options }; tempOptions.conditions = cond; const uncompletedTodos = await this.search(tempOptions); @@ -441,9 +470,32 @@ export default class Note extends BaseItem { options.conditions.push('is_todo = 1'); } - const results = await this.search(options); + let results = await this.search(options); this.handleTitleNaturalSorting(results, options); + if (withinTrash) { + const folderIds = results.map(n => n.parent_id).filter(id => !!id); + const allFolders: FolderEntity[] = await Folder.byIds(folderIds, { fields: ['id', 'parent_id', 'deleted_time', 'title'] }); + + // In the results, we only include notes that were originally at the + // root (no parent), or that are inside a folder that has also been + // deleted. + results = results.filter(note => { + const noteFolder = allFolders.find(f => f.id === note.parent_id); + return getDisplayParentId(note, noteFolder) === parentId; + }); + } + + if (autoAddedFields.length) { + results = results.map(n => { + n = { ...n }; + for (const field of autoAddedFields) { + delete (n as any)[field]; + } + return n; + }); + } + return results; } @@ -554,14 +606,19 @@ export default class Note extends BaseItem { public static async moveToFolder(noteId: string, folderId: string) { if (folderId === this.getClass('Folder').conflictFolderId()) throw new Error(_('Cannot move note to "%s" notebook', this.getClass('Folder').conflictFolderTitle())); - // When moving a note to a different folder, the user timestamp is not updated. - // However updated_time is updated so that the note can be synced later on. + // When moving a note to a different folder, the user timestamp is not + // updated. However updated_time is updated so that the note can be + // synced later on. + // + // We also reset deleted_time, so that if a deleted note is moved to + // that folder it is restored. If it wasn't deleted, it does nothing. - const modifiedNote = { + const modifiedNote: NoteEntity = { id: noteId, parent_id: folderId, is_conflict: 0, conflict_original_id: '', + deleted_time: 0, updated_time: time.unixMs(), }; @@ -699,6 +756,7 @@ export default class Note extends BaseItem { if (isNew && !o.source) o.source = Setting.value('appName'); if (isNew && !o.source_application) o.source_application = Setting.value('appId'); if (isNew && !('order' in o)) o.order = Date.now(); + if (isNew && !('deleted_time' in o)) o.deleted_time = 0; const changeSource = options && options.changeSource ? options.changeSource : null; @@ -736,14 +794,23 @@ export default class Note extends BaseItem { syncDebugLog.info('Save Note: N:', o); - const note = await super.save(o, options); + let savedNote = await super.save(o, options); - void ItemChange.add(BaseModel.TYPE_NOTE, note.id, isNew ? ItemChange.TYPE_CREATE : ItemChange.TYPE_UPDATE, changeSource, beforeNoteJson); + void ItemChange.add(BaseModel.TYPE_NOTE, savedNote.id, isNew ? ItemChange.TYPE_CREATE : ItemChange.TYPE_UPDATE, changeSource, beforeNoteJson); if (dispatchUpdateAction) { + // Ensures that any note added to the state has all the required + // properties for the UI to work. + if (!('deleted_time' in savedNote)) { + const fields = removeElement(unique(this.previewFields().concat(Object.keys(savedNote))), 'type_'); + savedNote = await this.load(savedNote.id, { + fields, + }); + } + this.dispatch({ type: 'NOTE_UPDATE_ONE', - note: note, + note: savedNote, provisional: isProvisional, ignoreProvisionalFlag: ignoreProvisionalFlag, changedFields: changedFields, @@ -753,30 +820,63 @@ export default class Note extends BaseItem { if ('todo_due' in o || 'todo_completed' in o || 'is_todo' in o || 'is_conflict' in o) { this.dispatch({ type: 'EVENT_NOTE_ALARM_FIELD_CHANGE', - id: note.id, + id: savedNote.id, }); } - return note; + return savedNote; } public static async batchDelete(ids: string[], options: DeleteOptions = null) { + if (!ids.length) return; + ids = ids.slice(); + const changeSource = options && options.changeSource ? options.changeSource : null; + const changeType = options && options.toTrash ? ItemChange.TYPE_UPDATE : ItemChange.TYPE_DELETE; + const toTrash = options && !!options.toTrash; + while (ids.length) { const processIds = ids.splice(0, 50); const notes = await Note.byIds(processIds); const beforeChangeItems: any = {}; for (const note of notes) { - beforeChangeItems[note.id] = JSON.stringify(note); + beforeChangeItems[note.id] = toTrash ? null : JSON.stringify(note); + } + + if (toTrash) { + const now = Date.now(); + + const updateSql = [ + 'deleted_time = ?', + 'updated_time = ?', + ]; + + const params: any[] = [ + now, + now, + ]; + + if ('toTrashParentId' in options) { + updateSql.push('parent_id = ?'); + params.push(options.toTrashParentId); + } + + const sql = ` + UPDATE notes + SET ${updateSql.join(', ')} + WHERE id IN ("${processIds.join('","')}") + `; + + await this.db().exec({ sql, params }); + } else { + await super.batchDelete(processIds, options); } - await super.batchDelete(processIds, options); - const changeSource = options && options.changeSource ? options.changeSource : null; for (let i = 0; i < processIds.length; i++) { const id = processIds[i]; - void ItemChange.add(BaseModel.TYPE_NOTE, id, ItemChange.TYPE_DELETE, changeSource, beforeChangeItems[id]); + void ItemChange.add(BaseModel.TYPE_NOTE, id, changeType, changeSource, beforeChangeItems[id]); this.dispatch({ type: 'NOTE_DELETE', @@ -786,14 +886,14 @@ export default class Note extends BaseItem { } } - public static async deleteMessage(noteIds: string[]): Promise { + public static async permanentlyDeleteMessage(noteIds: string[]): Promise { let msg = ''; if (noteIds.length === 1) { const note = await Note.load(noteIds[0]); if (!note) return null; - msg = _('Delete note "%s"?', substrWithEllipsis(note.title, 0, 32)); + msg = _('Permanently delete note "%s"?', substrWithEllipsis(note.title, 0, 32)); } else { - msg = _('Delete these %d notes?', noteIds.length); + msg = _('Permanently delete these %d notes?', noteIds.length); } return msg; } diff --git a/packages/lib/models/Setting.ts b/packages/lib/models/Setting.ts index 258c1bda1b..b407973a9a 100644 --- a/packages/lib/models/Setting.ts +++ b/packages/lib/models/Setting.ts @@ -1803,6 +1803,31 @@ class Setting extends BaseModel { label: () => _('Voice typing language files (URL)'), section: 'note', }, + + 'trash.autoDeletionEnabled': { + value: true, + type: SettingItemType.Bool, + public: true, + label: () => _('Automatically delete notes in the trash after a number of days'), + storage: SettingStorage.File, + isGlobal: false, + }, + + 'trash.ttlDays': { + value: 90, + type: SettingItemType.Int, + public: true, + minimum: 1, + maximum: 300, + step: 1, + unitLabel: (value: number = null) => { + return value === null ? _('days') : _('%d days', value); + }, + show: (settings: any) => settings['trash.autoDeletionEnabled'], + label: () => _('Keep notes in the trash for'), + storage: SettingStorage.File, + isGlobal: false, + }, }; this.metadata_ = { ...this.buildInMetadata_ }; diff --git a/packages/lib/models/Tag.test.ts b/packages/lib/models/Tag.test.ts index 19c9680c26..b9df787e4e 100644 --- a/packages/lib/models/Tag.test.ts +++ b/packages/lib/models/Tag.test.ts @@ -57,6 +57,26 @@ describe('models/Tag', () => { expect(notesTag3.map(n => n.id).sort()).toEqual([].sort()); }); + it('should not retrieve deleted notes', async () => { + const note1 = await Note.save({}); + const note2 = await Note.save({}); + + await Tag.setNoteTagsByTitles(note1.id, ['un']); + await Tag.setNoteTagsByTitles(note2.id, ['un']); + + const tag1 = await Tag.loadByTitle('un'); + + await Note.delete(note1.id, { toTrash: true }); + + expect(await Tag.noteIds(tag1.id)).toEqual([note2.id]); + expect((await Tag.notes(tag1.id)).map(n => n.id).sort()).toEqual([note2.id]); + expect(await Tag.hasNote(tag1.id, note1.id)).toBe(false); + expect(await Tag.hasNote(tag1.id, note2.id)).toBe(true); + + const allWithNotes = await Tag.allWithNotes(); + expect(allWithNotes[0].note_count).toBe(1); + }); + it('should not allow renaming tag to existing tag names', async () => { const folder1 = await Folder.save({ title: 'folder1' }); const note1 = await Note.save({ title: 'ma note', parent_id: folder1.id }); diff --git a/packages/lib/models/Tag.ts b/packages/lib/models/Tag.ts index 6e61778bbb..614f2299bb 100644 --- a/packages/lib/models/Tag.ts +++ b/packages/lib/models/Tag.ts @@ -16,7 +16,12 @@ export default class Tag extends BaseItem { } public static async noteIds(tagId: string) { - const rows = await this.db().selectAll('SELECT note_id FROM note_tags WHERE tag_id = ?', [tagId]); + const rows = await this.db().selectAll(` + SELECT note_id + FROM note_tags + LEFT JOIN notes ON notes.id = note_tags.note_id + WHERE tag_id = ? AND notes.deleted_time = 0 + `, [tagId]); const output = []; for (let i = 0; i < rows.length; i++) { output.push(rows[i].note_id); @@ -105,7 +110,13 @@ export default class Tag extends BaseItem { } public static async hasNote(tagId: string, noteId: string) { - const r = await this.db().selectOne('SELECT note_id FROM note_tags WHERE tag_id = ? AND note_id = ? LIMIT 1', [tagId, noteId]); + const r = await this.db().selectOne(` + SELECT note_id + FROM note_tags + LEFT JOIN notes ON notes.id = note_tags.note_id + WHERE tag_id = ? AND note_id = ? AND deleted_time = 0 + LIMIT 1 + `, [tagId, noteId]); return !!r; } diff --git a/packages/lib/models/utils/onFolderDrop.test.ts b/packages/lib/models/utils/onFolderDrop.test.ts new file mode 100644 index 0000000000..eb8468be59 --- /dev/null +++ b/packages/lib/models/utils/onFolderDrop.test.ts @@ -0,0 +1,73 @@ +import { getTrashFolderId } from '../../services/trash'; +import { setupDatabaseAndSynchronizer, switchClient } from '../../testing/test-utils'; +import Folder from '../Folder'; +import Note from '../Note'; +import onFolderDrop from './onFolderDrop'; + +describe('onFolderDrop', () => { + + beforeEach(async () => { + await setupDatabaseAndSynchronizer(1); + await switchClient(1); + }); + + it('should drop a note to the trash', async () => { + const note = await Note.save({}); + const beforeTime = Date.now(); + await onFolderDrop([note.id], [], getTrashFolderId()); + + const n = await Note.load(note.id); + expect(n.deleted_time).toBeGreaterThanOrEqual(beforeTime); + }); + + it('should drop a note in the trash to the root of the trash', async () => { + const folder = await Folder.save({}); + const note = await Note.save({ parent_id: folder.id }); + const beforeTime = Date.now(); + await Folder.delete(folder.id, { toTrash: true }); + + await onFolderDrop([note.id], [], getTrashFolderId()); + + const n = await Note.load(note.id); + expect(n.deleted_time).toBeGreaterThan(beforeTime); + expect(n.parent_id).toBe(''); + }); + + it('should drop a folder in the trash to the root of the trash', async () => { + const folder1 = await Folder.save({}); + const folder2 = await Folder.save({ parent_id: folder1.id }); + await Folder.delete(folder1.id, { toTrash: true }); + + await onFolderDrop([], [folder2.id], getTrashFolderId()); + + const f = await Folder.load(folder2.id); + expect(f.deleted_time).toBeTruthy(); + expect(f.parent_id).toBe(''); + }); + + it('should drop a deleted folder to a non-deleted one', async () => { + const folder1 = await Folder.save({}); + const folder2 = await Folder.save({}); + await Folder.delete(folder2.id, { toTrash: true }); + + await onFolderDrop([], [folder2.id], folder1.id); + + const f2 = await Folder.load(folder2.id); + expect(f2.deleted_time).toBe(0); + expect(f2.parent_id).toBe(folder1.id); + }); + + it('should drop a deleted note to a non-deleted folder', async () => { + const folder1 = await Folder.save({}); + const folder2 = await Folder.save({}); + const note1 = await Note.save({ parent_id: folder1.id }); + await Note.delete(note1.id, { toTrash: true }); + + await onFolderDrop([note1.id], [], folder2.id); + + const n1 = await Note.load(note1.id); + expect(n1.deleted_time).toBe(0); + expect(n1.parent_id).toBe(folder2.id); + }); + +}); diff --git a/packages/lib/models/utils/onFolderDrop.ts b/packages/lib/models/utils/onFolderDrop.ts new file mode 100644 index 0000000000..bfe71846f8 --- /dev/null +++ b/packages/lib/models/utils/onFolderDrop.ts @@ -0,0 +1,42 @@ +import { DeleteOptions, ModelType } from '../../BaseModel'; +import { FolderEntity, NoteEntity } from '../../services/database/types'; +import { getTrashFolderId } from '../../services/trash'; +import restoreItems from '../../services/trash/restoreItems'; +import Folder from '../Folder'; +import Note from '../Note'; + +export default async (noteIds: string[], folderIds: string[], targetFolderId: string) => { + const targetFolder = await Folder.load(targetFolderId, { fields: ['id', 'deleted_time'] }); + + if (!targetFolder) throw new Error(`No such folder: ${targetFolderId}`); + + const defaultDeleteOptions: DeleteOptions = { toTrash: true }; + + if (targetFolder.id !== getTrashFolderId()) { + defaultDeleteOptions.toTrashParentId = targetFolder.id; + } + + async function processList(itemType: ModelType, itemIds: string[]) { + const ModelClass = itemType === ModelType.Note ? Note : Folder; + const items: T[] = await ModelClass.byIds(itemIds, { fields: ['id', 'deleted_time', 'parent_id'] }); + + for (const item of items) { + if (item.id === targetFolder.id) continue; + + if (targetFolder.deleted_time || targetFolder.id === getTrashFolderId()) { + if (item.deleted_time && targetFolder.id === getTrashFolderId()) { + await ModelClass.delete(item.id, { ...defaultDeleteOptions, toTrashParentId: '' }); + } else { + await ModelClass.delete(item.id, defaultDeleteOptions); + } + } else if (item.deleted_time && !targetFolder.deleted_time) { + await restoreItems(itemType, [item], { targetFolderId: targetFolder.id }); + } else { + await ModelClass.moveToFolder(item.id, targetFolderId); + } + } + } + + await processList(ModelType.Note, noteIds); + await processList(ModelType.Folder, folderIds); +}; diff --git a/packages/lib/models/utils/paginatedFeed.ts b/packages/lib/models/utils/paginatedFeed.ts index e829e9c1dc..94bbe09d78 100755 --- a/packages/lib/models/utils/paginatedFeed.ts +++ b/packages/lib/models/utils/paginatedFeed.ts @@ -9,7 +9,7 @@ export interface ModelFeedPage { export interface WhereQuery { sql: string; - params: any[]; + params?: any[]; } // Note: this method might return more fields than was requested as it will @@ -48,8 +48,6 @@ export default async function(db: any, tableName: string, pagination: Pagination OFFSET ${offset} `; - // console.info('SQL', sql, sqlParams); - const rows = await db.selectAll(sql, sqlParams); return { diff --git a/packages/lib/models/utils/readOnly.ts b/packages/lib/models/utils/readOnly.ts index 7eb8aabff4..624581e4d4 100644 --- a/packages/lib/models/utils/readOnly.ts +++ b/packages/lib/models/utils/readOnly.ts @@ -5,18 +5,20 @@ import JoplinError from '../../JoplinError'; import { State as ShareState } from '../../services/share/reducer'; import ItemChange from '../ItemChange'; import Setting from '../Setting'; +import { checkObjectHasProperties } from '@joplin/utils/object'; const logger = Logger.create('models/utils/readOnly'); export interface ItemSlice { id?: string; share_id: string; + deleted_time: number; } -// This function can be called to wrap any read-only-related code. It should be -// fast and allows an early exit for cases that don't apply, for example if not +// This function can be called to wrap code that related to share permission read-only checks. It +// should be fast and allows an early exit for cases that don't apply, for example if not // synchronising with Joplin Cloud or if not sharing any notebook. -export const needsReadOnlyChecks = (itemType: ModelType, changeSource: number, shareState: ShareState, disableReadOnlyCheck = false) => { +export const needsShareReadOnlyChecks = (itemType: ModelType, changeSource: number, shareState: ShareState, disableReadOnlyCheck = false) => { if (disableReadOnlyCheck) return false; if (Setting.value('sync.target') !== 10) return false; if (changeSource === ItemChange.SOURCE_SYNC) return false; @@ -35,16 +37,16 @@ export const checkIfItemsCanBeChanged = (itemType: ModelType, changeSource: numb }; export const checkIfItemCanBeChanged = (itemType: ModelType, changeSource: number, item: ItemSlice, shareState: ShareState) => { - if (!needsReadOnlyChecks(itemType, changeSource, shareState)) return; + if (!needsShareReadOnlyChecks(itemType, changeSource, shareState)) return; if (!item) return; - if (itemIsReadOnlySync(itemType, changeSource, item, Setting.value('sync.userId'), shareState)) { + if (itemIsReadOnlySync(itemType, changeSource, item, Setting.value('sync.userId'), shareState, true)) { throw new JoplinError(`Cannot change or delete a read-only item: ${item.id}`, ErrorCode.IsReadOnly); } }; export const checkIfItemCanBeAddedToFolder = async (itemType: ModelType, Folder: any, changeSource: number, shareState: ShareState, parentId: string) => { - if (needsReadOnlyChecks(itemType, changeSource, shareState) && parentId) { + if (needsShareReadOnlyChecks(itemType, changeSource, shareState) && parentId) { const parentFolder = await Folder.load(parentId, { fields: ['id', 'share_id'] }); if (!parentFolder) { @@ -58,16 +60,27 @@ export const checkIfItemCanBeAddedToFolder = async (itemType: ModelType, Folder: return; } - if (itemIsReadOnlySync(itemType, changeSource, parentFolder, Setting.value('sync.userId'), shareState)) { + if (itemIsReadOnlySync(itemType, changeSource, parentFolder, Setting.value('sync.userId'), shareState, true)) { throw new JoplinError('Cannot add an item as a child of a read-only item', ErrorCode.IsReadOnly); } } }; -export const itemIsReadOnlySync = (itemType: ModelType, changeSource: number, item: ItemSlice, userId: string, shareState: ShareState): boolean => { - if (!needsReadOnlyChecks(itemType, changeSource, shareState)) return false; +// Originally all these functions were there to handle share permissions - a note, folder or +// resource that is not editable would be read-only. However this particular function now is also +// used to tell if a note is read-only because it is in the trash. +// +// But this requires access to more properties, `deleted_time` in particular, which are not needed +// for share-related checks (and does not exist on Resource objects). So this is why there's this +// extra `sharePermissionCheckOnly` boolean to do the check for one case or the other. A bit of a +// hack but good enough for now. +export const itemIsReadOnlySync = (itemType: ModelType, changeSource: number, item: ItemSlice, userId: string, shareState: ShareState, sharePermissionCheckOnly = false): boolean => { + checkObjectHasProperties(item, sharePermissionCheckOnly ? ['share_id'] : ['share_id', 'deleted_time']); - if (!('share_id' in item)) throw new Error('share_id property is missing'); + // Item is in trash + if (!sharePermissionCheckOnly && item.deleted_time) return true; + + if (!needsShareReadOnlyChecks(itemType, changeSource, shareState)) return false; // Item is not shared if (!item.share_id) return false; @@ -84,8 +97,8 @@ export const itemIsReadOnlySync = (itemType: ModelType, changeSource: number, it }; export const itemIsReadOnly = async (BaseItem: any, itemType: ModelType, changeSource: number, itemId: string, userId: string, shareState: ShareState): Promise => { - if (!needsReadOnlyChecks(itemType, changeSource, shareState)) return false; - const item: ItemSlice = await BaseItem.loadItem(itemType, itemId, { fields: ['id', 'share_id'] }); + // if (!needsShareReadOnlyChecks(itemType, changeSource, shareState)) return false; + const item: ItemSlice = await BaseItem.loadItem(itemType, itemId, { fields: ['id', 'share_id', 'deleted_time'] }); if (!item) throw new JoplinError(`No such item: ${itemType}: ${itemId}`, ErrorCode.NotFound); return itemIsReadOnlySync(itemType, changeSource, item, userId, shareState); }; diff --git a/packages/lib/models/utils/types.ts b/packages/lib/models/utils/types.ts index 3997de204c..53f05d761e 100644 --- a/packages/lib/models/utils/types.ts +++ b/packages/lib/models/utils/types.ts @@ -29,10 +29,13 @@ export interface LoadOptions { caseInsensitive?: boolean; }[]; limit?: number; + includeConflicts?: boolean; + includeDeleted?: boolean; } export interface FolderLoadOptions extends LoadOptions { includeConflictFolder?: boolean; + includeTrash?: boolean; } export interface SaveOptions { diff --git a/packages/lib/reducer.ts b/packages/lib/reducer.ts index b6bd02e16f..6ec1a25999 100644 --- a/packages/lib/reducer.ts +++ b/packages/lib/reducer.ts @@ -7,9 +7,10 @@ import BaseModel from './BaseModel'; import { Store } from 'redux'; import { ProfileConfig } from './services/profileConfig/types'; import * as ArrayUtils from './ArrayUtils'; -import { FolderEntity } from './services/database/types'; +import { FolderEntity, NoteEntity } from './services/database/types'; import { getListRendererIds } from './services/noteList/renderers'; import { ProcessResultsRow } from './services/search/SearchEngine'; +import { getDisplayParentId } from './services/trash'; const fastDeepEqual = require('fast-deep-equal'); const { ALL_NOTES_FILTER_ID } = require('./reserved-ids'); const { createSelectorCreator, defaultMemoize } = require('reselect'); @@ -53,8 +54,14 @@ interface StateResourceFetcher { toFetchCount: number; } +export interface StateLastDeletion { + noteIds: string[]; + folderIds: string[]; + timestamp: number; +} + export interface State { - notes: any[]; + notes: NoteEntity[]; noteSelectionEnabled?: boolean; notesSource: string; notesParentType: string; @@ -102,6 +109,8 @@ export interface State { profileConfig: ProfileConfig; noteListRendererIds: string[]; noteListLastSortTime: number; + lastDeletion: StateLastDeletion; + lastDeletionNotificationTime: number; mustUpgradeAppMessage: string; // Extra reducer keys go here: @@ -177,6 +186,12 @@ export const defaultState: State = { profileConfig: null, noteListRendererIds: getListRendererIds(), noteListLastSortTime: 0, + lastDeletion: { + noteIds: [], + folderIds: [], + timestamp: 0, + }, + lastDeletionNotificationTime: 0, mustUpgradeAppMessage: '', pluginService: pluginServiceDefaultState, @@ -839,6 +854,19 @@ const reducer = produce((draft: Draft = defaultState, action: any) => { } break; + case 'ITEMS_TRASHED': + + draft.lastDeletion = { + ...action.value, + timestamp: Date.now(), + }; + break; + + case 'DELETION_NOTIFICATION_DONE': + + draft.lastDeletionNotificationTime = Date.now(); + break; + case 'NOTE_PROVISIONAL_FLAG_CLEAR': { const newIds = ArrayUtils.removeElement(draft.provisionalNoteIds, action.id); @@ -860,14 +888,14 @@ const reducer = produce((draft: Draft = defaultState, action: any) => { // update it within the note array if it already exists. case 'NOTE_UPDATE_ONE': { - const modNote = action.note; + const modNote: NoteEntity = action.note; const isViewingAllNotes = (draft.notesParentType === 'SmartFilter' && draft.selectedSmartFilterId === ALL_NOTES_FILTER_ID); const isViewingConflictFolder = draft.notesParentType === 'Folder' && draft.selectedFolderId === Folder.conflictFolderId(); - const noteIsInFolder = function(note: any, folderId: string) { + const noteIsInFolder = function(note: NoteEntity, folderId: string) { if (note.is_conflict && isViewingConflictFolder) return true; - if (!('parent_id' in modNote) || note.parent_id === folderId) return true; - return false; + const noteDisplayParentId = getDisplayParentId(note, draft.folders.find(f => f.id === note.parent_id)); + return folderId === noteDisplayParentId; }; let movedNotePreviousIndex = 0; @@ -883,7 +911,7 @@ const reducer = produce((draft: Draft = defaultState, action: any) => { newNotes.splice(i, 1); noteFolderHasChanged = true; movedNotePreviousIndex = i; - } else if (isViewingAllNotes || noteIsInFolder(modNote, n.parent_id)) { + } else if (isViewingAllNotes || noteIsInFolder(modNote, draft.selectedFolderId)) { // Note is still in the same folder // Merge the properties that have changed (in modNote) into // the object we already have. @@ -891,7 +919,7 @@ const reducer = produce((draft: Draft = defaultState, action: any) => { for (const n in modNote) { if (!modNote.hasOwnProperty(n)) continue; - newNotes[i][n] = modNote[n]; + (newNotes[i] as any)[n] = (modNote as any)[n]; } } else { // Note has moved to a different folder diff --git a/packages/lib/services/KeymapService.ts b/packages/lib/services/KeymapService.ts index df5cacd1bf..11e12e8a16 100644 --- a/packages/lib/services/KeymapService.ts +++ b/packages/lib/services/KeymapService.ts @@ -60,6 +60,7 @@ const defaultKeymapItems = { { accelerator: 'Option+Cmd+1', command: 'switchProfile1' }, { accelerator: 'Option+Cmd+2', command: 'switchProfile2' }, { accelerator: 'Option+Cmd+3', command: 'switchProfile3' }, + { accelerator: 'Option+Cmd+Backspace', command: 'permanentlyDeleteNote' }, ], default: [ { accelerator: 'Ctrl+N', command: 'newNote' }, @@ -106,6 +107,7 @@ const defaultKeymapItems = { { accelerator: 'Ctrl+Alt+1', command: 'switchProfile1' }, { accelerator: 'Ctrl+Alt+2', command: 'switchProfile2' }, { accelerator: 'Ctrl+Alt+3', command: 'switchProfile3' }, + { accelerator: 'Shift+Delete', command: 'permanentlyDeleteNote' }, ], }; diff --git a/packages/lib/services/ResourceService.test.ts b/packages/lib/services/ResourceService.test.ts index f10e00547f..52e7855d39 100644 --- a/packages/lib/services/ResourceService.test.ts +++ b/packages/lib/services/ResourceService.test.ts @@ -235,30 +235,20 @@ describe('services/ResourceService', () => { expect(await Resource.load(resource.id)).toBeTruthy(); })); - // it('should auto-delete resource even if the associated note was deleted immediately', (async () => { - // // Previously, when a resource was be attached to a note, then the - // // note was immediately deleted, the ResourceService would not have - // // time to quick in an index the resource/note relation. It means - // // that when doing the orphan resource deletion job, those - // // resources would permanently stay behind. - // // https://github.com/laurent22/joplin/issues/932 + it('should still create associations for notes in the trash', async () => { + const note = await Note.save({}); + await shim.attachFileToNote(note, `${supportDir}/photo.jpg`); + await Note.delete(note.id, { toTrash: true }); + await resourceService().indexNoteResources(); - // const service = new ResourceService(); + // Check that the association is made despite the note being deleted + const noteResources = await NoteResource.all(); + expect(noteResources.length).toBe(1); + expect(noteResources[0].note_id).toBe(note.id); - // let note = await Note.save({}); - // note = await shim.attachFileToNote(note, `${supportDir}/photo.jpg`); - // const resource = (await Resource.all())[0]; - - // const noteIds = await NoteResource.associatedNoteIds(resource.id); - - // expect(noteIds[0]).toBe(note.id); - - // await Note.save({ id: note.id, body: '' }); - - // await resourceService().indexNoteResources(); - // await service.deleteOrphanResources(0); - - // expect((await Resource.all()).length).toBe(0); - // })); + // Also check that the resources are not deleted as orphan + await resourceService().deleteOrphanResources(0); + expect((await NoteResource.all()).length).toBe(1); + }); }); diff --git a/packages/lib/services/commands/stateToWhenClauseContext.ts b/packages/lib/services/commands/stateToWhenClauseContext.ts index b70a2e5220..89b4a11bc6 100644 --- a/packages/lib/services/commands/stateToWhenClauseContext.ts +++ b/packages/lib/services/commands/stateToWhenClauseContext.ts @@ -6,6 +6,7 @@ import { isRootSharedFolder, isSharedFolderOwner } from '../share/reducer'; import { FolderEntity, NoteEntity } from '../database/types'; import { itemIsReadOnlySync, ItemSlice } from '../../models/utils/readOnly'; import ItemChange from '../../models/ItemChange'; +import { getTrashFolderId } from '../trash'; export interface WhenClauseContextOptions { commandFolderId?: string; @@ -13,29 +14,34 @@ export interface WhenClauseContextOptions { } export interface WhenClauseContext { - notesAreBeingSaved: boolean; - syncStarted: boolean; - inConflictFolder: boolean; - oneNoteSelected: boolean; - someNotesSelected: boolean; - multipleNotesSelected: boolean; - noNotesSelected: boolean; - historyhasBackwardNotes: boolean; - historyhasForwardNotes: boolean; - oneFolderSelected: boolean; - noteIsTodo: boolean; - noteTodoCompleted: boolean; - noteIsMarkdown: boolean; - noteIsHtml: boolean; - folderIsShareRootAndNotOwnedByUser: boolean; - folderIsShareRootAndOwnedByUser: boolean; + allSelectedNotesAreDeleted: boolean; + folderIsDeleted: boolean; + folderIsReadOnly: boolean; folderIsShared: boolean; folderIsShareRoot: boolean; - joplinServerConnected: boolean; - joplinCloudAccountType: number; + folderIsShareRootAndNotOwnedByUser: boolean; + folderIsShareRootAndOwnedByUser: boolean; + folderIsTrash: boolean; hasMultiProfiles: boolean; + historyhasBackwardNotes: boolean; + historyhasForwardNotes: boolean; + inConflictFolder: boolean; + inTrash: boolean; + joplinCloudAccountType: number; + joplinServerConnected: boolean; + multipleNotesSelected: boolean; + noNotesSelected: boolean; + noteIsDeleted: boolean; + noteIsHtml: boolean; + noteIsMarkdown: boolean; noteIsReadOnly: boolean; - folderIsReadOnly: boolean; + noteIsTodo: boolean; + notesAreBeingSaved: boolean; + noteTodoCompleted: boolean; + oneFolderSelected: boolean; + oneNoteSelected: boolean; + someNotesSelected: boolean; + syncStarted: boolean; } export default function stateToWhenClauseContext(state: State, options: WhenClauseContextOptions = null): WhenClauseContext { @@ -48,6 +54,7 @@ export default function stateToWhenClauseContext(state: State, options: WhenClau const selectedNoteIds = state.selectedNoteIds || []; const selectedNoteId = selectedNoteIds.length === 1 ? selectedNoteIds[0] : null; const selectedNote: NoteEntity = selectedNoteId ? BaseModel.byId(state.notes, selectedNoteId) : null; + const selectedNotes = selectedNoteIds.map(id => state.notes.find(n => n.id === id)).filter(n => !!n); const commandFolderId = options.commandFolderId || state.selectedFolderId; const commandFolder: FolderEntity = commandFolderId ? BaseModel.byId(state.folders, commandFolderId) : null; @@ -61,6 +68,7 @@ export default function stateToWhenClauseContext(state: State, options: WhenClau // Current location inConflictFolder: state.selectedFolderId === Folder.conflictFolderId(), + inTrash: state.selectedFolderId === getTrashFolderId() || commandFolder && !!commandFolder.deleted_time, // Note selection oneNoteSelected: !!selectedNote, @@ -68,6 +76,9 @@ export default function stateToWhenClauseContext(state: State, options: WhenClau multipleNotesSelected: selectedNoteIds.length > 1, noNotesSelected: !selectedNoteIds.length, + // Selected notes properties + allSelectedNotesAreDeleted: !selectedNotes.find(n => !n.deleted_time), + // Note history historyhasBackwardNotes: state.backwardHistoryNotes && state.backwardHistoryNotes.length > 0, historyhasForwardNotes: state.forwardHistoryNotes && state.forwardHistoryNotes.length > 0, @@ -80,19 +91,20 @@ export default function stateToWhenClauseContext(state: State, options: WhenClau noteTodoCompleted: selectedNote ? !!selectedNote.todo_completed : false, noteIsMarkdown: selectedNote ? selectedNote.markup_language === MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN : false, noteIsHtml: selectedNote ? selectedNote.markup_language === MarkupToHtml.MARKUP_LANGUAGE_HTML : false, + noteIsReadOnly: selectedNote ? itemIsReadOnlySync(ModelType.Note, ItemChange.SOURCE_UNSPECIFIED, selectedNote as ItemSlice, settings['sync.userId'], state.shareService) : false, + noteIsDeleted: selectedNote ? !!selectedNote.deleted_time : false, // Current context folder folderIsShareRoot: commandFolder ? isRootSharedFolder(commandFolder) : false, folderIsShareRootAndNotOwnedByUser: commandFolder ? isRootSharedFolder(commandFolder) && !isSharedFolderOwner(state, commandFolder.id) : false, folderIsShareRootAndOwnedByUser: commandFolder ? isRootSharedFolder(commandFolder) && isSharedFolderOwner(state, commandFolder.id) : false, folderIsShared: commandFolder ? !!commandFolder.share_id : false, + folderIsDeleted: commandFolder ? !!commandFolder.deleted_time : false, + folderIsTrash: commandFolder ? commandFolder.id === getTrashFolderId() : false, + folderIsReadOnly: commandFolder ? itemIsReadOnlySync(ModelType.Note, ItemChange.SOURCE_UNSPECIFIED, commandFolder as ItemSlice, settings['sync.userId'], state.shareService) : false, joplinServerConnected: [9, 10].includes(settings['sync.target']), joplinCloudAccountType: settings['sync.target'] === 10 ? settings['sync.10.accountType'] : 0, - hasMultiProfiles: state.profileConfig && state.profileConfig.profiles.length > 1, - - noteIsReadOnly: selectedNote ? itemIsReadOnlySync(ModelType.Note, ItemChange.SOURCE_UNSPECIFIED, selectedNote as ItemSlice, settings['sync.userId'], state.shareService) : false, - folderIsReadOnly: commandFolder ? itemIsReadOnlySync(ModelType.Note, ItemChange.SOURCE_UNSPECIFIED, commandFolder as ItemSlice, settings['sync.userId'], state.shareService) : false, }; } diff --git a/packages/lib/services/rest/Api.test.ts b/packages/lib/services/rest/Api.test.ts index c78d84334b..c94d99149a 100644 --- a/packages/lib/services/rest/Api.test.ts +++ b/packages/lib/services/rest/Api.test.ts @@ -35,7 +35,7 @@ const createNoteForPagination = async (numOrTitle: number | string, time: number let api: Api = null; -describe('services_rest_Api', () => { +describe('services/rest/Api', () => { beforeEach(async () => { api = new Api(); @@ -71,7 +71,7 @@ describe('services_rest_Api', () => { it('should delete folders', (async () => { const f1 = await Folder.save({ title: 'mon carnet' }); - await api.route(RequestMethod.DELETE, `folders/${f1.id}`); + await api.route(RequestMethod.DELETE, `folders/${f1.id}`, { permanent: '1' }); const f1b = await Folder.load(f1.id); expect(!f1b).toBe(true); diff --git a/packages/lib/services/rest/Api.ts b/packages/lib/services/rest/Api.ts index da240d5423..2757b8bef8 100644 --- a/packages/lib/services/rest/Api.ts +++ b/packages/lib/services/rest/Api.ts @@ -48,6 +48,9 @@ interface RequestQuery { // Event cursor cursor?: string; + + // For note deletion + permanent?: string; } export interface Request { diff --git a/packages/lib/services/rest/routes/folders.test.ts b/packages/lib/services/rest/routes/folders.test.ts new file mode 100644 index 0000000000..77886ed44b --- /dev/null +++ b/packages/lib/services/rest/routes/folders.test.ts @@ -0,0 +1,43 @@ +import Note from '../../../models/Note'; +import Api, { RequestMethod } from '../Api'; +import { setupDatabase, switchClient } from '../../../testing/test-utils'; +import Folder from '../../../models/Folder'; + +describe('routes/folders', () => { + + beforeEach(async () => { + await setupDatabase(1); + await switchClient(1); + }); + + test('should not include deleted folders in GET call', async () => { + const api = new Api(); + const folder1 = await Folder.save({}); + const folder2 = await Folder.save({}); + await api.route(RequestMethod.DELETE, `folders/${folder1.id}`); + + const tree = await api.route(RequestMethod.GET, 'folders', { as_tree: 1 }); + expect(tree.length).toBe(1); + expect(tree[0].id).toBe(folder2.id); + + const page = await api.route(RequestMethod.GET, 'folders'); + expect(page.items.length).toBe(1); + expect(page.items[0].id).toBe(folder2.id); + }); + + test('should be able to delete to trash', async () => { + const api = new Api(); + const folder1 = await Folder.save({}); + const folder2 = await Folder.save({}); + const note1 = await Note.save({ parent_id: folder1.id }); + const note2 = await Note.save({ parent_id: folder2.id }); + const beforeTime = Date.now(); + await api.route(RequestMethod.DELETE, `folders/${folder1.id}`); + await api.route(RequestMethod.DELETE, `folders/${folder2.id}`, { permanent: '1' }); + + expect((await Folder.load(folder1.id)).deleted_time).toBeGreaterThanOrEqual(beforeTime); + expect(await Folder.load(folder2.id)).toBeFalsy(); + expect((await Note.load(note1.id)).deleted_time).toBeGreaterThanOrEqual(beforeTime); + expect(await Note.load(note2.id)).toBeFalsy(); + }); +}); diff --git a/packages/lib/services/rest/routes/folders.ts b/packages/lib/services/rest/routes/folders.ts index b6efa3ce6a..a17e6d3c38 100644 --- a/packages/lib/services/rest/routes/folders.ts +++ b/packages/lib/services/rest/routes/folders.ts @@ -1,5 +1,5 @@ -import { Request } from '../Api'; +import { Request, RequestMethod } from '../Api'; import defaultAction from '../utils/defaultAction'; import paginatedResults from '../utils/paginatedResults'; import BaseModel from '../../../BaseModel'; @@ -9,17 +9,20 @@ import { allForDisplay } from '../../../folders-screen-utils'; const { ErrorNotFound } = require('../utils/errors'); export default async function(request: Request, id: string = null, link: string = null) { - if (request.method === 'GET' && !id) { + if (request.method === RequestMethod.GET && !id) { if (request.query.as_tree) { - const folders = await allForDisplay({ fields: requestFields(request, BaseModel.TYPE_FOLDER) }); + const folders = await allForDisplay({ + fields: requestFields(request, BaseModel.TYPE_FOLDER), + includeDeleted: false, + }); const output = await Folder.allAsTree(folders); return output; } else { - return defaultAction(BaseModel.TYPE_FOLDER, request, id, link); + return defaultAction(BaseModel.TYPE_FOLDER, request, id, link, null, { sql: 'deleted_time = 0' }); } } - if (request.method === 'GET' && id) { + if (request.method === RequestMethod.GET && id) { if (link && link === 'notes') { const folder = await Folder.load(id); return paginatedResults(BaseModel.TYPE_NOTE, request, { sql: 'parent_id = ?', params: [folder.id] }); @@ -28,5 +31,10 @@ 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' }); + return; + } + return defaultAction(BaseModel.TYPE_FOLDER, request, id, link); } diff --git a/packages/lib/services/rest/routes/notes.test.ts b/packages/lib/services/rest/routes/notes.test.ts index 05471976bd..dfbc2ef86b 100644 --- a/packages/lib/services/rest/routes/notes.test.ts +++ b/packages/lib/services/rest/routes/notes.test.ts @@ -3,6 +3,9 @@ import shim from '../../../shim'; import { downloadMediaFile } from './notes'; import Setting from '../../../models/Setting'; import { readFile, readdir, remove, writeFile } from 'fs-extra'; +import Api, { RequestMethod } from '../Api'; +import Note from '../../../models/Note'; +import { setupDatabase, switchClient } from '../../../testing/test-utils'; const md5 = require('md5'); const imagePath = `${__dirname}/../../../images/SideMenuHeader.png`; @@ -10,8 +13,10 @@ const jpgBase64Content = '/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgICAgMCAgIDAwMDBA describe('routes/notes', () => { - beforeEach(() => { + beforeEach(async () => { jest.resetAllMocks(); + await setupDatabase(1); + await switchClient(1); }); test.each([ @@ -19,7 +24,7 @@ describe('routes/notes', () => { 'htp/asdfasf.com', 'https//joplinapp.org', ])('should not return a local file for invalid protocols', async (invalidUrl) => { - await expect(downloadMediaFile(invalidUrl)).resolves.toBe(''); + expect(await downloadMediaFile(invalidUrl)).toBe(''); }); test.each([ @@ -127,4 +132,17 @@ describe('routes/notes', () => { await remove(response); spy.mockRestore(); }); + + test('should be able to delete to trash', async () => { + const api = new Api(); + const note1 = await Note.save({}); + const note2 = await Note.save({}); + const beforeTime = Date.now(); + await api.route(RequestMethod.DELETE, `notes/${note1.id}`); + await api.route(RequestMethod.DELETE, `notes/${note2.id}`, { permanent: '1' }); + + expect((await Note.load(note1.id)).deleted_time).toBeGreaterThanOrEqual(beforeTime); + expect(await Note.load(note2.id)).toBeFalsy(); + }); + }); diff --git a/packages/lib/services/rest/routes/notes.ts b/packages/lib/services/rest/routes/notes.ts index b96d23a36a..247fcb4786 100644 --- a/packages/lib/services/rest/routes/notes.ts +++ b/packages/lib/services/rest/routes/notes.ts @@ -510,5 +510,10 @@ export default async function(request: Request, id: string = null, link: string return newNote; } + if (request.method === RequestMethod.DELETE) { + await Note.delete(id, { toTrash: request.query.permanent !== '1' }); + return; + } + return defaultAction(BaseModel.TYPE_NOTE, request, id, link); } diff --git a/packages/lib/services/rest/utils/defaultAction.ts b/packages/lib/services/rest/utils/defaultAction.ts index de09bdb574..65bed0c6b5 100644 --- a/packages/lib/services/rest/utils/defaultAction.ts +++ b/packages/lib/services/rest/utils/defaultAction.ts @@ -5,8 +5,9 @@ import paginatedResults from './paginatedResults'; import readonlyProperties from './readonlyProperties'; import requestFields from './requestFields'; import BaseItem from '../../../models/BaseItem'; +import { WhereQuery } from '../../../models/utils/paginatedFeed'; -export default async function(modelType: number, request: Request, id: string = null, link: string = null, defaultFields: string[] = null) { +export default async function(modelType: number, request: Request, id: string = null, link: string = null, defaultFields: string[] = null, whereQuery: WhereQuery = null) { if (link) throw new ErrorNotFound(); // Default action doesn't support links at all for now const ModelClass = BaseItem.getClassByItemType(modelType); @@ -23,7 +24,7 @@ export default async function(modelType: number, request: Request, id: string = fields: requestFields(request, modelType, defaultFields), }); } else { - return paginatedResults(modelType, request, null, defaultFields); + return paginatedResults(modelType, request, whereQuery, defaultFields); } } diff --git a/packages/lib/services/rest/utils/requestFields.ts b/packages/lib/services/rest/utils/requestFields.ts index 3810525ba6..1ed4c3df62 100644 --- a/packages/lib/services/rest/utils/requestFields.ts +++ b/packages/lib/services/rest/utils/requestFields.ts @@ -3,7 +3,7 @@ import BaseItem from '../../../models/BaseItem'; function defaultFieldsByModelType(modelType: number): string[] { const ModelClass = BaseItem.getClassByItemType(modelType); - const possibleFields = ['id', 'parent_id', 'title']; + const possibleFields = ['id', 'parent_id', 'title', 'deleted_time']; const output = []; for (const f of possibleFields) { if (ModelClass.hasField(f)) output.push(f); diff --git a/packages/lib/services/search/SearchEngine.resources.test.ts b/packages/lib/services/search/SearchEngine.resources.test.ts index 09cfd40ac6..a64dcbca2c 100644 --- a/packages/lib/services/search/SearchEngine.resources.test.ts +++ b/packages/lib/services/search/SearchEngine.resources.test.ts @@ -12,6 +12,17 @@ const newSearchEngine = () => { return engine; }; +const createNoteAndResource = async () => { + const note = await Note.save({}); + await Note.save({}); + await shim.attachFileToNote(note, `${ocrSampleDir}/testocr.png`); + const resource = (await Resource.all())[0]; + + await resourceService().indexNoteResources(); + + return { note, resource }; +}; + describe('SearchEngine.resources', () => { beforeEach(async () => { @@ -39,12 +50,7 @@ describe('SearchEngine.resources', () => { }); it('should return notes associated with indexed resources', (async () => { - const note1 = await Note.save({}); - await Note.save({}); - await shim.attachFileToNote(note1, `${ocrSampleDir}/testocr.png`); - const resource = (await Resource.all())[0]; - - await resourceService().indexNoteResources(); + const { note, resource } = await createNoteAndResource(); const ocrService = newOcrService(); await ocrService.processResources(); @@ -54,13 +60,29 @@ describe('SearchEngine.resources', () => { const results = await searchEngine.search('lazy fox'); expect(results.length).toBe(1); - expect(results[0].id).toBe(note1.id); + expect(results[0].id).toBe(note.id); expect(results[0].item_id).toBe(resource.id); expect(results[0].item_type).toBe(ModelType.Resource); await ocrService.dispose(); })); + it('should not return resources associated with deleted notes', (async () => { + const { note } = await createNoteAndResource(); + const note2 = await Note.save({ body: 'lazy fox' }); + await Note.delete(note.id, { toTrash: true }); + + const ocrService = newOcrService(); + await ocrService.processResources(); + + const searchEngine = newSearchEngine(); + await searchEngine.syncTables(); + + const results = await searchEngine.search('lazy fox'); + expect(results.length).toBe(1); + expect(results[0].id).toBe(note2.id); + })); + it('should delete normalized data when a resource is deleted', async () => { const engine = newSearchEngine(); diff --git a/packages/lib/services/search/SearchEngine.ts b/packages/lib/services/search/SearchEngine.ts index 49202af235..fca20562b7 100644 --- a/packages/lib/services/search/SearchEngine.ts +++ b/packages/lib/services/search/SearchEngine.ts @@ -119,7 +119,7 @@ export default class SearchEngine { } private async doInitialNoteIndexing_() { - const notes = await this.db().selectAll('SELECT id FROM notes WHERE is_conflict = 0 AND encryption_applied = 0'); + const notes = await this.db().selectAll('SELECT id FROM notes WHERE is_conflict = 0 AND encryption_applied = 0 AND deleted_time = 0'); const noteIds = notes.map(n => n.id); const lastChangeId = await ItemChange.lastChangeId(); @@ -132,7 +132,7 @@ export default class SearchEngine { const notes = await Note.modelSelectAll(` SELECT ${SearchEngine.relevantFields} FROM notes - WHERE id IN ("${currentIds.join('","')}") AND is_conflict = 0 AND encryption_applied = 0`); + WHERE id IN ("${currentIds.join('","')}") AND is_conflict = 0 AND encryption_applied = 0 AND deleted_time = 0`); const queries = []; for (let i = 0; i < notes.length; i++) { @@ -215,7 +215,7 @@ export default class SearchEngine { const noteIds = changes.map(a => a.item_id); const notes = await Note.modelSelectAll(` SELECT ${SearchEngine.relevantFields} - FROM notes WHERE id IN ("${noteIds.join('","')}") AND is_conflict = 0 AND encryption_applied = 0`, + FROM notes WHERE id IN ("${noteIds.join('","')}") AND is_conflict = 0 AND encryption_applied = 0 AND deleted_time = 0`, ); for (let i = 0; i < changes.length; i++) { @@ -801,16 +801,25 @@ export default class SearchEngine { } } - const resourcesToNotes = await NoteResource.associatedResourceNotes(itemRows.map(r => r.item_id), { fields: ['note_id', 'parent_id'] }); + const resourcesToNotes = await NoteResource.associatedResourceNotes( + itemRows.map(r => r.item_id), + { + fields: ['note_id', 'parent_id', 'deleted_time'], + }, + ); + + const deletedNoteIds: string[] = []; for (const itemRow of itemRows) { const notes = resourcesToNotes[itemRow.item_id]; const note = notes && notes.length ? notes[0] : null; + if (note && note.deleted_time) deletedNoteIds.push(note.note_id); itemRow.id = note ? note.note_id : null; itemRow.parent_id = note ? note.parent_id : null; } if (!options.includeOrphanedResources) itemRows = itemRows.filter(r => !!r.id); + itemRows = itemRows.filter(r => !deletedNoteIds.includes(r.id)); rows = rows.concat(itemRows); } diff --git a/packages/lib/services/search/SearchEngineUtils.test.ts b/packages/lib/services/search/SearchEngineUtils.test.ts index 38f2cb1251..6627cb996d 100644 --- a/packages/lib/services/search/SearchEngineUtils.test.ts +++ b/packages/lib/services/search/SearchEngineUtils.test.ts @@ -7,7 +7,7 @@ const Note = require('../../models/Note').default; let searchEngine: any = null; -describe('services_SearchEngineUtils', () => { +describe('SearchEngineUtils', () => { beforeEach(async () => { await setupDatabaseAndSynchronizer(1); await switchClient(1); diff --git a/packages/lib/services/search/SearchEngineUtils.ts b/packages/lib/services/search/SearchEngineUtils.ts index ba1f2485b8..2aad219de1 100644 --- a/packages/lib/services/search/SearchEngineUtils.ts +++ b/packages/lib/services/search/SearchEngineUtils.ts @@ -32,7 +32,7 @@ export default class SearchEngineUtils { const noteIds = results.map(n => n.id); // We need at least the note ID to be able to sort them below so if not - // present in field list, add it.L Also remember it was auto-added so that + // present in field list, add it. Also remember it was auto-added so that // it can be removed afterwards. let idWasAutoAdded = false; const fields = options.fields ? options.fields : Note.previewFields().slice(); diff --git a/packages/lib/services/synchronizer/Synchronizer.conflicts.test.ts b/packages/lib/services/synchronizer/Synchronizer.conflicts.test.ts index 433b1bdbfa..69e35b62c7 100644 --- a/packages/lib/services/synchronizer/Synchronizer.conflicts.test.ts +++ b/packages/lib/services/synchronizer/Synchronizer.conflicts.test.ts @@ -5,6 +5,7 @@ import Folder from '../../models/Folder'; import Note from '../../models/Note'; import BaseItem from '../../models/BaseItem'; import { setEncryptionEnabled } from '../synchronizer/syncInfoUtils'; +import { NoteEntity } from '../database/types'; describe('Synchronizer.conflicts', () => { @@ -103,7 +104,7 @@ describe('Synchronizer.conflicts', () => { await Note.save({ title: 'note1', parent_id: folder1.id }); await synchronizerStart(); - const items = await allNotesFolders(); + const items: NoteEntity[] = await allNotesFolders(); expect(items.length).toBe(1); expect(items[0].title).toBe('note1'); expect(items[0].is_conflict).toBe(1); diff --git a/packages/lib/services/synchronizer/Synchronizer.e2ee.test.ts b/packages/lib/services/synchronizer/Synchronizer.e2ee.test.ts index c3a500a19d..e50c0db932 100644 --- a/packages/lib/services/synchronizer/Synchronizer.e2ee.test.ts +++ b/packages/lib/services/synchronizer/Synchronizer.e2ee.test.ts @@ -11,6 +11,7 @@ import BaseItem from '../../models/BaseItem'; import Synchronizer from '../../Synchronizer'; import { fetchSyncInfo, getEncryptionEnabled, localSyncInfo, setEncryptionEnabled } from '../synchronizer/syncInfoUtils'; import { loadMasterKeysFromSettings, setupAndDisableEncryption, setupAndEnableEncryption } from '../e2ee/utils'; +import { remoteNotesAndFolders } from '../../testing/test-utils-synchronizer'; let insideBeforeEach = false; @@ -72,6 +73,25 @@ describe('Synchronizer.e2ee', () => { expect(!folder1_2.encryption_cipher_text).toBe(true); })); + it('should not encrypt structural properties', (async () => { + setEncryptionEnabled(true); + await loadEncryptionMasterKey(); + const folder1 = await Folder.save({}); + const folder2 = await Folder.save({}); + const note1 = await Note.save({ parent_id: folder1.id }); + const note2 = await Note.save({ parent_id: folder2.id }); + + await Folder.delete(folder2.id, { toTrash: true, deleteChildren: true }); + + await synchronizerStart(); + + const remoteItems = await remoteNotesAndFolders(); + expect(remoteItems.find(i => i.id === folder1.id).deleted_time).toBe(0); + expect(remoteItems.find(i => i.id === folder2.id).deleted_time).toBeGreaterThan(0); + expect(remoteItems.find(i => i.id === note1.id).deleted_time).toBe(0); + expect(remoteItems.find(i => i.id === note2.id).deleted_time).toBeGreaterThan(0); + })); + it('should mark the key has having been used when synchronising the first time', (async () => { setEncryptionEnabled(true); await loadEncryptionMasterKey(); diff --git a/packages/lib/services/trash/emptyTrash.test.ts b/packages/lib/services/trash/emptyTrash.test.ts new file mode 100644 index 0000000000..09b0e2a3a2 --- /dev/null +++ b/packages/lib/services/trash/emptyTrash.test.ts @@ -0,0 +1,29 @@ +import Folder from '../../models/Folder'; +import Note from '../../models/Note'; +import { setupDatabaseAndSynchronizer, switchClient } from '../../testing/test-utils'; +import emptyTrash from './emptyTrash'; + +describe('emptyTrash', () => { + + beforeEach(async () => { + await setupDatabaseAndSynchronizer(1); + await switchClient(1); + }); + + it('should empty the trash', async () => { + const folder1 = await Folder.save({}); + const folder2 = await Folder.save({ parent_id: folder1.id }); + const folder3 = await Folder.save({}); + await Note.save({ parent_id: folder1.id }); + await Note.save({ parent_id: folder2.id }); + await Note.save({ parent_id: folder3.id }); + + await Folder.delete(folder1.id, { toTrash: true }); + + await emptyTrash(); + + expect(await Folder.count()).toBe(1); + expect(await Note.count()).toBe(1); + }); + +}); diff --git a/packages/lib/services/trash/emptyTrash.ts b/packages/lib/services/trash/emptyTrash.ts new file mode 100644 index 0000000000..eb381d7bdd --- /dev/null +++ b/packages/lib/services/trash/emptyTrash.ts @@ -0,0 +1,12 @@ +import BaseItem from '../../models/BaseItem'; +import Folder from '../../models/Folder'; +import Note from '../../models/Note'; + +export default async () => { + const result = await BaseItem.allItemsInTrash(); + await Note.batchDelete(result.noteIds); + + for (const folderId of result.folderIds) { + await Folder.delete(folderId, { deleteChildren: false }); + } +}; diff --git a/packages/lib/services/trash/index.test.ts b/packages/lib/services/trash/index.test.ts new file mode 100644 index 0000000000..a96facbc18 --- /dev/null +++ b/packages/lib/services/trash/index.test.ts @@ -0,0 +1,41 @@ +import { getDisplayParentId, getTrashFolderId } from '.'; + +describe('services/trash', () => { + + test.each([ + [ + { + deleted_time: 0, + parent_id: '1', + }, + { + deleted_time: 0, + }, + '1', + ], + [ + { + deleted_time: 1000, + parent_id: '1', + }, + { + deleted_time: 0, + }, + getTrashFolderId(), + ], + [ + { + deleted_time: 1000, + parent_id: '1', + }, + { + deleted_time: 1000, + }, + '1', + ], + ])('should return the display parent ID', (item, itemParent, expected) => { + const actual = getDisplayParentId(item, itemParent); + expect(actual).toBe(expected); + }); + +}); diff --git a/packages/lib/services/trash/index.ts b/packages/lib/services/trash/index.ts new file mode 100644 index 0000000000..a79d664bb4 --- /dev/null +++ b/packages/lib/services/trash/index.ts @@ -0,0 +1,91 @@ +import { checkObjectHasProperties } from '@joplin/utils/object'; +import { ModelType } from '../../BaseModel'; +import { _ } from '../../locale'; +import { FolderEntity, FolderIcon, FolderIconType, NoteEntity } from '../database/types'; +import Folder from '../../models/Folder'; + +// When an item is deleted, all its properties are kept, including the parent ID +// so that it can potentially be restored to the right folder. However, when +// displaying that item, we should make sure it has the right parent, which may +// be different from the parent ID. For example, if we delete a note, the new +// parent is the trash folder. If we delete a folder, the folder parent is the +// trash folder, while the note parents are still the folder (since it is in the +// trash too). +// +// This function simplifies this logic wherever it is needed. +// +// `originalItemParent` is the parent before the item was deleted, which is the +// folder with ID = item.parent_id +export const getDisplayParentId = (item: FolderEntity | NoteEntity, originalItemParent: FolderEntity) => { + if (!('deleted_time' in item) || !('parent_id' in item)) throw new Error(`Missing "deleted_time" or "parent_id" property: ${JSON.stringify(item)}`); + if (originalItemParent && !('deleted_time' in originalItemParent)) throw new Error(`Missing "deleted_time" property: ${JSON.stringify(originalItemParent)}`); + + if (!item.deleted_time) return item.parent_id; + + if (!originalItemParent || !originalItemParent.deleted_time) return getTrashFolderId(); + + return item.parent_id; +}; + +export const getDisplayParentTitle = (item: FolderEntity | NoteEntity, originalItemParent: FolderEntity) => { + const displayParentId = getDisplayParentId(item, originalItemParent); + if (displayParentId === getTrashFolderId()) return getTrashFolderTitle(); + return originalItemParent && originalItemParent.id === displayParentId ? originalItemParent.title : ''; +}; + +export const getTrashFolderId = () => { + return 'de1e7ede1e7ede1e7ede1e7ede1e7ede'; +}; + +export const getTrashFolderTitle = () => { + return _('Trash'); +}; + +export const getTrashFolder = (): FolderEntity => { + const now = Date.now(); + + return { + type_: ModelType.Folder, + id: getTrashFolderId(), + parent_id: '', + title: getTrashFolderTitle(), + updated_time: now, + user_updated_time: now, + share_id: '', + is_shared: 0, + deleted_time: 0, + }; +}; + +export const getTrashFolderIcon = (type: FolderIconType): FolderIcon => { + if (type === FolderIconType.FontAwesome) { + return { + dataUrl: '', + emoji: '', + name: 'fas fa-trash', + type: FolderIconType.FontAwesome, + }; + } else { + return { + dataUrl: '', + emoji: '🗑️', + name: '', + type: FolderIconType.Emoji, + }; + } +}; + +export const itemIsInTrash = (item: FolderEntity | NoteEntity) => { + if (!item) return false; + + checkObjectHasProperties(item, ['id', 'deleted_time']); + + return item.id === getTrashFolderId() || !!item.deleted_time; +}; + +export const getRestoreFolder = async () => { + const title = _('Restored items'); + const output = await Folder.loadByTitleAndParent(title, ''); + if (output) return output; + return Folder.save({ title }); +}; diff --git a/packages/lib/services/trash/permanentlyDeleteOldItems.test.ts b/packages/lib/services/trash/permanentlyDeleteOldItems.test.ts new file mode 100644 index 0000000000..a0643c22b6 --- /dev/null +++ b/packages/lib/services/trash/permanentlyDeleteOldItems.test.ts @@ -0,0 +1,78 @@ +import { Day, msleep } from '@joplin/utils/time'; +import Folder from '../../models/Folder'; +import Note from '../../models/Note'; +import { setupDatabaseAndSynchronizer, switchClient } from '../../testing/test-utils'; +import permanentlyDeleteOldItems from './permanentlyDeleteOldItems'; +import Setting from '../../models/Setting'; + +describe('permanentlyDeleteOldItems', () => { + + beforeEach(async () => { + await setupDatabaseAndSynchronizer(1); + await switchClient(1); + }); + + it('should auto-delete old items', async () => { + const folder1 = await Folder.save({}); + const folder2 = await Folder.save({}); + const note1 = await Note.save({ parent_id: folder1.id }); + const note2 = await Note.save({}); + + await Folder.delete(folder1.id, { toTrash: true, deleteChildren: true }); + + // First check that it doesn't auto-delete if it's not within the right interval + await permanentlyDeleteOldItems(Day); + + expect((await Folder.load(folder1.id))).toBeTruthy(); + expect((await Folder.load(folder2.id))).toBeTruthy(); + expect((await Note.load(note1.id))).toBeTruthy(); + expect((await Note.load(note2.id))).toBeTruthy(); + + await msleep(1); + await permanentlyDeleteOldItems(0); + + expect((await Folder.load(folder1.id))).toBeFalsy(); + expect((await Folder.load(folder2.id))).toBeTruthy(); + expect((await Note.load(note1.id))).toBeFalsy(); + expect((await Note.load(note2.id))).toBeTruthy(); + }); + + it('should not auto-delete non-empty folders', async () => { + const folder1 = await Folder.save({}); + const folder2 = await Folder.save({}); + const note1 = await Note.save({ parent_id: folder1.id }); + const note2 = await Note.save({}); + + await Folder.delete(folder1.id, { toTrash: true, deleteChildren: true }); + + // Simulates a folder having been deleted a long time ago - so it should be deleted. But + // since it contains a note it should not. + await Folder.save({ id: folder1.id, deleted_time: 1000 }); + + await permanentlyDeleteOldItems(Day); + + expect((await Folder.load(folder1.id))).toBeTruthy(); + expect((await Note.load(note1.id))).toBeTruthy(); + + // Now both folders and items are within the deletion interval, so they should be both be + // auto-deleted + await Note.save({ id: note1.id, deleted_time: 1000 }); + + await permanentlyDeleteOldItems(1); + + expect((await Folder.load(folder1.id))).toBeFalsy(); + expect((await Folder.load(folder2.id))).toBeTruthy(); + expect((await Note.load(note1.id))).toBeFalsy(); + expect((await Note.load(note2.id))).toBeTruthy(); + }); + + it('should not do anything if auto-deletion is not enabled', async () => { + Setting.setValue('trash.autoDeletionEnabled', false); + const folder1 = await Folder.save({}); + await Folder.delete(folder1.id, { toTrash: true }); + await msleep(1); + await permanentlyDeleteOldItems(0); + expect(await Folder.count()).toBe(1); + }); + +}); diff --git a/packages/lib/services/trash/permanentlyDeleteOldItems.ts b/packages/lib/services/trash/permanentlyDeleteOldItems.ts new file mode 100644 index 0000000000..89ca21f9b5 --- /dev/null +++ b/packages/lib/services/trash/permanentlyDeleteOldItems.ts @@ -0,0 +1,46 @@ +import Logger from '@joplin/utils/Logger'; +import Folder from '../../models/Folder'; +import Setting from '../../models/Setting'; +import Note from '../../models/Note'; +import { Day, Hour } from '@joplin/utils/time'; +import shim from '../../shim'; + +const logger = Logger.create('permanentlyDeleteOldData'); + +const permanentlyDeleteOldItems = async (ttl: number = null) => { + ttl = ttl === null ? Setting.value('trash.ttlDays') * Day : ttl; + + logger.info(`Processing items older than ${ttl}ms...`); + + if (!Setting.value('trash.autoDeletionEnabled')) { + logger.info('Auto-deletion is not enabled - skipping'); + return; + } + + const result = await Folder.trashItemsOlderThan(ttl); + logger.info('Items to permanently delete:', result); + + await Note.batchDelete(result.noteIds); + + // We only auto-delete folders if they are empty. + for (const folderId of result.folderIds) { + const noteIds = await Folder.noteIds(folderId, { includeDeleted: true }); + if (!noteIds.length) { + logger.info(`Deleting empty folder: ${folderId}`); + await Folder.delete(folderId); + } else { + logger.info(`Skipping non-empty folder: ${folderId}`); + } + } +}; + + +export const setupAutoDeletion = async () => { + await permanentlyDeleteOldItems(); + + shim.setInterval(async () => { + await permanentlyDeleteOldItems(); + }, 18 * Hour); +}; + +export default permanentlyDeleteOldItems; diff --git a/packages/lib/services/trash/restoreItems.test.ts b/packages/lib/services/trash/restoreItems.test.ts new file mode 100644 index 0000000000..95518e9ba7 --- /dev/null +++ b/packages/lib/services/trash/restoreItems.test.ts @@ -0,0 +1,91 @@ +import { ModelType } from '../../BaseModel'; +import Folder from '../../models/Folder'; +import Note from '../../models/Note'; +import { setupDatabaseAndSynchronizer, switchClient } from '../../testing/test-utils'; +import restoreItems from './restoreItems'; + +describe('restoreItems', () => { + + beforeEach(async () => { + await setupDatabaseAndSynchronizer(1); + await switchClient(1); + }); + + it('should restore notes', async () => { + const folder = await Folder.save({}); + const note1 = await Note.save({ parent_id: folder.id }); + const note2 = await Note.save({ parent_id: folder.id }); + await Note.delete(note1.id, { toTrash: true }); + await Note.delete(note2.id, { toTrash: true }); + + expect((await Folder.noteIds(folder.id)).length).toBe(0); + + await restoreItems(ModelType.Note, [await Note.load(note1.id), await Note.load(note2.id)]); + + expect((await Folder.noteIds(folder.id)).length).toBe(2); + }); + + it('should restore folders and included notes', async () => { + const folder1 = await Folder.save({}); + const note1 = await Note.save({ parent_id: folder1.id }); + const note2 = await Note.save({ parent_id: folder1.id }); + + await Folder.delete(folder1.id, { toTrash: true }); + + await restoreItems(ModelType.Folder, [await Folder.load(folder1.id)]); + + expect((await Folder.load(folder1.id)).deleted_time).toBe(0); + expect((await Note.load(note1.id)).deleted_time).toBe(0); + expect((await Note.load(note2.id)).deleted_time).toBe(0); + }); + + it('should restore folders and sub-folders', async () => { + const folder1 = await Folder.save({}); + const folder2 = await Folder.save({ parent_id: folder1.id }); + const note1 = await Note.save({ parent_id: folder2.id }); + const note2 = await Note.save({ parent_id: folder2.id }); + + const beforeTime = Date.now(); + await Folder.delete(folder1.id, { toTrash: true, deleteChildren: true }); + + expect((await Folder.load(folder1.id)).deleted_time).toBeGreaterThanOrEqual(beforeTime); + expect((await Folder.load(folder2.id)).deleted_time).toBeGreaterThanOrEqual(beforeTime); + expect((await Note.load(note1.id)).deleted_time).toBeGreaterThanOrEqual(beforeTime); + expect((await Note.load(note2.id)).deleted_time).toBeGreaterThanOrEqual(beforeTime); + + await restoreItems(ModelType.Folder, [await Folder.load(folder1.id)]); + + expect((await Folder.load(folder1.id)).deleted_time).toBe(0); + expect((await Folder.load(folder2.id)).deleted_time).toBe(0); + expect((await Note.load(note1.id)).deleted_time).toBe(0); + expect((await Note.load(note2.id)).deleted_time).toBe(0); + }); + + it('should restore a note, even if the parent folder no longer exists', async () => { + const folder = await Folder.save({}); + const note = await Note.save({ parent_id: folder.id }); + + await Folder.delete(folder.id, { toTrash: true }); + + await restoreItems(ModelType.Note, [await Note.load(note.id)]); + + const noteReloaded = await Note.load(note.id); + expect(noteReloaded.parent_id).toBe(''); + }); + + it('should restore a folder, even if the parent folder no longer exists', async () => { + const folder1 = await Folder.save({}); + const folder2 = await Folder.save({}); + const note = await Note.save({ parent_id: folder2.id }); + + await Folder.delete(folder1.id, { toTrash: true }); + + await restoreItems(ModelType.Note, [await Folder.load(folder2.id)]); + + const folderReloaded2 = await Folder.load(folder2.id); + const noteReloaded = await Note.load(note.id); + expect(folderReloaded2.parent_id).toBe(''); + expect(noteReloaded.parent_id).toBe(folderReloaded2.id); + }); + +}); diff --git a/packages/lib/services/trash/restoreItems.ts b/packages/lib/services/trash/restoreItems.ts new file mode 100644 index 0000000000..5a021aed6c --- /dev/null +++ b/packages/lib/services/trash/restoreItems.ts @@ -0,0 +1,89 @@ +import { getRestoreFolder } from '.'; +import { ModelType } from '../../BaseModel'; +import Folder from '../../models/Folder'; +import Note from '../../models/Note'; +import { FolderEntity, NoteEntity } from '../database/types'; +import { checkObjectHasProperties } from '@joplin/utils/object'; + +export interface RestoreItemsOptions { + targetFolderId?: string; + + // Restore item to a special "Restore" folder if the item doesn't have a non-deleted parent to + // be restored to. + useRestoreFolder?: boolean; +} + +const restoreItems = async (itemType: ModelType, itemsOrIds: NoteEntity[] | FolderEntity[] | string[], options: RestoreItemsOptions = null) => { + if (!itemsOrIds.length) return; + + options = { + targetFolderId: null, + useRestoreFolder: false, + ...options, + }; + + const ModelClass = itemType === ModelType.Note ? Note : Folder; + + let items: NoteEntity[] | FolderEntity[] = []; + + if (typeof itemsOrIds[0] === 'string') { + items = await ModelClass.byIds(itemsOrIds as string[], { fields: ['id', 'parent_id', 'deleted_time'] }); + } else { + items = itemsOrIds as (NoteEntity[] | FolderEntity[]); + } + + let restoreFolder: FolderEntity = null; + + for (const item of items) { + checkObjectHasProperties(item, ['id', 'parent_id']); + + let itemParentId = item.parent_id; + + const parentItem = await Folder.load(item.parent_id, { fields: ['id', 'deleted_time'] }); + if (!parentItem || parentItem.deleted_time) { + if (options.useRestoreFolder) { + if (!restoreFolder) restoreFolder = await getRestoreFolder(); + itemParentId = restoreFolder.id; + } else { + itemParentId = ''; + } + } + + if (options.targetFolderId !== null) itemParentId = options.targetFolderId; + + let toSave: FolderEntity | NoteEntity = null; + + if (itemType === ModelType.Note) { + toSave = await Note.preview(item.id); + } else { + toSave = await Folder.load(item.id); + } + + toSave = { + ...toSave, + deleted_time: 0, + updated_time: Date.now(), + parent_id: itemParentId, + }; + + await ModelClass.save(toSave, { + autoTimestamp: false, + }); + + if (itemType === ModelType.Folder) { + const childrenFolderIds = await Folder.childrenIds(item.id, { includeDeleted: true }); + const childrenFolders: FolderEntity[] = await Folder.byIds(childrenFolderIds, { fields: ['id', 'parent_id', 'deleted_time'] }); + const deletedChildrenFolders = childrenFolders.filter(f => !!f.deleted_time); + await restoreItems(ModelType.Folder, deletedChildrenFolders); + + const notes = await Folder.notes(item.id, { + fields: ['id', 'parent_id'], + includeDeleted: true, + }); + + await restoreItems(ModelType.Note, notes); + } + } +}; + +export default restoreItems; diff --git a/packages/lib/testing/test-utils-synchronizer.ts b/packages/lib/testing/test-utils-synchronizer.ts index d150509a2b..0839c1c0e3 100644 --- a/packages/lib/testing/test-utils-synchronizer.ts +++ b/packages/lib/testing/test-utils-synchronizer.ts @@ -3,6 +3,7 @@ import { fileApi } from '../testing/../testing/test-utils'; import Folder from '../models/Folder'; import Note from '../models/Note'; import BaseItem from '../models/BaseItem'; +import { FolderEntity, NoteEntity } from '../services/database/types'; export async function allNotesFolders() { const folders = await Folder.all(); @@ -25,7 +26,7 @@ async function remoteItemsByTypes(types: number[]) { return output; } -export async function remoteNotesAndFolders() { +export async function remoteNotesAndFolders(): Promise<(NoteEntity | FolderEntity)[]> { return remoteItemsByTypes([BaseModel.TYPE_NOTE, BaseModel.TYPE_FOLDER]); } diff --git a/packages/lib/testing/test-utils.ts b/packages/lib/testing/test-utils.ts index 30d26c70b5..d95e6e4819 100644 --- a/packages/lib/testing/test-utils.ts +++ b/packages/lib/testing/test-utils.ts @@ -850,7 +850,7 @@ async function createNTestNotes(n: number, folder: any, tagIds: string[] = null, const notes = []; for (let i = 0; i < n; i++) { const title_ = n > 1 ? `${title}${i}` : title; - const note = await Note.save({ title: title_, parent_id: folder.id, is_conflict: 0 }); + const note = await Note.save({ title: title_, parent_id: folder.id, is_conflict: 0, deleted_time: 0 }); notes.push(note); await time.msleep(10); } diff --git a/packages/lib/time.ts b/packages/lib/time.ts index fa133e816c..64398626c6 100644 --- a/packages/lib/time.ts +++ b/packages/lib/time.ts @@ -99,7 +99,7 @@ class Time { public formatMsToLocal(ms: number, format: string = null) { if (format === null) format = this.dateTimeFormat(); - return moment(ms).format(format); + return moment(ms).format(format) as string; } public formatLocalToMs(localDateTime: any, format: string = null) { diff --git a/packages/lib/tsconfig.json b/packages/lib/tsconfig.json index 9ae51bf4a1..5ea6ebc46b 100644 --- a/packages/lib/tsconfig.json +++ b/packages/lib/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.json", "include": [ "**/*.ts", - "**/*.tsx", + "**/*.tsx", "../app-desktop/commands/emptyTrash.ts", ], "exclude": [ "**/node_modules", diff --git a/packages/tools/cspell/dictionary4.txt b/packages/tools/cspell/dictionary4.txt index 6144550b71..fe273191c9 100644 --- a/packages/tools/cspell/dictionary4.txt +++ b/packages/tools/cspell/dictionary4.txt @@ -87,4 +87,6 @@ mockup firstname lastname signup -activatable \ No newline at end of file +activatable +notyf +Notyf \ No newline at end of file diff --git a/packages/utils/object.ts b/packages/utils/object.ts new file mode 100644 index 0000000000..93cfd67e4a --- /dev/null +++ b/packages/utils/object.ts @@ -0,0 +1,7 @@ +/* eslint-disable import/prefer-default-export */ + +export function checkObjectHasProperties(object: any, properties: string[]) { + for (const prop of properties) { + if (!(prop in object)) throw new Error(`Missing property "${prop}": ${JSON.stringify(object)}`); + } +} diff --git a/packages/utils/package.json b/packages/utils/package.json index acd80b296a..dae236eede 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -6,6 +6,7 @@ "exports": { ".": "./dist/index.js", "./env": "./dist/env.js", + "./object": "./dist/object.js", "./fs": "./dist/fs.js", "./html": "./dist/html.js", "./Logger": "./dist/Logger.js", diff --git a/readme/api/references/plugin_theming.md b/readme/api/references/plugin_theming.md index 2f1a220960..5410e5189e 100644 --- a/readme/api/references/plugin_theming.md +++ b/readme/api/references/plugin_theming.md @@ -35,7 +35,7 @@ To display an icon, use CSS and HTML like the following. color: var(--joplin-color); } .secondary { - color: var(--joplin-color-2); + color: var(--joplin-color2); } ``` diff --git a/readme/api/references/rest_api.md b/readme/api/references/rest_api.md index a43fcb91a6..4bb699a08d 100644 --- a/readme/api/references/rest_api.md +++ b/readme/api/references/rest_api.md @@ -266,6 +266,8 @@ Sets the properties of the note with ID :id Deletes the note with ID :id +By default, the note will be moved **to the trash**. To permanently delete it, add the query parameter `permanent=1` + ## Folders This is actually a notebook. Internally notebooks are called "folders". @@ -316,6 +318,8 @@ Sets the properties of the folder with ID :id Deletes the folder with ID :id +By default, the folder will be moved **to the trash**. To permanently delete it, add the query parameter `permanent=1` + ## Resources ### Properties diff --git a/readme/apps/trash.md b/readme/apps/trash.md new file mode 100644 index 0000000000..5797b7fd60 --- /dev/null +++ b/readme/apps/trash.md @@ -0,0 +1,7 @@ +# About the trash feature + +When a note is deleted it is moved to the trash. + +When a note is restored, it is moved back to its original notebook. If it no longer exists, the note is moved instead at the root of the note collection. It means that to see it you will need to go to "All notes" at the top of the sidebar. + +When a notebook is restored, its content including sub-notebooks and notes is restored too. diff --git a/yarn.lock b/yarn.lock index c1af8ecc5b..1a56cc08ff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6574,6 +6574,7 @@ __metadata: node-fetch: 2.6.7 node-notifier: 10.0.1 node-rsa: 1.1.1 + notyf: 3.10.0 pdfjs-dist: 3.11.174 pretty-bytes: 5.6.0 re-resizable: 6.9.11 @@ -31605,6 +31606,13 @@ __metadata: languageName: node linkType: hard +"notyf@npm:3.10.0": + version: 3.10.0 + resolution: "notyf@npm:3.10.0" + checksum: 6cc533fccb0d74e544edf10e82d2942975adc4c993a68c966694bbb451dc06056d02e8dced4ecfce2c4586682223759cb1f9f3e3f609c83458e99c2bf5494b00 + languageName: node + linkType: hard + "now-and-later@npm:^2.0.0": version: 2.0.1 resolution: "now-and-later@npm:2.0.1"