1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-24 10:27:10 +02:00

All: Resolves #483: Add trash folder (#9671)

This commit is contained in:
Laurent Cozic 2024-03-02 14:25:27 +00:00 committed by GitHub
parent 07fbd547dc
commit f19b1c5364
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
112 changed files with 2322 additions and 966 deletions

View File

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

20
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 <pattern>';
}
public override description() {
return _('Restore the items matching <pattern>.');
}
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;

View File

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

View File

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

View File

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

View File

@ -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<MessageBoxSyncOptions, 'message'> {
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,

View File

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

View File

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

View File

@ -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<Props, State> {
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<Props, State> {
<PromptDialog autocomplete={promptOptions && 'autocomplete' in promptOptions ? promptOptions.autocomplete : null} defaultValue={promptOptions && promptOptions.value ? promptOptions.value : ''} themeId={this.props.themeId} style={styles.prompt} onClose={this.promptOnClose_} label={promptOptions ? promptOptions.label : ''} description={promptOptions ? promptOptions.description : null} visible={!!this.state.promptOptions} buttons={promptOptions && 'buttons' in promptOptions ? promptOptions.buttons : null} inputType={promptOptions && 'inputType' in promptOptions ? promptOptions.inputType : null} />
<TrashNotification
lastDeletion={this.props.lastDeletion}
lastDeletionNotificationTime={this.props.lastDeletionNotificationTime}
themeId={this.props.themeId}
dispatch={this.props.dispatch as any}
/>
{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,
};
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -13,6 +13,6 @@ export const runtime = (): CommandRuntime => {
parentId = parentId || context.state.selectedFolderId;
return CommandService.instance().execute('newFolder', parentId);
},
enabledCondition: '!folderIsReadOnly',
enabledCondition: '!folderIsReadOnly && !folderIsTrash',
};
};

View File

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

View File

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

View File

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

View File

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

View File

@ -66,6 +66,6 @@ export const runtime = (comp: any): CommandRuntime => {
},
});
},
enabledCondition: 'someNotesSelected',
enabledCondition: 'someNotesSelected && !inTrash',
};
};

View File

@ -18,6 +18,6 @@ export const runtime = (comp: any): CommandRuntime => {
},
});
},
enabledCondition: 'joplinServerConnected && someNotesSelected',
enabledCondition: 'joplinServerConnected && someNotesSelected && !noteIsDeleted',
};
};

View File

