mirror of
https://github.com/laurent22/joplin.git
synced 2024-11-24 08:12:24 +02:00
parent
07fbd547dc
commit
f19b1c5364
@ -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
20
.gitignore
vendored
@ -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
|
||||
|
@ -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');
|
||||
|
@ -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
|
@ -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 },
|
||||
|
@ -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('');
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
|
@ -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];
|
||||
|
@ -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(' ');
|
||||
}
|
||||
|
26
packages/app-cli/app/command-restore.ts
Normal file
26
packages/app-cli/app/command-restore.ts
Normal 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;
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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;
|
@ -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,
|
||||
|
24
packages/app-desktop/commands/emptyTrash.ts
Normal file
24
packages/app-desktop/commands/emptyTrash.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
};
|
@ -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,
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
@ -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',
|
||||
};
|
||||
|
@ -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',
|
||||
};
|
||||
};
|
||||
|
@ -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 = {
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
@ -13,6 +13,6 @@ export const runtime = (): CommandRuntime => {
|
||||
parentId = parentId || context.state.selectedFolderId;
|
||||
return CommandService.instance().execute('newFolder', parentId);
|
||||
},
|
||||
enabledCondition: '!folderIsReadOnly',
|
||||
enabledCondition: '!folderIsReadOnly && !folderIsTrash',
|
||||
};
|
||||
};
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
@ -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',
|
||||
};
|
||||
};
|
@ -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',
|
||||
};
|
||||
};
|
23
packages/app-desktop/gui/MainScreen/commands/restoreNote.ts
Normal file
23
packages/app-desktop/gui/MainScreen/commands/restoreNote.ts
Normal 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',
|
||||
};
|
||||
};
|
@ -66,6 +66,6 @@ export const runtime = (comp: any): CommandRuntime => {
|
||||
},
|
||||
});
|
||||
},
|
||||
enabledCondition: 'someNotesSelected',
|
||||
enabledCondition: 'someNotesSelected && !inTrash',
|
||||
};
|
||||
};
|
||||
|
@ -18,6 +18,6 @@ export const runtime = (comp: any): CommandRuntime => {
|
||||
},
|
||||
});
|
||||
},
|
||||
enabledCondition: 'joplinServerConnected && someNotesSelected',
|
||||
enabledCondition: 'joplinServerConnected && someNotesSelected && !noteIsDeleted',
|
||||
};
|
||||
};
|
||||
|
@ -837,6 +837,8 @@ function useMenu(props: Props) {
|
||||
separator(),
|
||||
menuItemDic.showNoteProperties,
|
||||
menuItemDic.showNoteContentProperties,
|
||||
separator(),
|
||||
menuItemDic.permanentlyDeleteNote,
|
||||
],
|
||||
},
|
||||
tools: {
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -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')), {
|
||||
|
@ -29,6 +29,7 @@ export interface Props {
|
||||
focusedField: string;
|
||||
parentFolderIsReadOnly: boolean;
|
||||
listRenderer: ListRenderer;
|
||||
selectedFolderInTrash: boolean;
|
||||
}
|
||||
|
||||
export enum BaseBreakpoint {
|
||||
|
@ -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 };
|
||||
};
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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 === ' ') {
|
||||
|
@ -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'],
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
);
|
||||
|
11
packages/app-desktop/gui/NotyfContext.tsx
Normal file
11
packages/app-desktop/gui/NotyfContext.tsx
Normal 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,
|
||||
}),
|
||||
);
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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' }}/>;
|
||||
};
|
27
packages/app-desktop/gui/TrashNotification/style.scss
Normal file
27
packages/app-desktop/gui/TrashNotification/style.scss
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
@ -20,6 +20,7 @@ export default function() {
|
||||
'setTags',
|
||||
'showLocalSearch',
|
||||
'showNoteContentProperties',
|
||||
'permanentlyDeleteNote',
|
||||
'synchronize',
|
||||
'textBold',
|
||||
'textCode',
|
||||
|
@ -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');
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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",
|
||||
|
@ -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;
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
};
|
||||
|
||||
|
@ -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();
|
||||
|
@ -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;
|
||||
|
@ -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 = '*';
|
||||
|
94
packages/lib/components/shared/side-menu-shared.test.ts
Normal file
94
packages/lib/components/shared/side-menu-shared.test.ts
Normal 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);
|
||||
});
|
||||
|
||||
});
|
@ -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);
|
||||
|
@ -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',
|
||||
|
@ -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);
|
||||
|
@ -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 }));
|
||||
});
|
||||
|
||||
});
|
||||
|
@ -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) : '';
|
||||
}
|
||||
|
@ -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());
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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_ };
|
||||
|
@ -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 });
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
73
packages/lib/models/utils/onFolderDrop.test.ts
Normal file
73
packages/lib/models/utils/onFolderDrop.test.ts
Normal 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);
|
||||
});
|
||||
|
||||
});
|
42
packages/lib/models/utils/onFolderDrop.ts
Normal file
42
packages/lib/models/utils/onFolderDrop.ts
Normal 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);
|
||||
};
|
@ -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 {
|
||||
|
@ -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);
|
||||
};
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -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' },
|
||||
],
|
||||
};
|
||||
|
||||
|
@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -48,6 +48,9 @@ interface RequestQuery {
|
||||
|
||||
// Event cursor
|
||||
cursor?: string;
|
||||
|
||||
// For note deletion
|
||||
permanent?: string;
|
||||
}
|
||||
|
||||
export interface Request {
|
||||
|
43
packages/lib/services/rest/routes/folders.test.ts
Normal file
43
packages/lib/services/rest/routes/folders.test.ts
Normal 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();
|
||||
});
|
||||
});
|
@ -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);
|
||||
}
|
||||
|
@ -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();
|
||||
});
|
||||
|
||||
});
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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();
|
||||
|
@ -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);
|
||||
|
@ -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();
|
||||
|
29
packages/lib/services/trash/emptyTrash.test.ts
Normal file
29
packages/lib/services/trash/emptyTrash.test.ts
Normal 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);
|
||||
});
|
||||
|
||||
});
|
12
packages/lib/services/trash/emptyTrash.ts
Normal file
12
packages/lib/services/trash/emptyTrash.ts
Normal 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 });
|
||||
}
|
||||
};
|
41
packages/lib/services/trash/index.test.ts
Normal file
41
packages/lib/services/trash/index.test.ts
Normal 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);
|
||||
});
|
||||
|
||||
});
|
91
packages/lib/services/trash/index.ts
Normal file
91
packages/lib/services/trash/index.ts
Normal 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 });
|
||||
};
|
@ -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);
|
||||
});
|
||||
|
||||
});
|
46
packages/lib/services/trash/permanentlyDeleteOldItems.ts
Normal file
46
packages/lib/services/trash/permanentlyDeleteOldItems.ts
Normal 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;
|
91
packages/lib/services/trash/restoreItems.test.ts
Normal file
91
packages/lib/services/trash/restoreItems.test.ts
Normal 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
Loading…
Reference in New Issue
Block a user