@ -837,6 +837,8 @@ function useMenu(props: Props) {
separator(),
menuItemDic.showNoteProperties,
menuItemDic.showNoteContentProperties,
separator(),
menuItemDic.permanentlyDeleteNote,
],
},
tools: {

View File

@ -26,9 +26,11 @@ export async function htmlToMarkdown(markupLanguage: number, html: string, origi
export async function formNoteToNote(formNote: FormNote): Promise<any> {
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,
};

View File

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

View File

@ -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<number>(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,

View File

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

View File

@ -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')), {

View File

@ -29,6 +29,7 @@ export interface Props {
focusedField: string;
parentFolderIsReadOnly: boolean;
listRenderer: ListRenderer;
selectedFolderInTrash: boolean;
}
export enum BaseBreakpoint {

View File

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

View File

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

View File

@ -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 === ' ') {

View File

@ -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'],

View File

@ -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<any>) => {
const useNoteListControlsBreakpoints = (width: number, newNoteRef: React.MutableRefObject<any>, selectedFolderId: string) => {
const [dynamicBreakpoints, setDynamicBreakpoints] = useState<Breakpoints>({ 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;

View File

@ -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<Props, State> {
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<Props, State> {
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<Props, State> {
}
}
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<Props, State> {
}
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<Props, State> {
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<Props, State> {
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<Props, State> {
});
}
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 = <label style={{ ...theme.textStyle, ...theme.controlBoxLabel }}>{this.formatLabel(key)}</label>;
@ -351,7 +367,7 @@ class NotePropertiesDialog extends React.Component<Props, State> {
}
}
if (editCompHandler) {
if (editCompHandler && !this.isReadOnly()) {
editComp = (
<a href="#" onClick={editCompHandler} style={styles.editPropertyButton}>
<i className={`fas ${editCompIcon}`} aria-hidden="true"></i>
@ -394,9 +410,9 @@ class NotePropertiesDialog extends React.Component<Props, State> {
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<Props, State> {
<div style={theme.dialogBox}>
<div style={theme.dialogTitle}>{_('Note properties')}</div>
<div>{noteComps}</div>
<DialogButtonRow themeId={this.props.themeId} okButtonRef={this.okButton} onClick={this.buttonRow_click}/>
<DialogButtonRow themeId={this.props.themeId} okButtonShow={!this.isReadOnly()} okButtonRef={this.okButton} onClick={this.buttonRow_click}/>
</div>
</div>
);

View File

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

View File

@ -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 ? <StyledNoteCount className="note-count-label">{noteCount}</StyledNoteCount> : null;
const shareIcon = shareId && !parentId ? <StyledShareIcon className="fas fa-share-alt"></StyledShareIcon> : 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 (
<StyledListItem depth={depth} selected={selected} className={`list-item-container list-item-depth-${depth} ${selected ? 'selected' : ''}`} onDragStart={onFolderDragStart_} onDragOver={onFolderDragOver_} onDrop={onFolderDrop_} draggable={true} data-folder-id={folderId}>
<StyledListItem depth={depth} selected={selected} className={`list-item-container list-item-depth-${depth} ${selected ? 'selected' : ''}`} onDragStart={onFolderDragStart_} onDragOver={onFolderDragOver_} onDrop={onFolderDrop_} draggable={draggable} data-folder-id={folderId}>
<ExpandLink themeId={props.themeId} hasChildren={hasChildren} folderId={folderId} onClick={onFolderToggleClick_} isExpanded={isExpanded}/>
<StyledListItemAnchor
ref={anchorRef}
@ -119,7 +129,7 @@ function FolderItem(props: any) {
}}
onDoubleClick={onFolderToggleClick_}
>
{showFolderIcon ? renderFolderIcon(folderIcon) : null}<StyledSpanFix className="title" style={{ lineHeight: 0 }}>{folderTitle}</StyledSpanFix>
{doRenderFolderIcon()}<StyledSpanFix className="title" style={{ lineHeight: 0 }}>{folderTitle}</StyledSpanFix>
{shareIcon} {noteCountComp}
</StyledListItemAnchor>
</StyledListItem>
@ -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;
}
}
}

View File

@ -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} <a href="#" class="cancel" data-lastDeletion="${htmlentities(JSON.stringify(props.lastDeletion))}" id="${linkId}">${cancelLabel}</a>`);
const element: HTMLAnchorElement = await waitForElement(document, linkId);
if (event.cancelled) return;
element.addEventListener('click', onCancelClick);
}, [props.lastDeletion, notyf, props.dispatch]);
return <div style={{ display: 'none' }}/>;
};

View File

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

View File

@ -20,6 +20,7 @@ export default function() {
'setTags',
'showLocalSearch',
'showNoteContentProperties',
'permanentlyDeleteNote',
'synchronize',
'textBold',
'textCode',

View File

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

View File

@ -15,6 +15,10 @@
<link rel="stylesheet" href="vendor/lib/smalltalk/css/smalltalk.css">
<link rel="stylesheet" href="vendor/lib/roboto-fontface/css/roboto/roboto-fontface.css">
<link rel="stylesheet" href="vendor/lib/codemirror/lib/codemirror.css">
<link rel="stylesheet" href="./node_modules/notyf/notyf.min.css">
<script src="./node_modules/tesseract.js/dist/tesseract.min.js"></script>
<style>
@ -50,5 +54,7 @@
-webkit-user-drag: none;
}
</style>
<script src="./node_modules/notyf/notyf.min.js"></script>
</body>
</html>

View File

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

View File

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

View File

@ -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<ScreenHeaderProps, ScreenHeade
// Dialog needs to be displayed as a child of the parent component, otherwise
// it won't be visible within the header component.
const noteIds = this.props.selectedNoteIds;
const msg = await Note.deleteMessage(noteIds);
if (!msg) return;
const ok = await dialogs.confirm(this.props.parentComponent, msg);
if (!ok) return;
this.props.dispatch({ type: 'NOTE_SELECTION_END' });
try {
await Note.batchDelete(noteIds);
await Note.batchDelete(noteIds, { toTrash: true });
} catch (error) {
alert(_n('This note could not be deleted: %s', 'These notes could not be deleted: %s', noteIds.length, error.message));
}
}
private async restoreButton_press() {
// Dialog needs to be displayed as a child of the parent component, otherwise
// it won't be visible within the header component.
const noteIds = this.props.selectedNoteIds;
this.props.dispatch({ type: 'NOTE_SELECTION_END' });
try {
await restoreItems(ModelType.Note, noteIds);
} catch (error) {
alert(`Could not restore note(s): ${error.message}`);
}
}
private menu_select(value: OnSelectCallbackType) {
if (typeof value === 'function') {
value();
@ -450,6 +461,24 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
);
}
function restoreButton(styles: any, onPress: OnPressCallback, disabled: boolean) {
return (
<CustomButton
onPress={onPress}
disabled={disabled}
themeId={themeId}
description={_('Restore')}
accessibilityHint={
disabled ? null : _('Restore')
}
contentStyle={disabled ? styles.iconButtonDisabled : styles.iconButton}
>
<Icon name="reload-circle" style={styles.topIcon} />
</CustomButton>
);
}
function duplicateButton(styles: any, onPress: OnPressCallback, disabled: boolean) {
return (
<CustomButton
@ -485,6 +514,9 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
let key = 0;
const menuOptionComponents = [];
const selectedFolder = this.props.notesParentType === 'Folder' ? Folder.byId(this.props.folders, this.props.selectedFolderId) : null;
const selectedFolderInTrash = itemIsInTrash(selectedFolder);
if (!this.props.noteSelectionEnabled) {
for (let i = 0; i < this.props.menuOptions.length; i++) {
const o = this.props.menuOptions[i];
@ -556,7 +588,7 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
}
}}
mustSelect={!!folderPickerOptions.mustSelect}
folders={this.props.folders}
folders={this.props.folders.filter(f => f.id !== getTrashFolderId())}
/>
);
} else {
@ -591,8 +623,9 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
const backButtonComp = !showBackButton ? null : backButton(this.styles(), () => 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<ScreenHeaderProps, ScreenHeade
{selectAllButtonComp}
{searchButtonComp}
{deleteButtonComp}
{restoreButtonComp}
{duplicateButtonComp}
{sortButtonComp}
{menuComp}
@ -667,6 +701,8 @@ const ScreenHeader = connect((state: State) => {
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,

View File

@ -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<Props, State> 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<Props, State> 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<Props, State> implements B
});
}
if (isSaved) {
if (isSaved && !isDeleted) {
output.push({
title: _('Tags'),
onPress: () => {
@ -1289,6 +1290,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
},
});
}
output.push({
title: isTodo ? _('Convert to note') : _('Convert to todo'),
onPress: () => {
@ -1296,7 +1298,8 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
},
disabled: readOnly,
});
if (isSaved) {
if (isSaved && !isDeleted) {
output.push({
title: _('Copy Markdown link'),
onPress: () => {
@ -1304,12 +1307,27 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> 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<Props, State> 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<Props, State> 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<Props, State> implements B
}
}
const NoteScreen = connect((state: any) => {
const NoteScreen = connect((state: AppState) => {
return {
noteId: state.selectedNoteIds.length ? state.selectedNoteIds[0] : null,
noteHash: state.selectedNoteHash,

View File

@ -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<any> {
@ -237,6 +238,8 @@ class NotesScreenComponent extends BaseScreenComponent<any> {
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;

View File

@ -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 {
<FolderPicker
themeId={this.props.themeId}
placeholder={_('Select parent notebook')}
folders={this.props.folders}
folders={this.props.folders.filter(f => f.id !== getTrashFolderId())}
selectedFolderId={this.state.folder.parent_id}
onValueChange={newValue => this.parent_changeValue(newValue)}
mustSelect

View File

@ -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 <Icon name="folder-outline" style={styles_.emptyFolderIcon} />;
} else if (folderId === getTrashFolderId()) {
folderIcon = getTrashFolderIcon(FolderIconType.Emoji);
} else {
return null;
}
@ -378,7 +422,7 @@ const SideMenuContentComponent = (props: Props) => {
}}
>
<View style={folderButtonStyle}>
{renderFolderIcon(theme, folderIcon)}
{renderFolderIcon(folder.id, theme, folderIcon)}
<Text numberOfLines={1} style={styles_.folderButtonText}>
{Folder.displayTitle(folder)}
</Text>

View File

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

View File

@ -1,10 +1,10 @@
export const unique = function(array: any[]) {
export const unique = function<T extends any>(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<T extends any>(array: T[], element: T): T[] {
const index = array.indexOf(element);
if (index < 0) return array;
const newArray = array.slice();

View File

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

View File

@ -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 = '*';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string, any> = {
'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<string, FolderEntityWithNoteCount> = {};
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<FolderEntity> {
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<FolderEntity> {
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<string[]> => {
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) : '';
}

View File

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

View File

@ -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<string|null> {
public static async permanentlyDeleteMessage(noteIds: string[]): Promise<string|null> {
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;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<T extends NoteEntity | FolderEntity>(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);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -48,6 +48,9 @@ interface RequestQuery {
// Event cursor
cursor?: string;
// For note deletion
permanent?: string;
}
export interface Request {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -119,7 +119,7 @@ export default class SearchEngine {
}
private async doInitialNoteIndexing_() {
const notes = await this.db().selectAll<NoteEntity>('SELECT id FROM notes WHERE is_conflict = 0 AND encryption_applied = 0');
const notes = await this.db().selectAll<NoteEntity>('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);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More