You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-07-16 00:14:34 +02:00
@ -109,6 +109,7 @@ packages/app-cli/app/command-mkbook.test.js
|
|||||||
packages/app-cli/app/command-mkbook.js
|
packages/app-cli/app/command-mkbook.js
|
||||||
packages/app-cli/app/command-mv.js
|
packages/app-cli/app/command-mv.js
|
||||||
packages/app-cli/app/command-ren.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-rmbook.js
|
||||||
packages/app-cli/app/command-rmnote.js
|
packages/app-cli/app/command-rmnote.js
|
||||||
packages/app-cli/app/command-set.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-testing.js
|
||||||
packages/app-cli/app/command-use.js
|
packages/app-cli/app/command-use.js
|
||||||
packages/app-cli/app/command-version.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/gui/StatusBarWidget.js
|
||||||
packages/app-cli/app/services/plugins/PluginRunner.js
|
packages/app-cli/app/services/plugins/PluginRunner.js
|
||||||
packages/app-cli/app/setupCommand.js
|
packages/app-cli/app/setupCommand.js
|
||||||
@ -143,6 +145,7 @@ packages/app-desktop/bridge.js
|
|||||||
packages/app-desktop/checkForUpdates.js
|
packages/app-desktop/checkForUpdates.js
|
||||||
packages/app-desktop/commands/copyDevCommand.js
|
packages/app-desktop/commands/copyDevCommand.js
|
||||||
packages/app-desktop/commands/editProfileConfig.js
|
packages/app-desktop/commands/editProfileConfig.js
|
||||||
|
packages/app-desktop/commands/emptyTrash.js
|
||||||
packages/app-desktop/commands/exportFolders.js
|
packages/app-desktop/commands/exportFolders.js
|
||||||
packages/app-desktop/commands/exportNotes.js
|
packages/app-desktop/commands/exportNotes.js
|
||||||
packages/app-desktop/commands/focusElement.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/openNote.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/openPdfViewer.js
|
packages/app-desktop/gui/MainScreen/commands/openPdfViewer.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/openTag.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/print.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/renameFolder.js
|
packages/app-desktop/gui/MainScreen/commands/renameFolder.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/renameTag.js
|
packages/app-desktop/gui/MainScreen/commands/renameTag.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/resetLayout.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/revealResourceFile.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/search.js
|
packages/app-desktop/gui/MainScreen/commands/search.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/setTags.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/NoteStatusBar.js
|
||||||
packages/app-desktop/gui/NoteTextViewer.js
|
packages/app-desktop/gui/NoteTextViewer.js
|
||||||
packages/app-desktop/gui/NoteToolbar/NoteToolbar.js
|
packages/app-desktop/gui/NoteToolbar/NoteToolbar.js
|
||||||
|
packages/app-desktop/gui/NotyfContext.js
|
||||||
packages/app-desktop/gui/OneDriveLoginScreen.js
|
packages/app-desktop/gui/OneDriveLoginScreen.js
|
||||||
packages/app-desktop/gui/PasswordInput/PasswordInput.js
|
packages/app-desktop/gui/PasswordInput/PasswordInput.js
|
||||||
packages/app-desktop/gui/PdfViewer.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/ToolbarButton.js
|
||||||
packages/app-desktop/gui/ToolbarButton/styles/index.js
|
packages/app-desktop/gui/ToolbarButton/styles/index.js
|
||||||
packages/app-desktop/gui/ToolbarSpace.js
|
packages/app-desktop/gui/ToolbarSpace.js
|
||||||
|
packages/app-desktop/gui/TrashNotification/TrashNotification.js
|
||||||
packages/app-desktop/gui/dialogs.js
|
packages/app-desktop/gui/dialogs.js
|
||||||
packages/app-desktop/gui/hooks/useEffectDebugger.js
|
packages/app-desktop/gui/hooks/useEffectDebugger.js
|
||||||
packages/app-desktop/gui/hooks/useImperativeHandlerDebugger.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/config/shouldShowMissingPasswordWarning.js
|
||||||
packages/lib/components/shared/note-screen-shared.js
|
packages/lib/components/shared/note-screen-shared.js
|
||||||
packages/lib/components/shared/reduxSharedMiddleware.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/components/shared/side-menu-shared.js
|
||||||
packages/lib/database-driver-better-sqlite.js
|
packages/lib/database-driver-better-sqlite.js
|
||||||
packages/lib/database.js
|
packages/lib/database.js
|
||||||
@ -751,6 +760,8 @@ packages/lib/models/settings/FileHandler.js
|
|||||||
packages/lib/models/settings/settingValidations.js
|
packages/lib/models/settings/settingValidations.js
|
||||||
packages/lib/models/utils/isItemId.js
|
packages/lib/models/utils/isItemId.js
|
||||||
packages/lib/models/utils/itemCanBeEncrypted.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/paginatedFeed.js
|
||||||
packages/lib/models/utils/paginationToSql.js
|
packages/lib/models/utils/paginationToSql.js
|
||||||
packages/lib/models/utils/readOnly.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/auth.js
|
||||||
packages/lib/services/rest/routes/events.test.js
|
packages/lib/services/rest/routes/events.test.js
|
||||||
packages/lib/services/rest/routes/events.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/folders.js
|
||||||
packages/lib/services/rest/routes/master_keys.js
|
packages/lib/services/rest/routes/master_keys.js
|
||||||
packages/lib/services/rest/routes/notes.test.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/resourceRemotePath.js
|
||||||
packages/lib/services/synchronizer/utils/syncDeleteStep.js
|
packages/lib/services/synchronizer/utils/syncDeleteStep.js
|
||||||
packages/lib/services/synchronizer/utils/types.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-init-node.js
|
||||||
packages/lib/shim.js
|
packages/lib/shim.js
|
||||||
packages/lib/string-utils.test.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-mkbook.js
|
||||||
packages/app-cli/app/command-mv.js
|
packages/app-cli/app/command-mv.js
|
||||||
packages/app-cli/app/command-ren.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-rmbook.js
|
||||||
packages/app-cli/app/command-rmnote.js
|
packages/app-cli/app/command-rmnote.js
|
||||||
packages/app-cli/app/command-set.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-testing.js
|
||||||
packages/app-cli/app/command-use.js
|
packages/app-cli/app/command-use.js
|
||||||
packages/app-cli/app/command-version.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/gui/StatusBarWidget.js
|
||||||
packages/app-cli/app/services/plugins/PluginRunner.js
|
packages/app-cli/app/services/plugins/PluginRunner.js
|
||||||
packages/app-cli/app/setupCommand.js
|
packages/app-cli/app/setupCommand.js
|
||||||
@ -123,6 +125,7 @@ packages/app-desktop/bridge.js
|
|||||||
packages/app-desktop/checkForUpdates.js
|
packages/app-desktop/checkForUpdates.js
|
||||||
packages/app-desktop/commands/copyDevCommand.js
|
packages/app-desktop/commands/copyDevCommand.js
|
||||||
packages/app-desktop/commands/editProfileConfig.js
|
packages/app-desktop/commands/editProfileConfig.js
|
||||||
|
packages/app-desktop/commands/emptyTrash.js
|
||||||
packages/app-desktop/commands/exportFolders.js
|
packages/app-desktop/commands/exportFolders.js
|
||||||
packages/app-desktop/commands/exportNotes.js
|
packages/app-desktop/commands/exportNotes.js
|
||||||
packages/app-desktop/commands/focusElement.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/openNote.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/openPdfViewer.js
|
packages/app-desktop/gui/MainScreen/commands/openPdfViewer.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/openTag.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/print.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/renameFolder.js
|
packages/app-desktop/gui/MainScreen/commands/renameFolder.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/renameTag.js
|
packages/app-desktop/gui/MainScreen/commands/renameTag.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/resetLayout.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/revealResourceFile.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/search.js
|
packages/app-desktop/gui/MainScreen/commands/search.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/setTags.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/NoteStatusBar.js
|
||||||
packages/app-desktop/gui/NoteTextViewer.js
|
packages/app-desktop/gui/NoteTextViewer.js
|
||||||
packages/app-desktop/gui/NoteToolbar/NoteToolbar.js
|
packages/app-desktop/gui/NoteToolbar/NoteToolbar.js
|
||||||
|
packages/app-desktop/gui/NotyfContext.js
|
||||||
packages/app-desktop/gui/OneDriveLoginScreen.js
|
packages/app-desktop/gui/OneDriveLoginScreen.js
|
||||||
packages/app-desktop/gui/PasswordInput/PasswordInput.js
|
packages/app-desktop/gui/PasswordInput/PasswordInput.js
|
||||||
packages/app-desktop/gui/PdfViewer.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/ToolbarButton.js
|
||||||
packages/app-desktop/gui/ToolbarButton/styles/index.js
|
packages/app-desktop/gui/ToolbarButton/styles/index.js
|
||||||
packages/app-desktop/gui/ToolbarSpace.js
|
packages/app-desktop/gui/ToolbarSpace.js
|
||||||
|
packages/app-desktop/gui/TrashNotification/TrashNotification.js
|
||||||
packages/app-desktop/gui/dialogs.js
|
packages/app-desktop/gui/dialogs.js
|
||||||
packages/app-desktop/gui/hooks/useEffectDebugger.js
|
packages/app-desktop/gui/hooks/useEffectDebugger.js
|
||||||
packages/app-desktop/gui/hooks/useImperativeHandlerDebugger.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/config/shouldShowMissingPasswordWarning.js
|
||||||
packages/lib/components/shared/note-screen-shared.js
|
packages/lib/components/shared/note-screen-shared.js
|
||||||
packages/lib/components/shared/reduxSharedMiddleware.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/components/shared/side-menu-shared.js
|
||||||
packages/lib/database-driver-better-sqlite.js
|
packages/lib/database-driver-better-sqlite.js
|
||||||
packages/lib/database.js
|
packages/lib/database.js
|
||||||
@ -731,6 +740,8 @@ packages/lib/models/settings/FileHandler.js
|
|||||||
packages/lib/models/settings/settingValidations.js
|
packages/lib/models/settings/settingValidations.js
|
||||||
packages/lib/models/utils/isItemId.js
|
packages/lib/models/utils/isItemId.js
|
||||||
packages/lib/models/utils/itemCanBeEncrypted.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/paginatedFeed.js
|
||||||
packages/lib/models/utils/paginationToSql.js
|
packages/lib/models/utils/paginationToSql.js
|
||||||
packages/lib/models/utils/readOnly.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/auth.js
|
||||||
packages/lib/services/rest/routes/events.test.js
|
packages/lib/services/rest/routes/events.test.js
|
||||||
packages/lib/services/rest/routes/events.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/folders.js
|
||||||
packages/lib/services/rest/routes/master_keys.js
|
packages/lib/services/rest/routes/master_keys.js
|
||||||
packages/lib/services/rest/routes/notes.test.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/resourceRemotePath.js
|
||||||
packages/lib/services/synchronizer/utils/syncDeleteStep.js
|
packages/lib/services/synchronizer/utils/syncDeleteStep.js
|
||||||
packages/lib/services/synchronizer/utils/types.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-init-node.js
|
||||||
packages/lib/shim.js
|
packages/lib/shim.js
|
||||||
packages/lib/string-utils.test.js
|
packages/lib/string-utils.test.js
|
||||||
|
@ -31,7 +31,7 @@ const WindowWidget = require('tkwidgets/WindowWidget.js');
|
|||||||
const NoteWidget = require('./gui/NoteWidget.js');
|
const NoteWidget = require('./gui/NoteWidget.js');
|
||||||
const ResourceServer = require('./ResourceServer.js');
|
const ResourceServer = require('./ResourceServer.js');
|
||||||
const NoteMetadataWidget = require('./gui/NoteMetadataWidget.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 NoteListWidget = require('./gui/NoteListWidget.js');
|
||||||
const StatusBarWidget = require('./gui/StatusBarWidget').default;
|
const StatusBarWidget = require('./gui/StatusBarWidget').default;
|
||||||
const ConsoleWidget = require('./gui/ConsoleWidget.js');
|
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: ['tc'], type: 'function', command: 'toggle_console' },
|
||||||
{ keys: ['tm'], type: 'function', command: 'toggle_metadata' },
|
{ keys: ['tm'], type: 'function', command: 'toggle_metadata' },
|
||||||
{ keys: ['ti'], type: 'function', command: 'toggle_ids' },
|
{ keys: ['ti'], type: 'function', command: 'toggle_ids' },
|
||||||
|
{ keys: ['r'], type: 'prompt', command: 'restore $n' },
|
||||||
{ keys: ['/'], type: 'prompt', command: 'search ""', cursorPosition: -2 },
|
{ keys: ['/'], type: 'prompt', command: 'search ""', cursorPosition: -2 },
|
||||||
{ keys: ['mn'], type: 'prompt', command: 'mknote ""', cursorPosition: -2 },
|
{ keys: ['mn'], type: 'prompt', command: 'mknote ""', cursorPosition: -2 },
|
||||||
{ keys: ['mt'], type: 'prompt', command: 'mktodo ""', 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('Remove the tag from the note.');
|
||||||
lines.push('');
|
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 Folder from '@joplin/lib/models/Folder';
|
||||||
import Note from '@joplin/lib/models/Note';
|
import Note from '@joplin/lib/models/Note';
|
||||||
import Tag from '@joplin/lib/models/Tag';
|
import Tag from '@joplin/lib/models/Tag';
|
||||||
|
import { FolderEntity, NoteEntity } from '@joplin/lib/services/database/types';
|
||||||
|
|
||||||
class Command extends BaseCommand {
|
class Command extends BaseCommand {
|
||||||
public override usage() {
|
public override usage() {
|
||||||
@ -17,7 +18,7 @@ class Command extends BaseCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public override async action() {
|
public override async action() {
|
||||||
let items = [];
|
let items: (NoteEntity | FolderEntity)[] = [];
|
||||||
const folders = await Folder.all();
|
const folders = await Folder.all();
|
||||||
for (let i = 0; i < folders.length; i++) {
|
for (let i = 0; i < folders.length; i++) {
|
||||||
const folder = folders[i];
|
const folder = folders[i];
|
||||||
|
@ -7,6 +7,7 @@ import Setting from '@joplin/lib/models/Setting';
|
|||||||
import Note from '@joplin/lib/models/Note';
|
import Note from '@joplin/lib/models/Note';
|
||||||
const { sprintf } = require('sprintf-js');
|
const { sprintf } = require('sprintf-js');
|
||||||
import time from '@joplin/lib/time';
|
import time from '@joplin/lib/time';
|
||||||
|
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||||
const { cliUtils } = require('./cli-utils.js');
|
const { cliUtils } = require('./cli-utils.js');
|
||||||
|
|
||||||
class Command extends BaseCommand {
|
class Command extends BaseCommand {
|
||||||
@ -71,7 +72,7 @@ class Command extends BaseCommand {
|
|||||||
let hasTodos = false;
|
let hasTodos = false;
|
||||||
for (let i = 0; i < items.length; i++) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
const item = items[i];
|
const item = items[i];
|
||||||
if (item.is_todo) {
|
if ((item as NoteEntity).is_todo) {
|
||||||
hasTodos = true;
|
hasTodos = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -103,8 +104,8 @@ class Command extends BaseCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hasTodos) {
|
if (hasTodos) {
|
||||||
if (item.is_todo) {
|
if ((item as NoteEntity).is_todo) {
|
||||||
row.push(sprintf('[%s]', item.todo_completed ? 'X' : ' '));
|
row.push(sprintf('[%s]', (item as NoteEntity).todo_completed ? 'X' : ' '));
|
||||||
} else {
|
} else {
|
||||||
row.push(' ');
|
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 { _ } from '@joplin/lib/locale';
|
||||||
import Folder from '@joplin/lib/models/Folder';
|
import Folder from '@joplin/lib/models/Folder';
|
||||||
import BaseModel from '@joplin/lib/BaseModel';
|
import BaseModel from '@joplin/lib/BaseModel';
|
||||||
|
const { substrWithEllipsis } = require('@joplin/lib/string-utils');
|
||||||
|
|
||||||
class Command extends BaseCommand {
|
class Command extends BaseCommand {
|
||||||
public override usage() {
|
public override usage() {
|
||||||
@ -23,10 +24,11 @@ class Command extends BaseCommand {
|
|||||||
|
|
||||||
const folder = await app().loadItem(BaseModel.TYPE_FOLDER, pattern);
|
const folder = await app().loadItem(BaseModel.TYPE_FOLDER, pattern);
|
||||||
if (!folder) throw new Error(_('Cannot find "%s".', 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;
|
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 { _ } from '@joplin/lib/locale';
|
||||||
import Note from '@joplin/lib/models/Note';
|
import Note from '@joplin/lib/models/Note';
|
||||||
import BaseModel from '@joplin/lib/BaseModel';
|
import BaseModel from '@joplin/lib/BaseModel';
|
||||||
|
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||||
|
|
||||||
class Command extends BaseCommand {
|
class Command extends BaseCommand {
|
||||||
public override usage() {
|
public override usage() {
|
||||||
@ -21,13 +22,18 @@ class Command extends BaseCommand {
|
|||||||
const pattern = args['note-pattern'];
|
const pattern = args['note-pattern'];
|
||||||
const force = args.options && args.options.force === true;
|
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));
|
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;
|
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;
|
import Folder from '@joplin/lib/models/Folder';
|
||||||
const Tag = require('@joplin/lib/models/Tag').default;
|
import Tag from '@joplin/lib/models/Tag';
|
||||||
const BaseModel = require('@joplin/lib/BaseModel').default;
|
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 ListWidget = require('tkwidgets/ListWidget.js');
|
||||||
const Setting = require('@joplin/lib/models/Setting').default;
|
|
||||||
const _ = require('@joplin/lib/locale')._;
|
|
||||||
|
|
||||||
class FolderListWidget extends ListWidget {
|
export default class FolderListWidget extends ListWidget {
|
||||||
constructor() {
|
|
||||||
|
private folders_: FolderEntity[] = [];
|
||||||
|
|
||||||
|
public constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.tags_ = [];
|
this.tags_ = [];
|
||||||
this.folders_ = [];
|
|
||||||
this.searches_ = [];
|
this.searches_ = [];
|
||||||
this.selectedFolderId_ = null;
|
this.selectedFolderId_ = null;
|
||||||
this.selectedTagId_ = null;
|
this.selectedTagId_ = null;
|
||||||
@ -21,7 +25,7 @@ class FolderListWidget extends ListWidget {
|
|||||||
this.trimItemTitle = false;
|
this.trimItemTitle = false;
|
||||||
this.showIds = false;
|
this.showIds = false;
|
||||||
|
|
||||||
this.itemRenderer = item => {
|
this.itemRenderer = (item: any) => {
|
||||||
const output = [];
|
const output = [];
|
||||||
if (item === '-') {
|
if (item === '-') {
|
||||||
output.push('-'.repeat(this.innerWidth));
|
output.push('-'.repeat(this.innerWidth));
|
||||||
@ -33,13 +37,12 @@ class FolderListWidget extends ListWidget {
|
|||||||
}
|
}
|
||||||
output.push(Folder.displayTitle(item));
|
output.push(Folder.displayTitle(item));
|
||||||
|
|
||||||
if (Setting.value('showNoteCounts')) {
|
if (Setting.value('showNoteCounts') && !item.deleted_time && item.id !== getTrashFolderId()) {
|
||||||
let noteCount = item.note_count;
|
let noteCount = item.note_count;
|
||||||
// Subtract children note_count from parent folder.
|
|
||||||
if (this.folderHasChildren_(this.folders, item.id)) {
|
if (this.folderHasChildren_(this.folders, item.id)) {
|
||||||
for (let i = 0; i < this.folders.length; i++) {
|
for (let i = 0; i < this.folders.length; i++) {
|
||||||
if (this.folders[i].parent_id === item.id) {
|
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;
|
let output = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
const folder = BaseModel.byId(folders, folderId);
|
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++;
|
output++;
|
||||||
folderId = folder.parent_id;
|
folderId = folderParentId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get selectedFolderId() {
|
public get selectedFolderId() {
|
||||||
return this.selectedFolderId_;
|
return this.selectedFolderId_;
|
||||||
}
|
}
|
||||||
|
|
||||||
set selectedFolderId(v) {
|
public set selectedFolderId(v) {
|
||||||
this.selectedFolderId_ = v;
|
this.selectedFolderId_ = v;
|
||||||
this.updateIndexFromSelectedItemId();
|
this.updateIndexFromSelectedItemId();
|
||||||
this.invalidate();
|
this.invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
get selectedSearchId() {
|
public get selectedSearchId() {
|
||||||
return this.selectedSearchId_;
|
return this.selectedSearchId_;
|
||||||
}
|
}
|
||||||
|
|
||||||
set selectedSearchId(v) {
|
public set selectedSearchId(v) {
|
||||||
this.selectedSearchId_ = v;
|
this.selectedSearchId_ = v;
|
||||||
this.updateIndexFromSelectedItemId();
|
this.updateIndexFromSelectedItemId();
|
||||||
this.invalidate();
|
this.invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
get selectedTagId() {
|
public get selectedTagId() {
|
||||||
return this.selectedTagId_;
|
return this.selectedTagId_;
|
||||||
}
|
}
|
||||||
|
|
||||||
set selectedTagId(v) {
|
public set selectedTagId(v) {
|
||||||
this.selectedTagId_ = v;
|
this.selectedTagId_ = v;
|
||||||
this.updateIndexFromSelectedItemId();
|
this.updateIndexFromSelectedItemId();
|
||||||
this.invalidate();
|
this.invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
get notesParentType() {
|
public get notesParentType() {
|
||||||
return this.notesParentType_;
|
return this.notesParentType_;
|
||||||
}
|
}
|
||||||
|
|
||||||
set notesParentType(v) {
|
public set notesParentType(v) {
|
||||||
this.notesParentType_ = v;
|
this.notesParentType_ = v;
|
||||||
this.updateIndexFromSelectedItemId();
|
this.updateIndexFromSelectedItemId();
|
||||||
this.invalidate();
|
this.invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
get searches() {
|
public get searches() {
|
||||||
return this.searches_;
|
return this.searches_;
|
||||||
}
|
}
|
||||||
|
|
||||||
set searches(v) {
|
public set searches(v) {
|
||||||
this.searches_ = v;
|
this.searches_ = v;
|
||||||
this.updateItems_ = true;
|
this.updateItems_ = true;
|
||||||
this.updateIndexFromSelectedItemId();
|
this.updateIndexFromSelectedItemId();
|
||||||
this.invalidate();
|
this.invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
get tags() {
|
public get tags() {
|
||||||
return this.tags_;
|
return this.tags_;
|
||||||
}
|
}
|
||||||
|
|
||||||
set tags(v) {
|
public set tags(v) {
|
||||||
this.tags_ = v;
|
this.tags_ = v;
|
||||||
this.updateItems_ = true;
|
this.updateItems_ = true;
|
||||||
this.updateIndexFromSelectedItemId();
|
this.updateIndexFromSelectedItemId();
|
||||||
this.invalidate();
|
this.invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
get folders() {
|
public get folders() {
|
||||||
return this.folders_;
|
return this.folders_;
|
||||||
}
|
}
|
||||||
|
|
||||||
set folders(v) {
|
public set folders(v) {
|
||||||
this.folders_ = v;
|
this.folders_ = v;
|
||||||
this.updateItems_ = true;
|
this.updateItems_ = true;
|
||||||
this.updateIndexFromSelectedItemId();
|
this.updateIndexFromSelectedItemId();
|
||||||
this.invalidate();
|
this.invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleShowIds() {
|
public toggleShowIds() {
|
||||||
this.showIds = !this.showIds;
|
this.showIds = !this.showIds;
|
||||||
this.invalidate();
|
this.invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
folderHasChildren_(folders, folderId) {
|
public folderHasChildren_(folders: FolderEntity[], folderId: string) {
|
||||||
for (let i = 0; i < folders.length; i++) {
|
for (let i = 0; i < folders.length; i++) {
|
||||||
const folder = folders[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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
public render() {
|
||||||
if (this.updateItems_) {
|
if (this.updateItems_) {
|
||||||
this.logger().debug('Rebuilding items...', this.notesParentType, this.selectedJoplinItemId, this.selectedSearchId);
|
this.logger().debug('Rebuilding items...', this.notesParentType, this.selectedJoplinItemId, this.selectedSearchId);
|
||||||
const wasSelectedItemId = this.selectedJoplinItemId;
|
const wasSelectedItemId = this.selectedJoplinItemId;
|
||||||
const previousParentType = this.notesParentType;
|
const previousParentType = this.notesParentType;
|
||||||
|
|
||||||
let newItems = [];
|
this.logger().info('FFFFFFFFFFFFF', JSON.stringify(this.folders, null, 4));
|
||||||
const orderFolders = parentId => {
|
|
||||||
|
let newItems: any[] = [];
|
||||||
|
const orderFolders = (parentId: string) => {
|
||||||
|
this.logger().info('PARENT', parentId);
|
||||||
for (let i = 0; i < this.folders.length; i++) {
|
for (let i = 0; i < this.folders.length; i++) {
|
||||||
const f = this.folders[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) {
|
if (folderParentId === parentId) {
|
||||||
newItems.push(f);
|
newItems.push(f);
|
||||||
if (this.folderHasChildren_(this.folders, f.id)) orderFolders(f.id);
|
if (this.folderHasChildren_(this.folders, f.id)) orderFolders(f.id);
|
||||||
@ -192,7 +203,7 @@ class FolderListWidget extends ListWidget {
|
|||||||
super.render();
|
super.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
get selectedJoplinItemId() {
|
public get selectedJoplinItemId() {
|
||||||
if (!this.notesParentType) return '';
|
if (!this.notesParentType) return '';
|
||||||
if (this.notesParentType === 'Folder') return this.selectedFolderId;
|
if (this.notesParentType === 'Folder') return this.selectedFolderId;
|
||||||
if (this.notesParentType === 'Tag') return this.selectedTagId;
|
if (this.notesParentType === 'Tag') return this.selectedTagId;
|
||||||
@ -200,17 +211,15 @@ class FolderListWidget extends ListWidget {
|
|||||||
throw new Error(`Unknown parent type: ${this.notesParentType}`);
|
throw new Error(`Unknown parent type: ${this.notesParentType}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
get selectedJoplinItem() {
|
public get selectedJoplinItem() {
|
||||||
const id = this.selectedJoplinItemId;
|
const id = this.selectedJoplinItemId;
|
||||||
const index = this.itemIndexByKey('id', id);
|
const index = this.itemIndexByKey('id', id);
|
||||||
return this.itemAt(index);
|
return this.itemAt(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateIndexFromSelectedItemId(itemId = null) {
|
public updateIndexFromSelectedItemId(itemId: string = null) {
|
||||||
if (itemId === null) itemId = this.selectedJoplinItemId;
|
if (itemId === null) itemId = this.selectedJoplinItemId;
|
||||||
const index = this.itemIndexByKey('id', itemId);
|
const index = this.itemIndexByKey('id', itemId);
|
||||||
this.currentIndex = index >= 0 ? index : 0;
|
this.currentIndex = index >= 0 ? index : 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = FolderListWidget;
|
|
@ -1,7 +1,7 @@
|
|||||||
import ElectronAppWrapper from './ElectronAppWrapper';
|
import ElectronAppWrapper from './ElectronAppWrapper';
|
||||||
import shim from '@joplin/lib/shim';
|
import shim from '@joplin/lib/shim';
|
||||||
import { _, setLocale } from '@joplin/lib/locale';
|
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 { dirname, isUncPath, toSystemSlashes } from '@joplin/lib/path-utils';
|
||||||
import { fileUriToPath } from '@joplin/utils/url';
|
import { fileUriToPath } from '@joplin/utils/url';
|
||||||
import { urlDecode } from '@joplin/lib/string-utils';
|
import { urlDecode } from '@joplin/lib/string-utils';
|
||||||
@ -23,6 +23,10 @@ interface OpenDialogOptions {
|
|||||||
filters?: any[];
|
filters?: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MessageDialogOptions extends Omit<MessageBoxSyncOptions, 'message'> {
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class Bridge {
|
export class Bridge {
|
||||||
|
|
||||||
private electronWrapper_: ElectronAppWrapper;
|
private electronWrapper_: ElectronAppWrapper;
|
||||||
@ -228,7 +232,6 @@ export class Bridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async showSaveDialog(options: any) {
|
public async showSaveDialog(options: any) {
|
||||||
const { dialog } = require('electron');
|
|
||||||
if (!options) options = {};
|
if (!options) options = {};
|
||||||
if (!('defaultPath' in options) && this.lastSelectedPaths_.file) options.defaultPath = this.lastSelectedPaths_.file;
|
if (!('defaultPath' in options) && this.lastSelectedPaths_.file) options.defaultPath = this.lastSelectedPaths_.file;
|
||||||
const { filePath } = await dialog.showSaveDialog(this.window(), options);
|
const { filePath } = await dialog.showSaveDialog(this.window(), options);
|
||||||
@ -239,7 +242,6 @@ export class Bridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async showOpenDialog(options: OpenDialogOptions = null) {
|
public async showOpenDialog(options: OpenDialogOptions = null) {
|
||||||
const { dialog } = require('electron');
|
|
||||||
if (!options) options = {};
|
if (!options) options = {};
|
||||||
let fileType = 'file';
|
let fileType = 'file';
|
||||||
if (options.properties && options.properties.includes('openDirectory')) fileType = 'directory';
|
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
|
// Don't use this directly - call one of the showXxxxxxxMessageBox() instead
|
||||||
private showMessageBox_(window: any, options: any): number {
|
private showMessageBox_(window: any, options: MessageDialogOptions): number {
|
||||||
const { dialog } = require('electron');
|
|
||||||
if (!window) window = this.window();
|
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 = {
|
options = {
|
||||||
buttons: [_('OK')],
|
buttons: [_('OK')],
|
||||||
...options,
|
...options,
|
||||||
@ -272,7 +273,7 @@ export class Bridge {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public showConfirmMessageBox(message: string, options: any = null) {
|
public showConfirmMessageBox(message: string, options: MessageDialogOptions = null) {
|
||||||
options = {
|
options = {
|
||||||
buttons: [_('OK'), _('Cancel')],
|
buttons: [_('OK'), _('Cancel')],
|
||||||
...options,
|
...options,
|
||||||
@ -287,8 +288,8 @@ export class Bridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* returns the index of the clicked button */
|
/* returns the index of the clicked button */
|
||||||
public showMessageBox(message: string, options: any = null) {
|
public showMessageBox(message: string, options: MessageDialogOptions = null) {
|
||||||
if (options === null) options = {};
|
if (options === null) options = { message: '' };
|
||||||
|
|
||||||
const result = this.showMessageBox_(this.window(), { type: 'question',
|
const result = this.showMessageBox_(this.window(), { type: 'question',
|
||||||
message: message,
|
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`
|
// AUTO-GENERATED using `gulp buildScriptIndexes`
|
||||||
import * as copyDevCommand from './copyDevCommand';
|
import * as copyDevCommand from './copyDevCommand';
|
||||||
import * as editProfileConfig from './editProfileConfig';
|
import * as editProfileConfig from './editProfileConfig';
|
||||||
|
import * as emptyTrash from './emptyTrash';
|
||||||
import * as exportFolders from './exportFolders';
|
import * as exportFolders from './exportFolders';
|
||||||
import * as exportNotes from './exportNotes';
|
import * as exportNotes from './exportNotes';
|
||||||
import * as focusElement from './focusElement';
|
import * as focusElement from './focusElement';
|
||||||
@ -19,6 +20,7 @@ import * as toggleSafeMode from './toggleSafeMode';
|
|||||||
const index: any[] = [
|
const index: any[] = [
|
||||||
copyDevCommand,
|
copyDevCommand,
|
||||||
editProfileConfig,
|
editProfileConfig,
|
||||||
|
emptyTrash,
|
||||||
exportFolders,
|
exportFolders,
|
||||||
exportNotes,
|
exportNotes,
|
||||||
focusElement,
|
focusElement,
|
||||||
|
@ -13,7 +13,7 @@ import Sidebar from '../Sidebar/Sidebar';
|
|||||||
import UserWebview from '../../services/plugins/UserWebview';
|
import UserWebview from '../../services/plugins/UserWebview';
|
||||||
import UserWebviewDialog from '../../services/plugins/UserWebviewDialog';
|
import UserWebviewDialog from '../../services/plugins/UserWebviewDialog';
|
||||||
import { ContainerType } from '@joplin/lib/services/plugins/WebviewController';
|
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 InteropServiceHelper from '../../InteropServiceHelper';
|
||||||
import { _ } from '@joplin/lib/locale';
|
import { _ } from '@joplin/lib/locale';
|
||||||
import NoteListWrapper from '../NoteListWrapper/NoteListWrapper';
|
import NoteListWrapper from '../NoteListWrapper/NoteListWrapper';
|
||||||
@ -45,6 +45,8 @@ import restart from '../../services/restart';
|
|||||||
const { connect } = require('react-redux');
|
const { connect } = require('react-redux');
|
||||||
import PromptDialog from '../PromptDialog';
|
import PromptDialog from '../PromptDialog';
|
||||||
import NotePropertiesDialog from '../NotePropertiesDialog';
|
import NotePropertiesDialog from '../NotePropertiesDialog';
|
||||||
|
import TrashNotification from '../TrashNotification/TrashNotification';
|
||||||
|
|
||||||
const PluginManager = require('@joplin/lib/services/PluginManager');
|
const PluginManager = require('@joplin/lib/services/PluginManager');
|
||||||
const ipcRenderer = require('electron').ipcRenderer;
|
const ipcRenderer = require('electron').ipcRenderer;
|
||||||
|
|
||||||
@ -83,6 +85,9 @@ interface Props {
|
|||||||
processingShareInvitationResponse: boolean;
|
processingShareInvitationResponse: boolean;
|
||||||
isResettingLayout: boolean;
|
isResettingLayout: boolean;
|
||||||
listRendererId: string;
|
listRendererId: string;
|
||||||
|
lastDeletion: StateLastDeletion;
|
||||||
|
lastDeletionNotificationTime: number;
|
||||||
|
selectedFolderId: string;
|
||||||
mustUpgradeAppMessage: string;
|
mustUpgradeAppMessage: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -732,6 +737,7 @@ class MainScreenComponent extends React.Component<Props, State> {
|
|||||||
themeId={this.props.themeId}
|
themeId={this.props.themeId}
|
||||||
listRendererId={this.props.listRendererId}
|
listRendererId={this.props.listRendererId}
|
||||||
startupPluginsLoaded={this.props.startupPluginsLoaded}
|
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} />
|
<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}
|
{messageComp}
|
||||||
{layoutComp}
|
{layoutComp}
|
||||||
{pluginDialog}
|
{pluginDialog}
|
||||||
@ -918,6 +930,9 @@ const mapStateToProps = (state: AppState) => {
|
|||||||
needApiAuth: state.needApiAuth,
|
needApiAuth: state.needApiAuth,
|
||||||
isResettingLayout: state.isResettingLayout,
|
isResettingLayout: state.isResettingLayout,
|
||||||
listRendererId: state.settings['notes.listRendererId'],
|
listRendererId: state.settings['notes.listRendererId'],
|
||||||
|
lastDeletion: state.lastDeletion,
|
||||||
|
lastDeletionNotificationTime: state.lastDeletionNotificationTime,
|
||||||
|
selectedFolderId: state.selectedFolderId,
|
||||||
mustUpgradeAppMessage: state.mustUpgradeAppMessage,
|
mustUpgradeAppMessage: state.mustUpgradeAppMessage,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -17,7 +17,7 @@ export const runtime = (): CommandRuntime => {
|
|||||||
const folder = await Folder.load(folderId);
|
const folder = await Folder.load(folderId);
|
||||||
if (!folder) throw new Error(`No such folder: ${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']) {
|
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.');
|
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);
|
const ok = bridge().showConfirmMessageBox(deleteMessage);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
|
||||||
await Folder.delete(folderId);
|
await Folder.delete(folderId, { toTrash: true });
|
||||||
},
|
},
|
||||||
enabledCondition: '!folderIsReadOnly',
|
enabledCondition: '!folderIsReadOnly',
|
||||||
};
|
};
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||||
import { _ } from '@joplin/lib/locale';
|
import { _ } from '@joplin/lib/locale';
|
||||||
import Note from '@joplin/lib/models/Note';
|
import Note from '@joplin/lib/models/Note';
|
||||||
import bridge from '../../../services/bridge';
|
|
||||||
|
|
||||||
export const declaration: CommandDeclaration = {
|
export const declaration: CommandDeclaration = {
|
||||||
name: 'deleteNote',
|
name: 'deleteNote',
|
||||||
@ -13,20 +12,17 @@ export const runtime = (): CommandRuntime => {
|
|||||||
return {
|
return {
|
||||||
execute: async (context: CommandContext, noteIds: string[] = null) => {
|
execute: async (context: CommandContext, noteIds: string[] = null) => {
|
||||||
if (noteIds === null) noteIds = context.state.selectedNoteIds;
|
if (noteIds === null) noteIds = context.state.selectedNoteIds;
|
||||||
|
|
||||||
if (!noteIds.length) return;
|
if (!noteIds.length) return;
|
||||||
|
await Note.batchDelete(noteIds, { toTrash: true });
|
||||||
|
|
||||||
const msg = await Note.deleteMessage(noteIds);
|
context.dispatch({
|
||||||
if (!msg) return;
|
type: 'ITEMS_TRASHED',
|
||||||
|
value: {
|
||||||
const ok = bridge().showConfirmMessageBox(msg, {
|
noteIds,
|
||||||
buttons: [_('Delete'), _('Cancel')],
|
folderIds: [],
|
||||||
defaultId: 1,
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
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 { stateUtils } from '@joplin/lib/reducer';
|
||||||
import Note from '@joplin/lib/models/Note';
|
import Note from '@joplin/lib/models/Note';
|
||||||
import time from '@joplin/lib/time';
|
import time from '@joplin/lib/time';
|
||||||
|
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||||
|
|
||||||
export const declaration: CommandDeclaration = {
|
export const declaration: CommandDeclaration = {
|
||||||
name: 'editAlarm',
|
name: 'editAlarm',
|
||||||
@ -29,7 +30,7 @@ export const runtime = (comp: any): CommandRuntime => {
|
|||||||
buttons: ['ok', 'cancel', 'clear'],
|
buttons: ['ok', 'cancel', 'clear'],
|
||||||
value: note.todo_due ? new Date(note.todo_due) : defaultDate,
|
value: note.todo_due ? new Date(note.todo_due) : defaultDate,
|
||||||
onClose: async (answer: any, buttonType: string) => {
|
onClose: async (answer: any, buttonType: string) => {
|
||||||
let newNote = null;
|
let newNote: NoteEntity = null;
|
||||||
|
|
||||||
if (buttonType === 'clear') {
|
if (buttonType === 'clear') {
|
||||||
newNote = {
|
newNote = {
|
||||||
|
@ -20,10 +20,13 @@ import * as openItem from './openItem';
|
|||||||
import * as openNote from './openNote';
|
import * as openNote from './openNote';
|
||||||
import * as openPdfViewer from './openPdfViewer';
|
import * as openPdfViewer from './openPdfViewer';
|
||||||
import * as openTag from './openTag';
|
import * as openTag from './openTag';
|
||||||
|
import * as permanentlyDeleteNote from './permanentlyDeleteNote';
|
||||||
import * as print from './print';
|
import * as print from './print';
|
||||||
import * as renameFolder from './renameFolder';
|
import * as renameFolder from './renameFolder';
|
||||||
import * as renameTag from './renameTag';
|
import * as renameTag from './renameTag';
|
||||||
import * as resetLayout from './resetLayout';
|
import * as resetLayout from './resetLayout';
|
||||||
|
import * as restoreFolder from './restoreFolder';
|
||||||
|
import * as restoreNote from './restoreNote';
|
||||||
import * as revealResourceFile from './revealResourceFile';
|
import * as revealResourceFile from './revealResourceFile';
|
||||||
import * as search from './search';
|
import * as search from './search';
|
||||||
import * as setTags from './setTags';
|
import * as setTags from './setTags';
|
||||||
@ -66,10 +69,13 @@ const index: any[] = [
|
|||||||
openNote,
|
openNote,
|
||||||
openPdfViewer,
|
openPdfViewer,
|
||||||
openTag,
|
openTag,
|
||||||
|
permanentlyDeleteNote,
|
||||||
print,
|
print,
|
||||||
renameFolder,
|
renameFolder,
|
||||||
renameTag,
|
renameTag,
|
||||||
resetLayout,
|
resetLayout,
|
||||||
|
restoreFolder,
|
||||||
|
restoreNote,
|
||||||
revealResourceFile,
|
revealResourceFile,
|
||||||
search,
|
search,
|
||||||
setTags,
|
setTags,
|
||||||
|
@ -3,6 +3,8 @@ import { _ } from '@joplin/lib/locale';
|
|||||||
import Setting from '@joplin/lib/models/Setting';
|
import Setting from '@joplin/lib/models/Setting';
|
||||||
import Note from '@joplin/lib/models/Note';
|
import Note from '@joplin/lib/models/Note';
|
||||||
|
|
||||||
|
export const newNoteEnabledConditions = 'oneFolderSelected && !inConflictFolder && !folderIsReadOnly && !folderIsTrash';
|
||||||
|
|
||||||
export const declaration: CommandDeclaration = {
|
export const declaration: CommandDeclaration = {
|
||||||
name: 'newNote',
|
name: 'newNote',
|
||||||
label: () => _('New note'),
|
label: () => _('New note'),
|
||||||
@ -36,6 +38,6 @@ export const runtime = (): CommandRuntime => {
|
|||||||
type: 'NOTE_SORT',
|
type: 'NOTE_SORT',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
enabledCondition: 'oneFolderSelected && !inConflictFolder && !folderIsReadOnly',
|
enabledCondition: newNoteEnabledConditions,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -13,6 +13,6 @@ export const runtime = (): CommandRuntime => {
|
|||||||
parentId = parentId || context.state.selectedFolderId;
|
parentId = parentId || context.state.selectedFolderId;
|
||||||
return CommandService.instance().execute('newFolder', parentId);
|
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 CommandService, { CommandContext, CommandDeclaration, CommandRuntime } from '@joplin/lib/services/CommandService';
|
||||||
import { _ } from '@joplin/lib/locale';
|
import { _ } from '@joplin/lib/locale';
|
||||||
|
import { newNoteEnabledConditions } from './newNote';
|
||||||
|
|
||||||
export const declaration: CommandDeclaration = {
|
export const declaration: CommandDeclaration = {
|
||||||
name: 'newTodo',
|
name: 'newTodo',
|
||||||
@ -12,6 +13,6 @@ export const runtime = (): CommandRuntime => {
|
|||||||
execute: async (_context: CommandContext, body = '') => {
|
execute: async (_context: CommandContext, body = '') => {
|
||||||
return CommandService.instance().execute('newNote', body, true);
|
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(),
|
separator(),
|
||||||
menuItemDic.showNoteProperties,
|
menuItemDic.showNoteProperties,
|
||||||
menuItemDic.showNoteContentProperties,
|
menuItemDic.showNoteContentProperties,
|
||||||
|
separator(),
|
||||||
|
menuItemDic.permanentlyDeleteNote,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
tools: {
|
tools: {
|
||||||
|
@ -26,9 +26,11 @@ export async function htmlToMarkdown(markupLanguage: number, html: string, origi
|
|||||||
export async function formNoteToNote(formNote: FormNote): Promise<any> {
|
export async function formNoteToNote(formNote: FormNote): Promise<any> {
|
||||||
return {
|
return {
|
||||||
id: formNote.id,
|
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
|
// https://discourse.joplinapp.org/t/experimental-wysiwyg-editor-in-joplin/6915/57?u=laurent
|
||||||
parent_id: formNote.parent_id,
|
parent_id: formNote.parent_id,
|
||||||
|
deleted_time: formNote.deleted_time,
|
||||||
title: formNote.title,
|
title: formNote.title,
|
||||||
body: formNote.body,
|
body: formNote.body,
|
||||||
};
|
};
|
||||||
|
@ -134,6 +134,7 @@ export interface FormNote {
|
|||||||
markup_language: number;
|
markup_language: number;
|
||||||
user_updated_time: number;
|
user_updated_time: number;
|
||||||
encryption_applied: number;
|
encryption_applied: number;
|
||||||
|
deleted_time: number;
|
||||||
|
|
||||||
hasChanged: boolean;
|
hasChanged: boolean;
|
||||||
|
|
||||||
@ -173,6 +174,7 @@ export function defaultFormNote(): FormNote {
|
|||||||
return {
|
return {
|
||||||
id: '',
|
id: '',
|
||||||
parent_id: '',
|
parent_id: '',
|
||||||
|
deleted_time: 0,
|
||||||
title: '',
|
title: '',
|
||||||
body: '',
|
body: '',
|
||||||
is_todo: 0,
|
is_todo: 0,
|
||||||
|
@ -13,6 +13,7 @@ import Note from '@joplin/lib/models/Note';
|
|||||||
import { reg } from '@joplin/lib/registry';
|
import { reg } from '@joplin/lib/registry';
|
||||||
import ResourceFetcher from '@joplin/lib/services/ResourceFetcher';
|
import ResourceFetcher from '@joplin/lib/services/ResourceFetcher';
|
||||||
import DecryptionWorker from '@joplin/lib/services/DecryptionWorker';
|
import DecryptionWorker from '@joplin/lib/services/DecryptionWorker';
|
||||||
|
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||||
|
|
||||||
export interface OnLoadEvent {
|
export interface OnLoadEvent {
|
||||||
formNote: FormNote;
|
formNote: FormNote;
|
||||||
@ -77,7 +78,7 @@ export default function useFormNote(dependencies: HookDependencies) {
|
|||||||
// a new refresh.
|
// a new refresh.
|
||||||
const [formNoteRefreshScheduled, setFormNoteRefreshScheduled] = useState<number>(0);
|
const [formNoteRefreshScheduled, setFormNoteRefreshScheduled] = useState<number>(0);
|
||||||
|
|
||||||
async function initNoteState(n: any) {
|
async function initNoteState(n: NoteEntity) {
|
||||||
let originalCss = '';
|
let originalCss = '';
|
||||||
|
|
||||||
if (n.markup_language === MarkupToHtml.MARKUP_LANGUAGE_HTML) {
|
if (n.markup_language === MarkupToHtml.MARKUP_LANGUAGE_HTML) {
|
||||||
@ -91,6 +92,7 @@ export default function useFormNote(dependencies: HookDependencies) {
|
|||||||
body: n.body,
|
body: n.body,
|
||||||
is_todo: n.is_todo,
|
is_todo: n.is_todo,
|
||||||
parent_id: n.parent_id,
|
parent_id: n.parent_id,
|
||||||
|
deleted_time: n.deleted_time,
|
||||||
bodyWillChangeId: 0,
|
bodyWillChangeId: 0,
|
||||||
bodyChangeId: 0,
|
bodyChangeId: 0,
|
||||||
markup_language: n.markup_language,
|
markup_language: n.markup_language,
|
||||||
|
@ -22,6 +22,8 @@ import * as focusElementNoteList from './commands/focusElementNoteList';
|
|||||||
import CommandService from '@joplin/lib/services/CommandService';
|
import CommandService from '@joplin/lib/services/CommandService';
|
||||||
import useDragAndDrop from './utils/useDragAndDrop';
|
import useDragAndDrop from './utils/useDragAndDrop';
|
||||||
import usePrevious from '../hooks/usePrevious';
|
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 { connect } = require('react-redux');
|
||||||
|
|
||||||
const commands = {
|
const commands = {
|
||||||
@ -74,6 +76,7 @@ const NoteList = (props: Props) => {
|
|||||||
props.uncompletedTodosOnTop,
|
props.uncompletedTodosOnTop,
|
||||||
props.showCompletedTodos,
|
props.showCompletedTodos,
|
||||||
props.notes,
|
props.notes,
|
||||||
|
props.selectedFolderInTrash,
|
||||||
);
|
);
|
||||||
|
|
||||||
const noteItemStyle = useMemo(() => {
|
const noteItemStyle = useMemo(() => {
|
||||||
@ -136,6 +139,7 @@ const NoteList = (props: Props) => {
|
|||||||
props.showCompletedTodos,
|
props.showCompletedTodos,
|
||||||
listRenderer.flow,
|
listRenderer.flow,
|
||||||
itemsPerLine,
|
itemsPerLine,
|
||||||
|
props.selectedFolderInTrash,
|
||||||
);
|
);
|
||||||
|
|
||||||
const previousSelectedNoteIds = usePrevious(props.selectedNoteIds, []);
|
const previousSelectedNoteIds = usePrevious(props.selectedNoteIds, []);
|
||||||
@ -264,7 +268,7 @@ const NoteList = (props: Props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const mapStateToProps = (state: AppState) => {
|
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'];
|
const userId = state.settings['sync.userId'];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -287,6 +291,7 @@ const mapStateToProps = (state: AppState) => {
|
|||||||
customCss: state.customCss,
|
customCss: state.customCss,
|
||||||
focusedField: state.focusedField,
|
focusedField: state.focusedField,
|
||||||
parentFolderIsReadOnly: state.notesParentType === 'Folder' && selectedFolder ? itemIsReadOnlySync(ModelType.Folder, ItemChange.SOURCE_UNSPECIFIED, selectedFolder as ItemSlice, userId, state.shareService) : false,
|
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 Setting from '@joplin/lib/models/Setting';
|
||||||
import bridge from '../../../services/bridge';
|
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 (notesParentType !== 'Folder') return false;
|
||||||
|
if (selectedFolderInTrash) return false;
|
||||||
|
|
||||||
if (noteSortOrder !== 'order') {
|
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')), {
|
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;
|
focusedField: string;
|
||||||
parentFolderIsReadOnly: boolean;
|
parentFolderIsReadOnly: boolean;
|
||||||
listRenderer: ListRenderer;
|
listRenderer: ListRenderer;
|
||||||
|
selectedFolderInTrash: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum BaseBreakpoint {
|
export enum BaseBreakpoint {
|
||||||
|
@ -18,6 +18,7 @@ const useDragAndDrop = (
|
|||||||
showCompletedTodos: boolean,
|
showCompletedTodos: boolean,
|
||||||
flow: ItemFlow,
|
flow: ItemFlow,
|
||||||
itemsPerLine: number,
|
itemsPerLine: number,
|
||||||
|
selectedFolderInTrash: boolean,
|
||||||
) => {
|
) => {
|
||||||
const [dragOverTargetNoteIndex, setDragOverTargetNoteIndex] = useState(null);
|
const [dragOverTargetNoteIndex, setDragOverTargetNoteIndex] = useState(null);
|
||||||
|
|
||||||
@ -72,6 +73,7 @@ const useDragAndDrop = (
|
|||||||
|
|
||||||
const onDragOver: DragEventHandler = useCallback(event => {
|
const onDragOver: DragEventHandler = useCallback(event => {
|
||||||
if (notesParentType !== 'Folder') return;
|
if (notesParentType !== 'Folder') return;
|
||||||
|
if (selectedFolderInTrash) return;
|
||||||
|
|
||||||
const dt = event.dataTransfer;
|
const dt = event.dataTransfer;
|
||||||
|
|
||||||
@ -81,11 +83,11 @@ const useDragAndDrop = (
|
|||||||
if (dragOverTargetNoteIndex === newIndex) return;
|
if (dragOverTargetNoteIndex === newIndex) return;
|
||||||
setDragOverTargetNoteIndex(newIndex);
|
setDragOverTargetNoteIndex(newIndex);
|
||||||
}
|
}
|
||||||
}, [notesParentType, dragTargetNoteIndex, dragOverTargetNoteIndex]);
|
}, [notesParentType, dragTargetNoteIndex, dragOverTargetNoteIndex, selectedFolderInTrash]);
|
||||||
|
|
||||||
const onDrop: DragEventHandler = useCallback(async (event: any) => {
|
const onDrop: DragEventHandler = useCallback(async (event: any) => {
|
||||||
// TODO: check that parent type is folder
|
// TODO: check that parent type is folder
|
||||||
if (!canManuallySortNotes(notesParentType, noteSortOrder)) return;
|
if (!canManuallySortNotes(notesParentType, noteSortOrder, selectedFolderInTrash)) return;
|
||||||
|
|
||||||
const dt = event.dataTransfer;
|
const dt = event.dataTransfer;
|
||||||
setDragOverTargetNoteIndex(null);
|
setDragOverTargetNoteIndex(null);
|
||||||
@ -94,7 +96,7 @@ const useDragAndDrop = (
|
|||||||
const noteIds: string[] = JSON.parse(dt.getData('text/x-jop-note-ids'));
|
const noteIds: string[] = JSON.parse(dt.getData('text/x-jop-note-ids'));
|
||||||
|
|
||||||
await Note.insertNotesAt(selectedFolderId, noteIds, targetNoteIndex, uncompletedTodosOnTop, showCompletedTodos);
|
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 };
|
return { onDragStart, onDragOver, onDrop, dragOverTargetNoteIndex };
|
||||||
};
|
};
|
||||||
|
@ -4,9 +4,9 @@ import { NoteEntity } from '@joplin/lib/services/database/types';
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import canManuallySortNotes from './canManuallySortNotes';
|
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) => {
|
const moveNote = useCallback((direction: number, inc: number) => {
|
||||||
if (!canManuallySortNotes(notesParentType, noteSortOrder)) return;
|
if (!canManuallySortNotes(notesParentType, noteSortOrder, selectedFolderInTrash)) return;
|
||||||
|
|
||||||
const noteId = selectedNoteIds[0];
|
const noteId = selectedNoteIds[0];
|
||||||
let targetNoteIndex = BaseModel.modelIndexById(notes, noteId);
|
let targetNoteIndex = BaseModel.modelIndexById(notes, noteId);
|
||||||
@ -17,7 +17,7 @@ const useMoveNote = (notesParentType: string, noteSortOrder: string, selectedNot
|
|||||||
targetNoteIndex -= inc;
|
targetNoteIndex -= inc;
|
||||||
}
|
}
|
||||||
void Note.insertNotesAt(selectedFolderId, selectedNoteIds, targetNoteIndex, uncompletedTodosOnTop, showCompletedTodos);
|
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;
|
return moveNote;
|
||||||
};
|
};
|
||||||
|
@ -106,7 +106,9 @@ const useOnKeyDown = (
|
|||||||
|
|
||||||
if (noteIds.length && (key === 'Delete' || (key === 'Backspace' && event.metaKey))) {
|
if (noteIds.length && (key === 'Delete' || (key === 'Backspace' && event.metaKey))) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void CommandService.instance().execute('deleteNote', noteIds);
|
if (CommandService.instance().isEnabled('deleteNote')) {
|
||||||
|
void CommandService.instance().execute('deleteNote', noteIds);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (noteIds.length && key === ' ') {
|
if (noteIds.length && key === ' ') {
|
||||||
|
@ -11,6 +11,7 @@ import { _ } from '@joplin/lib/locale';
|
|||||||
const { connect } = require('react-redux');
|
const { connect } = require('react-redux');
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext';
|
import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext';
|
||||||
|
import { getTrashFolderId } from '@joplin/lib/services/trash';
|
||||||
import { Breakpoints } from '../NoteList/utils/types';
|
import { Breakpoints } from '../NoteList/utils/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -265,7 +266,7 @@ const mapStateToProps = (state: AppState) => {
|
|||||||
const whenClauseContext = stateToWhenClauseContext(state);
|
const whenClauseContext = stateToWhenClauseContext(state);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
showNewNoteButtons: true,
|
showNewNoteButtons: state.selectedFolderId !== getTrashFolderId(),
|
||||||
newNoteButtonEnabled: CommandService.instance().isEnabled('newNote', whenClauseContext),
|
newNoteButtonEnabled: CommandService.instance().isEnabled('newNote', whenClauseContext),
|
||||||
newTodoButtonEnabled: CommandService.instance().isEnabled('newTodo', whenClauseContext),
|
newTodoButtonEnabled: CommandService.instance().isEnabled('newTodo', whenClauseContext),
|
||||||
sortOrderButtonsVisible: state.settings['notes.sortOrder.buttonsVisible'],
|
sortOrderButtonsVisible: state.settings['notes.sortOrder.buttonsVisible'],
|
||||||
|
@ -10,6 +10,7 @@ import Logger from '@joplin/utils/Logger';
|
|||||||
import { _ } from '@joplin/lib/locale';
|
import { _ } from '@joplin/lib/locale';
|
||||||
import { BaseBreakpoint, Breakpoints } from '../NoteList/utils/types';
|
import { BaseBreakpoint, Breakpoints } from '../NoteList/utils/types';
|
||||||
import { ButtonSize, buttonSizePx } from '../Button/Button';
|
import { ButtonSize, buttonSizePx } from '../Button/Button';
|
||||||
|
import { getTrashFolderId } from '@joplin/lib/services/trash';
|
||||||
|
|
||||||
const logger = Logger.create('NoteListWrapper');
|
const logger = Logger.create('NoteListWrapper');
|
||||||
|
|
||||||
@ -20,6 +21,7 @@ interface Props {
|
|||||||
themeId: number;
|
themeId: number;
|
||||||
listRendererId: string;
|
listRendererId: string;
|
||||||
startupPluginsLoaded: boolean;
|
startupPluginsLoaded: boolean;
|
||||||
|
selectedFolderId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StyledRoot = styled.div`
|
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
|
// 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.
|
// 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 [dynamicBreakpoints, setDynamicBreakpoints] = useState<Breakpoints>({ Sm: BaseBreakpoint.Sm, Md: BaseBreakpoint.Md, Lg: BaseBreakpoint.Lg, Xl: BaseBreakpoint.Xl });
|
||||||
|
|
||||||
const getTextWidth = useCallback((text: string): number => {
|
const getTextWidth = useCallback((text: string): number => {
|
||||||
@ -47,9 +49,12 @@ const useNoteListControlsBreakpoints = (width: number, newNoteRef: React.Mutable
|
|||||||
return ctx.measureText(text).width;
|
return ctx.measureText(text).width;
|
||||||
}, [newNoteRef]);
|
}, [newNoteRef]);
|
||||||
|
|
||||||
|
const showNewNoteButton = selectedFolderId !== getTrashFolderId();
|
||||||
|
|
||||||
// Initialize language-specific breakpoints
|
// Initialize language-specific breakpoints
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!newNoteRef.current) return;
|
if (!newNoteRef.current) return;
|
||||||
|
if (showNewNoteButton) return;
|
||||||
|
|
||||||
// Use the longest string to calculate the amount of extra width needed
|
// Use the longest string to calculate the amount of extra width needed
|
||||||
const smAdditional = getTextWidth(_('note')) > getTextWidth(_('to-do')) ? getTextWidth(_('note')) : getTextWidth(_('to-do'));
|
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;
|
const Xl = BaseBreakpoint.Xl;
|
||||||
|
|
||||||
setDynamicBreakpoints({ Sm, Md, Lg, Xl });
|
setDynamicBreakpoints({ Sm, Md, Lg, Xl });
|
||||||
}, [newNoteRef, getTextWidth]);
|
}, [newNoteRef, getTextWidth, showNewNoteButton]);
|
||||||
|
|
||||||
const breakpoint: number = useMemo(() => {
|
const breakpoint: number = useMemo(() => {
|
||||||
// Find largest breakpoint that width is less than
|
// 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 listRenderer = useListRenderer(props.listRendererId, props.startupPluginsLoaded);
|
||||||
const newNoteButtonRef = useRef(null);
|
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 noteListControlsButtonSize = ButtonSize.Small;
|
||||||
const noteListControlsPadding = theme.mainPadding;
|
const noteListControlsPadding = theme.mainPadding;
|
||||||
|
@ -20,9 +20,21 @@ interface Props {
|
|||||||
themeId: number;
|
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 {
|
interface State {
|
||||||
editedKey: string;
|
editedKey: string;
|
||||||
formNote: any;
|
formNote: FormNote;
|
||||||
editedValue: any;
|
editedValue: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,6 +62,7 @@ class NotePropertiesDialog extends React.Component<Props, State> {
|
|||||||
id: _('ID'),
|
id: _('ID'),
|
||||||
user_created_time: _('Created'),
|
user_created_time: _('Created'),
|
||||||
user_updated_time: _('Updated'),
|
user_updated_time: _('Updated'),
|
||||||
|
deleted_time: _('Deleted'),
|
||||||
todo_completed: _('Completed'),
|
todo_completed: _('Completed'),
|
||||||
location: _('Location'),
|
location: _('Location'),
|
||||||
source_url: _('URL'),
|
source_url: _('URL'),
|
||||||
@ -64,7 +77,7 @@ class NotePropertiesDialog extends React.Component<Props, State> {
|
|||||||
|
|
||||||
public componentDidUpdate() {
|
public componentDidUpdate() {
|
||||||
if (this.state.editedKey === null) {
|
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) {
|
public latLongFromLocation(location: string) {
|
||||||
const o: any = {};
|
const o: any = {};
|
||||||
const l = location.split(',');
|
const l = location.split(',');
|
||||||
@ -92,36 +109,35 @@ class NotePropertiesDialog extends React.Component<Props, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public noteToFormNote(note: NoteEntity) {
|
public noteToFormNote(note: NoteEntity) {
|
||||||
const formNote: any = {};
|
const formNote: FormNote = {
|
||||||
|
id: note.id,
|
||||||
formNote.user_updated_time = time.formatMsToLocal(note.user_updated_time);
|
user_updated_time: time.formatMsToLocal(note.user_updated_time),
|
||||||
formNote.user_created_time = time.formatMsToLocal(note.user_created_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) {
|
if (note.todo_completed) {
|
||||||
formNote.todo_completed = time.formatMsToLocal(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)) {
|
if (Number(note.latitude) || Number(note.longitude)) {
|
||||||
formNote.location = `${note.latitude}, ${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;
|
return formNote;
|
||||||
}
|
}
|
||||||
|
|
||||||
public formNoteToNote(formNote: any) {
|
public formNoteToNote(formNote: FormNote) {
|
||||||
const note = { id: formNote.id, ...this.latLongFromLocation(formNote.location) };
|
const note: NoteEntity = { id: formNote.id, ...this.latLongFromLocation(formNote.location) };
|
||||||
note.user_created_time = time.formatLocalToMs(formNote.user_created_time);
|
note.user_created_time = time.formatLocalToMs(formNote.user_created_time);
|
||||||
note.user_updated_time = time.formatLocalToMs(formNote.user_updated_time);
|
note.user_updated_time = time.formatLocalToMs(formNote.user_updated_time);
|
||||||
|
|
||||||
if (formNote.todo_completed) {
|
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;
|
note.source_url = formNote.source_url;
|
||||||
@ -218,9 +234,9 @@ class NotePropertiesDialog extends React.Component<Props, State> {
|
|||||||
|
|
||||||
if (this.state.editedKey.indexOf('_time') >= 0) {
|
if (this.state.editedKey.indexOf('_time') >= 0) {
|
||||||
const dt = time.anythingToDateTime(this.state.editedValue, new Date());
|
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 {
|
} else {
|
||||||
newFormNote[this.state.editedKey] = this.state.editedValue;
|
(newFormNote as any)[this.state.editedKey] = this.state.editedValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState(
|
this.setState(
|
||||||
@ -239,7 +255,7 @@ class NotePropertiesDialog extends React.Component<Props, State> {
|
|||||||
public async cancelProperty() {
|
public async cancelProperty() {
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||||
return new Promise((resolve: Function) => {
|
return new Promise((resolve: Function) => {
|
||||||
this.okButton.current.focus();
|
if (this.okButton.current) this.okButton.current.focus();
|
||||||
this.setState({
|
this.setState({
|
||||||
editedKey: null,
|
editedKey: null,
|
||||||
editedValue: 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 styles = this.styles(this.props.themeId);
|
||||||
const theme = themeStyle(this.props.themeId);
|
const theme = themeStyle(this.props.themeId);
|
||||||
const labelComp = <label style={{ ...theme.textStyle, ...theme.controlBoxLabel }}>{this.formatLabel(key)}</label>;
|
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 = (
|
editComp = (
|
||||||
<a href="#" onClick={editCompHandler} style={styles.editPropertyButton}>
|
<a href="#" onClick={editCompHandler} style={styles.editPropertyButton}>
|
||||||
<i className={`fas ${editCompIcon}`} aria-hidden="true"></i>
|
<i className={`fas ${editCompIcon}`} aria-hidden="true"></i>
|
||||||
@ -394,9 +410,9 @@ class NotePropertiesDialog extends React.Component<Props, State> {
|
|||||||
const noteComps = [];
|
const noteComps = [];
|
||||||
|
|
||||||
if (formNote) {
|
if (formNote) {
|
||||||
for (const key in formNote) {
|
for (const key of Object.keys(formNote)) {
|
||||||
if (!formNote.hasOwnProperty(key)) continue;
|
if (key === 'deleted_time' && !formNote.deleted_time) continue;
|
||||||
const comp = this.createNoteField(key, formNote[key]);
|
const comp = this.createNoteField(key as (keyof FormNote), (formNote as any)[key]);
|
||||||
noteComps.push(comp);
|
noteComps.push(comp);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -406,7 +422,7 @@ class NotePropertiesDialog extends React.Component<Props, State> {
|
|||||||
<div style={theme.dialogBox}>
|
<div style={theme.dialogBox}>
|
||||||
<div style={theme.dialogTitle}>{_('Note properties')}</div>
|
<div style={theme.dialogTitle}>{_('Note properties')}</div>
|
||||||
<div>{noteComps}</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>
|
||||||
</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 { ModelType } from '@joplin/lib/BaseModel';
|
||||||
import BaseModel from '@joplin/lib/BaseModel';
|
import BaseModel from '@joplin/lib/BaseModel';
|
||||||
import Folder from '@joplin/lib/models/Folder';
|
import Folder from '@joplin/lib/models/Folder';
|
||||||
import Note from '@joplin/lib/models/Note';
|
|
||||||
import Tag from '@joplin/lib/models/Tag';
|
import Tag from '@joplin/lib/models/Tag';
|
||||||
import Logger from '@joplin/utils/Logger';
|
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 stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext';
|
||||||
import { store } from '@joplin/lib/reducer';
|
import { store } from '@joplin/lib/reducer';
|
||||||
import PerFolderSortOrderService from '../../services/sortOrder/PerFolderSortOrderService';
|
import PerFolderSortOrderService from '../../services/sortOrder/PerFolderSortOrderService';
|
||||||
import { getFolderCallbackUrl, getTagCallbackUrl } from '@joplin/lib/callbackUrlUtils';
|
import { getFolderCallbackUrl, getTagCallbackUrl } from '@joplin/lib/callbackUrlUtils';
|
||||||
import FolderIconBox from '../FolderIconBox';
|
import FolderIconBox from '../FolderIconBox';
|
||||||
|
import onFolderDrop from '@joplin/lib/models/utils/onFolderDrop';
|
||||||
import { Theme } from '@joplin/lib/themes/type';
|
import { Theme } from '@joplin/lib/themes/type';
|
||||||
import { RuntimeProps } from './commands/focusElementSideBar';
|
import { RuntimeProps } from './commands/focusElementSideBar';
|
||||||
const { connect } = require('react-redux');
|
const { connect } = require('react-redux');
|
||||||
import { renderFolders, renderTags } from '@joplin/lib/components/shared/side-menu-shared';
|
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 { themeStyle } = require('@joplin/lib/theme');
|
||||||
const bridge = require('@electron/remote').require('./bridge').default;
|
const bridge = require('@electron/remote').require('./bridge').default;
|
||||||
const Menu = bridge().Menu;
|
const Menu = bridge().Menu;
|
||||||
@ -42,7 +43,7 @@ interface Props {
|
|||||||
themeId: number;
|
themeId: number;
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||||
dispatch: Function;
|
dispatch: Function;
|
||||||
folders: any[];
|
folders: FolderEntity[];
|
||||||
collapsedFolderIds: string[];
|
collapsedFolderIds: string[];
|
||||||
notesParentType: string;
|
notesParentType: string;
|
||||||
selectedFolderId: string;
|
selectedFolderId: string;
|
||||||
@ -51,7 +52,7 @@ interface Props {
|
|||||||
decryptionWorker: any;
|
decryptionWorker: any;
|
||||||
resourceFetcher: any;
|
resourceFetcher: any;
|
||||||
syncReport: any;
|
syncReport: any;
|
||||||
tags: any[];
|
tags: TagEntity[];
|
||||||
syncStarted: boolean;
|
syncStarted: boolean;
|
||||||
plugins: PluginStates;
|
plugins: PluginStates;
|
||||||
folderHeaderIsExpanded: boolean;
|
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 { 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 noteCountComp = noteCount ? <StyledNoteCount className="note-count-label">{noteCount}</StyledNoteCount> : null;
|
||||||
|
|
||||||
const shareIcon = shareId && !parentId ? <StyledShareIcon className="fas fa-share-alt"></StyledShareIcon> : 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 (
|
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}/>
|
<ExpandLink themeId={props.themeId} hasChildren={hasChildren} folderId={folderId} onClick={onFolderToggleClick_} isExpanded={isExpanded}/>
|
||||||
<StyledListItemAnchor
|
<StyledListItemAnchor
|
||||||
ref={anchorRef}
|
ref={anchorRef}
|
||||||
@ -119,7 +129,7 @@ function FolderItem(props: any) {
|
|||||||
}}
|
}}
|
||||||
onDoubleClick={onFolderToggleClick_}
|
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}
|
{shareIcon} {noteCountComp}
|
||||||
</StyledListItemAnchor>
|
</StyledListItemAnchor>
|
||||||
</StyledListItem>
|
</StyledListItem>
|
||||||
@ -220,20 +230,13 @@ const SidebarComponent = (props: Props) => {
|
|||||||
try {
|
try {
|
||||||
if (dt.types.indexOf('text/x-jop-note-ids') >= 0) {
|
if (dt.types.indexOf('text/x-jop-note-ids') >= 0) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
if (!folderId) return;
|
if (!folderId) return;
|
||||||
|
|
||||||
const noteIds = JSON.parse(dt.getData('text/x-jop-note-ids'));
|
const noteIds = JSON.parse(dt.getData('text/x-jop-note-ids'));
|
||||||
for (let i = 0; i < noteIds.length; i++) {
|
await onFolderDrop(noteIds, [], folderId);
|
||||||
await Note.moveToFolder(noteIds[i], folderId);
|
|
||||||
}
|
|
||||||
} else if (dt.types.indexOf('text/x-jop-folder-ids') >= 0) {
|
} else if (dt.types.indexOf('text/x-jop-folder-ids') >= 0) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const folderIds = JSON.parse(dt.getData('text/x-jop-folder-ids'));
|
const folderIds = JSON.parse(dt.getData('text/x-jop-folder-ids'));
|
||||||
for (let i = 0; i < folderIds.length; i++) {
|
await onFolderDrop([], folderIds, folderId);
|
||||||
await Folder.moveToFolder(folderIds[i], folderId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
@ -296,131 +299,149 @@ const SidebarComponent = (props: Props) => {
|
|||||||
|
|
||||||
const menu = new Menu();
|
const menu = new Menu();
|
||||||
|
|
||||||
|
if (itemId === getTrashFolderId()) {
|
||||||
|
menu.append(
|
||||||
|
new MenuItem(menuUtils.commandToStatefulMenuItem('emptyTrash')),
|
||||||
|
);
|
||||||
|
menu.popup({ window: bridge().window() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let item = null;
|
let item = null;
|
||||||
if (itemType === BaseModel.TYPE_FOLDER) {
|
if (itemType === BaseModel.TYPE_FOLDER) {
|
||||||
item = BaseModel.byId(props.folders, itemId);
|
item = BaseModel.byId(props.folders, itemId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied) {
|
const isDeleted = item ? !!item.deleted_time : false;
|
||||||
menu.append(
|
|
||||||
new MenuItem(menuUtils.commandToStatefulMenuItem('newFolder', itemId)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (itemType === BaseModel.TYPE_FOLDER) {
|
if (!isDeleted) {
|
||||||
menu.append(
|
if (itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied) {
|
||||||
new MenuItem(menuUtils.commandToStatefulMenuItem('deleteFolder', itemId)),
|
menu.append(
|
||||||
);
|
new MenuItem(menuUtils.commandToStatefulMenuItem('newFolder', itemId)),
|
||||||
} else {
|
);
|
||||||
menu.append(
|
}
|
||||||
new MenuItem({
|
|
||||||
label: deleteButtonLabel,
|
|
||||||
click: async () => {
|
|
||||||
const ok = bridge().showConfirmMessageBox(deleteMessage, {
|
|
||||||
buttons: [deleteButtonLabel, _('Cancel')],
|
|
||||||
defaultId: 1,
|
|
||||||
});
|
|
||||||
if (!ok) return;
|
|
||||||
|
|
||||||
if (itemType === BaseModel.TYPE_TAG) {
|
if (itemType === BaseModel.TYPE_FOLDER) {
|
||||||
await Tag.untagAll(itemId);
|
menu.append(
|
||||||
} else if (itemType === BaseModel.TYPE_SEARCH) {
|
new MenuItem(menuUtils.commandToStatefulMenuItem('deleteFolder', itemId)),
|
||||||
props.dispatch({
|
);
|
||||||
type: 'SEARCH_DELETE',
|
} else {
|
||||||
id: itemId,
|
menu.append(
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
|
||||||
new MenuItem({
|
new MenuItem({
|
||||||
label: module.fullLabel(),
|
label: deleteButtonLabel,
|
||||||
click: async () => {
|
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
|
if (itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied) {
|
||||||
// that are within a shared notebook. If user wants to do this,
|
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('openFolderDialog', { folderId: itemId })));
|
||||||
// they'd have to move the notebook out of the shared notebook
|
|
||||||
// first.
|
|
||||||
const whenClause = stateToWhenClauseContext(state, { commandFolderId: itemId });
|
|
||||||
|
|
||||||
if (CommandService.instance().isEnabled('showShareFolderDialog', whenClause)) {
|
menu.append(new MenuItem({ type: 'separator' }));
|
||||||
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('showShareFolderDialog', itemId)));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (CommandService.instance().isEnabled('leaveSharedFolder', whenClause)) {
|
const exportMenu = new Menu();
|
||||||
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('leaveSharedFolder', itemId)));
|
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(
|
exportMenu.append(
|
||||||
new MenuItem({
|
new MenuItem({
|
||||||
label: _('Export'),
|
label: module.fullLabel(),
|
||||||
submenu: exportMenu,
|
click: async () => {
|
||||||
}),
|
await InteropServiceHelper.export(props.dispatch, module, { sourceFolderIds: [itemId], plugins: pluginsRef.current });
|
||||||
);
|
},
|
||||||
if (Setting.value('notes.perFolderSortOrderEnabled')) {
|
}),
|
||||||
menu.append(new MenuItem({
|
);
|
||||||
...menuUtils.commandToStatefulMenuItem('togglePerFolderSortOrder', itemId),
|
}
|
||||||
type: 'checkbox',
|
|
||||||
checked: PerFolderSortOrderService.isSet(itemId),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (itemType === BaseModel.TYPE_FOLDER) {
|
// We don't display the "Share notebook" menu item for sub-notebooks
|
||||||
menu.append(
|
// that are within a shared notebook. If user wants to do this,
|
||||||
new MenuItem({
|
// they'd have to move the notebook out of the shared notebook
|
||||||
label: _('Copy external link'),
|
// first.
|
||||||
click: () => {
|
const whenClause = stateToWhenClauseContext(state, { commandFolderId: itemId });
|
||||||
clipboard.writeText(getFolderCallbackUrl(itemId));
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (itemType === BaseModel.TYPE_TAG) {
|
if (CommandService.instance().isEnabled('showShareFolderDialog', whenClause)) {
|
||||||
menu.append(new MenuItem(
|
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('showShareFolderDialog', itemId)));
|
||||||
menuUtils.commandToStatefulMenuItem('renameTag', itemId),
|
}
|
||||||
));
|
|
||||||
menu.append(
|
|
||||||
new MenuItem({
|
|
||||||
label: _('Copy external link'),
|
|
||||||
click: () => {
|
|
||||||
clipboard.writeText(getTagCallbackUrl(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(
|
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;
|
const isExpanded = props.collapsedFolderIds.indexOf(folder.id) < 0;
|
||||||
let noteCount = (folder as any).note_count;
|
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.
|
// Thunderbird count: Subtract children note_count from parent folder if it expanded.
|
||||||
if (isExpanded) {
|
if (isExpanded) {
|
||||||
for (let i = 0; i < props.folders.length; i++) {
|
for (let i = 0; i < props.folders.length; i++) {
|
||||||
if (props.folders[i].parent_id === folder.id) {
|
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',
|
'setTags',
|
||||||
'showLocalSearch',
|
'showLocalSearch',
|
||||||
'showNoteContentProperties',
|
'showNoteContentProperties',
|
||||||
|
'permanentlyDeleteNote',
|
||||||
'synchronize',
|
'synchronize',
|
||||||
'textBold',
|
'textBold',
|
||||||
'textCode',
|
'textCode',
|
||||||
|
@ -13,12 +13,13 @@ import Note from '@joplin/lib/models/Note';
|
|||||||
import Setting from '@joplin/lib/models/Setting';
|
import Setting from '@joplin/lib/models/Setting';
|
||||||
const { clipboard } = require('electron');
|
const { clipboard } = require('electron');
|
||||||
import { Dispatch } from 'redux';
|
import { Dispatch } from 'redux';
|
||||||
|
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||||
|
|
||||||
const Menu = bridge().Menu;
|
const Menu = bridge().Menu;
|
||||||
const MenuItem = bridge().MenuItem;
|
const MenuItem = bridge().MenuItem;
|
||||||
|
|
||||||
interface ContextMenuProps {
|
interface ContextMenuProps {
|
||||||
notes: any[];
|
notes: NoteEntity[];
|
||||||
dispatch: Dispatch;
|
dispatch: Dispatch;
|
||||||
watchedNoteFiles: string[];
|
watchedNoteFiles: string[];
|
||||||
plugins: PluginStates;
|
plugins: PluginStates;
|
||||||
@ -32,18 +33,16 @@ export default class NoteListUtils {
|
|||||||
|
|
||||||
const menuUtils = new MenuUtils(cmdService);
|
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;
|
const singleNoteId = noteIds.length === 1 ? noteIds[0] : null;
|
||||||
|
|
||||||
let hasEncrypted = false;
|
const includeDeletedNotes = notes.find(n => !!n.deleted_time);
|
||||||
for (let i = 0; i < notes.length; i++) {
|
const includeEncryptedNotes = notes.find(n => !!n.encryption_applied);
|
||||||
if (notes[i].encryption_applied) hasEncrypted = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const menu = new Menu();
|
const menu = new Menu();
|
||||||
|
|
||||||
if (!hasEncrypted) {
|
if (!includeEncryptedNotes && !includeDeletedNotes) {
|
||||||
menu.append(
|
menu.append(
|
||||||
new MenuItem(menuUtils.commandToStatefulMenuItem('setTags', noteIds) as any),
|
new MenuItem(menuUtils.commandToStatefulMenuItem('setTags', noteIds) as any),
|
||||||
);
|
);
|
||||||
@ -165,11 +164,25 @@ export default class NoteListUtils {
|
|||||||
menu.append(exportMenuItem);
|
menu.append(exportMenuItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
menu.append(
|
if (includeDeletedNotes) {
|
||||||
new MenuItem(
|
menu.append(
|
||||||
menuUtils.commandToStatefulMenuItem('deleteNote', noteIds) as any,
|
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');
|
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/smalltalk/css/smalltalk.css">
|
||||||
<link rel="stylesheet" href="vendor/lib/roboto-fontface/css/roboto/roboto-fontface.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="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>
|
<script src="./node_modules/tesseract.js/dist/tesseract.min.js"></script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@ -50,5 +54,7 @@
|
|||||||
-webkit-user-drag: none;
|
-webkit-user-drag: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<script src="./node_modules/notyf/notyf.min.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -173,6 +173,7 @@
|
|||||||
"node-fetch": "2.6.7",
|
"node-fetch": "2.6.7",
|
||||||
"node-notifier": "10.0.1",
|
"node-notifier": "10.0.1",
|
||||||
"node-rsa": "1.1.1",
|
"node-rsa": "1.1.1",
|
||||||
|
"notyf": "3.10.0",
|
||||||
"pdfjs-dist": "3.11.174",
|
"pdfjs-dist": "3.11.174",
|
||||||
"pretty-bytes": "5.6.0",
|
"pretty-bytes": "5.6.0",
|
||||||
"re-resizable": "6.9.11",
|
"re-resizable": "6.9.11",
|
||||||
|
@ -6,4 +6,5 @@
|
|||||||
@use 'gui/Dropdown/style.scss' as dropdown-control;
|
@use 'gui/Dropdown/style.scss' as dropdown-control;
|
||||||
@use 'gui/ShareFolderDialog/style.scss' as share-folder-dialog;
|
@use 'gui/ShareFolderDialog/style.scss' as share-folder-dialog;
|
||||||
@use 'gui/NoteList/style.scss' as note-list;
|
@use 'gui/NoteList/style.scss' as note-list;
|
||||||
|
@use 'gui/TrashNotification/style.scss' as trash-notification;
|
||||||
@use 'main.scss' as main;
|
@use 'main.scss' as main;
|
@ -21,6 +21,9 @@ import { FolderEntity } from '@joplin/lib/services/database/types';
|
|||||||
import { State } from '@joplin/lib/reducer';
|
import { State } from '@joplin/lib/reducer';
|
||||||
import CustomButton from './CustomButton';
|
import CustomButton from './CustomButton';
|
||||||
import FolderPicker from './FolderPicker';
|
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
|
// We need this to suppress the useless warning
|
||||||
// https://github.com/oblador/react-native-vector-icons/issues/1465
|
// https://github.com/oblador/react-native-vector-icons/issues/1465
|
||||||
@ -50,6 +53,8 @@ export interface MenuOptionType {
|
|||||||
type DispatchCommandType=(event: { type: string })=> void;
|
type DispatchCommandType=(event: { type: string })=> void;
|
||||||
interface ScreenHeaderProps {
|
interface ScreenHeaderProps {
|
||||||
selectedNoteIds: string[];
|
selectedNoteIds: string[];
|
||||||
|
selectedFolderId: string;
|
||||||
|
notesParentType: string;
|
||||||
noteSelectionEnabled: boolean;
|
noteSelectionEnabled: boolean;
|
||||||
parentComponent: any;
|
parentComponent: any;
|
||||||
showUndoButton: boolean;
|
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
|
// Dialog needs to be displayed as a child of the parent component, otherwise
|
||||||
// it won't be visible within the header component.
|
// it won't be visible within the header component.
|
||||||
const noteIds = this.props.selectedNoteIds;
|
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' });
|
this.props.dispatch({ type: 'NOTE_SELECTION_END' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Note.batchDelete(noteIds);
|
await Note.batchDelete(noteIds, { toTrash: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert(_n('This note could not be deleted: %s', 'These notes could not be deleted: %s', noteIds.length, error.message));
|
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) {
|
private menu_select(value: OnSelectCallbackType) {
|
||||||
if (typeof value === 'function') {
|
if (typeof value === 'function') {
|
||||||
value();
|
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) {
|
function duplicateButton(styles: any, onPress: OnPressCallback, disabled: boolean) {
|
||||||
return (
|
return (
|
||||||
<CustomButton
|
<CustomButton
|
||||||
@ -485,6 +514,9 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
|
|||||||
let key = 0;
|
let key = 0;
|
||||||
const menuOptionComponents = [];
|
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) {
|
if (!this.props.noteSelectionEnabled) {
|
||||||
for (let i = 0; i < this.props.menuOptions.length; i++) {
|
for (let i = 0; i < this.props.menuOptions.length; i++) {
|
||||||
const o = this.props.menuOptions[i];
|
const o = this.props.menuOptions[i];
|
||||||
@ -556,7 +588,7 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
mustSelect={!!folderPickerOptions.mustSelect}
|
mustSelect={!!folderPickerOptions.mustSelect}
|
||||||
folders={this.props.folders}
|
folders={this.props.folders.filter(f => f.id !== getTrashFolderId())}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@ -591,8 +623,9 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
|
|||||||
const backButtonComp = !showBackButton ? null : backButton(this.styles(), () => this.backButton_press(), backButtonDisabled);
|
const backButtonComp = !showBackButton ? null : backButton(this.styles(), () => this.backButton_press(), backButtonDisabled);
|
||||||
const selectAllButtonComp = !showSelectAllButton ? null : selectAllButton(this.styles(), () => this.selectAllButton_press());
|
const selectAllButtonComp = !showSelectAllButton ? null : selectAllButton(this.styles(), () => this.selectAllButton_press());
|
||||||
const searchButtonComp = !showSearchButton ? null : searchButton(this.styles(), () => this.searchButton_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 deleteButtonComp = !selectedFolderInTrash && 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 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 sortButtonComp = !this.props.noteSelectionEnabled && this.props.sortButton_press ? sortButton(this.styles(), () => this.props.sortButton_press()) : null;
|
||||||
const windowHeight = Dimensions.get('window').height - 50;
|
const windowHeight = Dimensions.get('window').height - 50;
|
||||||
|
|
||||||
@ -637,6 +670,7 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
|
|||||||
{selectAllButtonComp}
|
{selectAllButtonComp}
|
||||||
{searchButtonComp}
|
{searchButtonComp}
|
||||||
{deleteButtonComp}
|
{deleteButtonComp}
|
||||||
|
{restoreButtonComp}
|
||||||
{duplicateButtonComp}
|
{duplicateButtonComp}
|
||||||
{sortButtonComp}
|
{sortButtonComp}
|
||||||
{menuComp}
|
{menuComp}
|
||||||
@ -667,6 +701,8 @@ const ScreenHeader = connect((state: State) => {
|
|||||||
hasDisabledEncryptionItems: state.hasDisabledEncryptionItems,
|
hasDisabledEncryptionItems: state.hasDisabledEncryptionItems,
|
||||||
noteSelectionEnabled: state.noteSelectionEnabled,
|
noteSelectionEnabled: state.noteSelectionEnabled,
|
||||||
selectedNoteIds: state.selectedNoteIds,
|
selectedNoteIds: state.selectedNoteIds,
|
||||||
|
selectedFolderId: state.selectedFolderId,
|
||||||
|
notesParentType: state.notesParentType,
|
||||||
showMissingMasterKeyMessage: showMissingMasterKeyMessage(syncInfo, state.notLoadedMasterKeys),
|
showMissingMasterKeyMessage: showMissingMasterKeyMessage(syncInfo, state.notLoadedMasterKeys),
|
||||||
hasDisabledSyncItems: state.hasDisabledSyncItems,
|
hasDisabledSyncItems: state.hasDisabledSyncItems,
|
||||||
shouldUpgradeSyncTarget: state.settings['sync.upgradeState'] === Setting.SYNC_UPGRADE_STATE_SHOULD_DO,
|
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 md5 = require('md5');
|
||||||
const { BackButtonService } = require('../../services/back-button.js');
|
const { BackButtonService } = require('../../services/back-button.js');
|
||||||
import NavService, { OnNavigateCallback as OnNavigateCallback } from '@joplin/lib/services/NavService';
|
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';
|
import ActionButton from '../ActionButton';
|
||||||
const { fileExtension, safeFileExtension } = require('@joplin/lib/path-utils');
|
const { fileExtension, safeFileExtension } = require('@joplin/lib/path-utils');
|
||||||
const mimeUtils = require('@joplin/lib/mime-utils.js').mime;
|
const mimeUtils = require('@joplin/lib/mime-utils.js').mime;
|
||||||
@ -57,6 +57,9 @@ import { join } from 'path';
|
|||||||
import { Dispatch } from 'redux';
|
import { Dispatch } from 'redux';
|
||||||
import { RefObject } from 'react';
|
import { RefObject } from 'react';
|
||||||
import { SelectionRange } from '../NoteEditor/types';
|
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 urlUtils = require('@joplin/lib/urlUtils');
|
||||||
|
|
||||||
const emptyArray: any[] = [];
|
const emptyArray: any[] = [];
|
||||||
@ -134,7 +137,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
note: any;
|
note: NoteEntity;
|
||||||
mode: 'view'|'edit';
|
mode: 'view'|'edit';
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
folder: FolderEntity|null;
|
folder: FolderEntity|null;
|
||||||
@ -677,12 +680,9 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
|
|||||||
const note = this.state.note;
|
const note = this.state.note;
|
||||||
if (!note.id) return;
|
if (!note.id) return;
|
||||||
|
|
||||||
const ok = await dialogs.confirm(this, _('Delete note?'));
|
|
||||||
if (!ok) return;
|
|
||||||
|
|
||||||
const folderId = note.parent_id;
|
const folderId = note.parent_id;
|
||||||
|
|
||||||
await Note.delete(note.id);
|
await Note.delete(note.id, { toTrash: true });
|
||||||
|
|
||||||
this.props.dispatch({
|
this.props.dispatch({
|
||||||
type: 'NAV_GO',
|
type: 'NAV_GO',
|
||||||
@ -1220,6 +1220,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
|
|||||||
const isTodo = note && !!note.is_todo;
|
const isTodo = note && !!note.is_todo;
|
||||||
const isSaved = note && note.id;
|
const isSaved = note && note.id;
|
||||||
const readOnly = this.state.readOnly;
|
const readOnly = this.state.readOnly;
|
||||||
|
const isDeleted = !!this.state.note.deleted_time;
|
||||||
|
|
||||||
const cacheKey = md5([isTodo, isSaved].join('_'));
|
const cacheKey = md5([isTodo, isSaved].join('_'));
|
||||||
if (!this.menuOptionsCache_) this.menuOptionsCache_ = {};
|
if (!this.menuOptionsCache_) this.menuOptionsCache_ = {};
|
||||||
@ -1281,7 +1282,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSaved) {
|
if (isSaved && !isDeleted) {
|
||||||
output.push({
|
output.push({
|
||||||
title: _('Tags'),
|
title: _('Tags'),
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
@ -1289,6 +1290,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
output.push({
|
output.push({
|
||||||
title: isTodo ? _('Convert to note') : _('Convert to todo'),
|
title: isTodo ? _('Convert to note') : _('Convert to todo'),
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
@ -1296,7 +1298,8 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
|
|||||||
},
|
},
|
||||||
disabled: readOnly,
|
disabled: readOnly,
|
||||||
});
|
});
|
||||||
if (isSaved) {
|
|
||||||
|
if (isSaved && !isDeleted) {
|
||||||
output.push({
|
output.push({
|
||||||
title: _('Copy Markdown link'),
|
title: _('Copy Markdown link'),
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
@ -1304,12 +1307,27 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
output.push({
|
output.push({
|
||||||
title: _('Properties'),
|
title: _('Properties'),
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
this.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({
|
output.push({
|
||||||
title: _('Delete'),
|
title: _('Delete'),
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
@ -1550,6 +1568,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
|
|||||||
|
|
||||||
const renderActionButton = () => {
|
const renderActionButton = () => {
|
||||||
if (this.state.voiceTypingDialogShown) return null;
|
if (this.state.voiceTypingDialogShown) return null;
|
||||||
|
if (!this.state.note || !!this.state.note.deleted_time) return null;
|
||||||
|
|
||||||
const editButton = {
|
const editButton = {
|
||||||
label: _('Edit'),
|
label: _('Edit'),
|
||||||
@ -1615,7 +1634,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
|
|||||||
undoButtonDisabled={!this.state.undoRedoButtonState.canUndo && this.state.undoRedoButtonState.canRedo}
|
undoButtonDisabled={!this.state.undoRedoButtonState.canUndo && this.state.undoRedoButtonState.canRedo}
|
||||||
onUndoButtonPress={this.screenHeader_undoButtonPress}
|
onUndoButtonPress={this.screenHeader_undoButtonPress}
|
||||||
onRedoButtonPress={this.screenHeader_redoButtonPress}
|
onRedoButtonPress={this.screenHeader_redoButtonPress}
|
||||||
title={this.state.folder ? this.state.folder.title : ''}
|
title={getDisplayParentTitle(this.state.note, this.state.folder)}
|
||||||
/>
|
/>
|
||||||
{titleComp}
|
{titleComp}
|
||||||
{bodyComponent}
|
{bodyComponent}
|
||||||
@ -1635,7 +1654,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const NoteScreen = connect((state: any) => {
|
const NoteScreen = connect((state: AppState) => {
|
||||||
return {
|
return {
|
||||||
noteId: state.selectedNoteIds.length ? state.selectedNoteIds[0] : null,
|
noteId: state.selectedNoteIds.length ? state.selectedNoteIds[0] : null,
|
||||||
noteHash: state.selectedNoteHash,
|
noteHash: state.selectedNoteHash,
|
||||||
|
@ -17,6 +17,7 @@ const { BaseScreenComponent } = require('../base-screen');
|
|||||||
const { BackButtonService } = require('../../services/back-button.js');
|
const { BackButtonService } = require('../../services/back-button.js');
|
||||||
import { AppState } from '../../utils/types';
|
import { AppState } from '../../utils/types';
|
||||||
import { NoteEntity } from '@joplin/lib/services/database/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');
|
const { ALL_NOTES_FILTER_ID } = require('@joplin/lib/reserved-ids.js');
|
||||||
|
|
||||||
class NotesScreenComponent extends BaseScreenComponent<any> {
|
class NotesScreenComponent extends BaseScreenComponent<any> {
|
||||||
@ -237,6 +238,8 @@ class NotesScreenComponent extends BaseScreenComponent<any> {
|
|||||||
const thisComp = this;
|
const thisComp = this;
|
||||||
|
|
||||||
const makeActionButtonComp = () => {
|
const makeActionButtonComp = () => {
|
||||||
|
if (this.props.notesParentType === 'Folder' && itemIsInTrash(parent)) return null;
|
||||||
|
|
||||||
const getTargetFolderId = async () => {
|
const getTargetFolderId = async () => {
|
||||||
if (!buttonFolderId && isAllNotes) {
|
if (!buttonFolderId && isAllNotes) {
|
||||||
return (await Folder.defaultFolder()).id;
|
return (await Folder.defaultFolder()).id;
|
||||||
|
@ -10,6 +10,7 @@ const { dialogs } = require('../../utils/dialogs.js');
|
|||||||
const { _ } = require('@joplin/lib/locale');
|
const { _ } = require('@joplin/lib/locale');
|
||||||
const { default: FolderPicker } = require('../FolderPicker');
|
const { default: FolderPicker } = require('../FolderPicker');
|
||||||
const TextInput = require('../TextInput').default;
|
const TextInput = require('../TextInput').default;
|
||||||
|
const { getTrashFolderId } = require('@joplin/lib/services/trash');
|
||||||
|
|
||||||
class FolderScreenComponent extends BaseScreenComponent {
|
class FolderScreenComponent extends BaseScreenComponent {
|
||||||
static navigationOptions() {
|
static navigationOptions() {
|
||||||
@ -107,7 +108,7 @@ class FolderScreenComponent extends BaseScreenComponent {
|
|||||||
<FolderPicker
|
<FolderPicker
|
||||||
themeId={this.props.themeId}
|
themeId={this.props.themeId}
|
||||||
placeholder={_('Select parent notebook')}
|
placeholder={_('Select parent notebook')}
|
||||||
folders={this.props.folders}
|
folders={this.props.folders.filter(f => f.id !== getTrashFolderId())}
|
||||||
selectedFolderId={this.state.folder.parent_id}
|
selectedFolderId={this.state.folder.parent_id}
|
||||||
onValueChange={newValue => this.parent_changeValue(newValue)}
|
onValueChange={newValue => this.parent_changeValue(newValue)}
|
||||||
mustSelect
|
mustSelect
|
||||||
|
@ -9,11 +9,15 @@ import NavService from '@joplin/lib/services/NavService';
|
|||||||
import { _ } from '@joplin/lib/locale';
|
import { _ } from '@joplin/lib/locale';
|
||||||
const { themeStyle } = require('./global-style.js');
|
const { themeStyle } = require('./global-style.js');
|
||||||
import { renderFolders } from '@joplin/lib/components/shared/side-menu-shared';
|
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 { AppState } from '../utils/types';
|
||||||
import Setting from '@joplin/lib/models/Setting';
|
import Setting from '@joplin/lib/models/Setting';
|
||||||
import { reg } from '@joplin/lib/registry';
|
import { reg } from '@joplin/lib/registry';
|
||||||
import { ProfileConfig } from '@joplin/lib/services/profileConfig/types';
|
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
|
// We need this to suppress the useless warning
|
||||||
// https://github.com/oblador/react-native-vector-icons/issues/1465
|
// https://github.com/oblador/react-native-vector-icons/issues/1465
|
||||||
@ -142,58 +146,96 @@ const SideMenuContentComponent = (props: Props) => {
|
|||||||
|
|
||||||
const folder = folderOrAll as FolderEntity;
|
const folder = folderOrAll as FolderEntity;
|
||||||
|
|
||||||
const generateFolderDeletion = () => {
|
if (folder && folder.id === getTrashFolderId()) return;
|
||||||
const folderDeletion = (message: string) => {
|
|
||||||
Alert.alert('', message, [
|
const menuItems: any[] = [];
|
||||||
{
|
|
||||||
text: _('OK'),
|
if (folder && !!folder.deleted_time) {
|
||||||
onPress: () => {
|
menuItems.push({
|
||||||
void Folder.delete(folder.id);
|
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'),
|
||||||
text: _('Cancel'),
|
onPress: () => { },
|
||||||
onPress: () => { },
|
style: 'cancel',
|
||||||
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) {
|
menuItems.push({
|
||||||
return folderDeletion(
|
text: _('Edit'),
|
||||||
_('Delete the Inbox notebook?\n\nIf you delete the inbox notebook, any email that\'s recently been sent to it may be lost.'),
|
onPress: () => {
|
||||||
);
|
props.dispatch({ type: 'SIDE_MENU_CLOSE' });
|
||||||
}
|
|
||||||
return folderDeletion(_('Delete notebook "%s"?\n\nAll notes and sub-notebooks within this notebook will also be deleted.', folder.title));
|
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(
|
Alert.alert(
|
||||||
'',
|
'',
|
||||||
_('Notebook: %s', folder.title),
|
_('Notebook: %s', folder.title),
|
||||||
[
|
menuItems,
|
||||||
{
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
{
|
{
|
||||||
cancelable: false,
|
cancelable: false,
|
||||||
},
|
},
|
||||||
@ -308,10 +350,12 @@ const SideMenuContentComponent = (props: Props) => {
|
|||||||
if (actionDone === 'auth') props.dispatch({ type: 'SIDE_MENU_CLOSE' });
|
if (actionDone === 'auth') props.dispatch({ type: 'SIDE_MENU_CLOSE' });
|
||||||
}, [performSync, props.dispatch]);
|
}, [performSync, props.dispatch]);
|
||||||
|
|
||||||
const renderFolderIcon = (theme: any, folderIcon: FolderIcon) => {
|
const renderFolderIcon = (folderId: string, theme: any, folderIcon: FolderIcon) => {
|
||||||
if (!folderIcon) {
|
if (!folderIcon) {
|
||||||
if (alwaysShowFolderIcons) {
|
if (alwaysShowFolderIcons) {
|
||||||
return <Icon name="folder-outline" style={styles_.emptyFolderIcon} />;
|
return <Icon name="folder-outline" style={styles_.emptyFolderIcon} />;
|
||||||
|
} else if (folderId === getTrashFolderId()) {
|
||||||
|
folderIcon = getTrashFolderIcon(FolderIconType.Emoji);
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -378,7 +422,7 @@ const SideMenuContentComponent = (props: Props) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View style={folderButtonStyle}>
|
<View style={folderButtonStyle}>
|
||||||
{renderFolderIcon(theme, folderIcon)}
|
{renderFolderIcon(folder.id, theme, folderIcon)}
|
||||||
<Text numberOfLines={1} style={styles_.folderButtonText}>
|
<Text numberOfLines={1} style={styles_.folderButtonText}>
|
||||||
{Folder.displayTitle(folder)}
|
{Folder.displayTitle(folder)}
|
||||||
</Text>
|
</Text>
|
||||||
|
@ -121,13 +121,13 @@ import { ReactNode } from 'react';
|
|||||||
import { parseShareCache } from '@joplin/lib/services/share/reducer';
|
import { parseShareCache } from '@joplin/lib/services/share/reducer';
|
||||||
import autodetectTheme, { onSystemColorSchemeChange } from './utils/autodetectTheme';
|
import autodetectTheme, { onSystemColorSchemeChange } from './utils/autodetectTheme';
|
||||||
import runOnDeviceFsDriverTests from './utils/fs-driver/runOnDeviceTests';
|
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';
|
type SideMenuPosition = 'left' | 'right';
|
||||||
|
|
||||||
const logger = Logger.create('root');
|
const logger = Logger.create('root');
|
||||||
|
|
||||||
let storeDispatch = function(_action: any) {};
|
let storeDispatch: any = function(_action: any) {};
|
||||||
|
|
||||||
const logReducerAction = function(action: any) {
|
const logReducerAction = function(action: any) {
|
||||||
if (['SIDE_MENU_OPEN_PERCENT', 'SYNC_REPORT_UPDATE'].indexOf(action.type) >= 0) return;
|
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 result = next(action);
|
||||||
const newState = store.getState();
|
const newState = store.getState();
|
||||||
|
let doRefreshFolders = false;
|
||||||
|
|
||||||
await reduxSharedMiddleware(store, next, action, storeDispatch as any);
|
await reduxSharedMiddleware(store, next, action, storeDispatch as any);
|
||||||
|
|
||||||
@ -158,6 +159,10 @@ const generalMiddleware = (store: any) => (next: any) => async (action: any) =>
|
|||||||
SearchEngine.instance().scheduleSyncTables();
|
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) {
|
if (['EVENT_NOTE_ALARM_FIELD_CHANGE', 'NOTE_DELETE'].indexOf(action.type) >= 0) {
|
||||||
await AlarmService.updateNoteNotification(action.id, action.type === 'NOTE_DELETE');
|
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();
|
void ResourceFetcher.instance().autoAddResources();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (doRefreshFolders) {
|
||||||
|
await scheduleRefreshFolders((action: any) => storeDispatch(action));
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
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 array.filter((elem, index, self) => {
|
||||||
return index === self.indexOf(elem);
|
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);
|
const index = array.indexOf(element);
|
||||||
if (index < 0) return array;
|
if (index < 0) return array;
|
||||||
const newArray = array.slice();
|
const newArray = array.slice();
|
||||||
|
@ -61,6 +61,7 @@ import RotatingLogs from './RotatingLogs';
|
|||||||
import { NoteEntity } from './services/database/types';
|
import { NoteEntity } from './services/database/types';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import processStartFlags from './utils/processStartFlags';
|
import processStartFlags from './utils/processStartFlags';
|
||||||
|
import { setupAutoDeletion } from './services/trash/permanentlyDeleteOldItems';
|
||||||
import determineProfileAndBaseDir from './determineBaseAppDirs';
|
import determineProfileAndBaseDir from './determineBaseAppDirs';
|
||||||
|
|
||||||
const appLogger: LoggerWrapper = Logger.create('App');
|
const appLogger: LoggerWrapper = Logger.create('App');
|
||||||
@ -438,6 +439,14 @@ export default class BaseApplication {
|
|||||||
doRefreshFolders = true;
|
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') {
|
if (action.type === 'HISTORY_BACKWARD' || action.type === 'HISTORY_FORWARD') {
|
||||||
refreshNotes = true;
|
refreshNotes = true;
|
||||||
refreshNotesUseSelectedNoteId = true;
|
refreshNotesUseSelectedNoteId = true;
|
||||||
@ -822,6 +831,8 @@ export default class BaseApplication {
|
|||||||
if (!currentFolder) currentFolder = await Folder.defaultFolder();
|
if (!currentFolder) currentFolder = await Folder.defaultFolder();
|
||||||
Setting.setValue('activeFolderId', currentFolder ? currentFolder.id : '');
|
Setting.setValue('activeFolderId', currentFolder ? currentFolder.id : '');
|
||||||
|
|
||||||
|
await setupAutoDeletion();
|
||||||
|
|
||||||
await MigrationService.instance().run();
|
await MigrationService.instance().run();
|
||||||
|
|
||||||
return argv;
|
return argv;
|
||||||
|
@ -40,6 +40,15 @@ export interface DeleteOptions {
|
|||||||
trackDeleted?: boolean;
|
trackDeleted?: boolean;
|
||||||
|
|
||||||
disableReadOnlyCheck?: 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 {
|
class BaseModel {
|
||||||
@ -308,7 +317,7 @@ class BaseModel {
|
|||||||
return this.modelSelectAll(q.sql, q.params);
|
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 (!ids.length) return [];
|
||||||
if (!options) options = {};
|
if (!options) options = {};
|
||||||
if (!options.fields) options.fields = '*';
|
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 Folder from '../../models/Folder';
|
||||||
import BaseModel from '../../BaseModel';
|
import BaseModel from '../../BaseModel';
|
||||||
import { FolderEntity, TagEntity } from '../../services/database/types';
|
import { FolderEntity, TagEntity } from '../../services/database/types';
|
||||||
|
import { getDisplayParentId, getTrashFolderId } from '../../services/trash';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
folders: FolderEntity[];
|
folders: FolderEntity[];
|
||||||
@ -11,26 +12,33 @@ interface Props {
|
|||||||
tags?: TagEntity[];
|
tags?: TagEntity[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type RenderFolderItem = (folder: FolderEntity, selected: boolean, hasChildren: boolean, depth: number)=> any;
|
export type RenderFolderItem = (folder: FolderEntity, selected: boolean, hasChildren: boolean, depth: number)=> any;
|
||||||
type RenderTagItem = (tag: TagEntity, selected: boolean)=> any;
|
export type RenderTagItem = (tag: TagEntity, selected: boolean)=> any;
|
||||||
|
|
||||||
function folderHasChildren_(folders: FolderEntity[], folderId: string) {
|
function folderHasChildren_(folders: FolderEntity[], folderId: string) {
|
||||||
|
if (folderId === getTrashFolderId()) {
|
||||||
|
return !!folders.find(f => !!f.deleted_time);
|
||||||
|
}
|
||||||
|
|
||||||
for (let i = 0; i < folders.length; i++) {
|
for (let i = 0; i < folders.length; i++) {
|
||||||
const folder = folders[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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function folderIsVisible(folders: FolderEntity[], folderId: string, collapsedFolderIds: string[]) {
|
function folderIsCollapsed(folders: FolderEntity[], folderId: string, collapsedFolderIds: string[]) {
|
||||||
if (!collapsedFolderIds || !collapsedFolderIds.length) return true;
|
if (!collapsedFolderIds || !collapsedFolderIds.length) return false;
|
||||||
|
|
||||||
while (true) {
|
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) throw new Error(`No folder with id ${folder.id}`);
|
||||||
if (!folder.parent_id) return true;
|
const folderParentId = getDisplayParentId(folder, folders.find(f => f.id === folder.parent_id));
|
||||||
if (collapsedFolderIds.indexOf(folder.parent_id) >= 0) return false;
|
if (!folderParentId) return false;
|
||||||
folderId = folder.parent_id;
|
if (collapsedFolderIds.indexOf(folderParentId) >= 0) return true;
|
||||||
|
folderId = folderParentId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -38,8 +46,11 @@ function renderFoldersRecursive_(props: Props, renderItem: RenderFolderItem, ite
|
|||||||
const folders = props.folders;
|
const folders = props.folders;
|
||||||
for (let i = 0; i < folders.length; i++) {
|
for (let i = 0; i < folders.length; i++) {
|
||||||
const folder = folders[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);
|
const hasChildren = folderHasChildren_(folders, folder.id);
|
||||||
order.push(folder.id);
|
order.push(folder.id);
|
||||||
items.push(renderItem(folder, props.selectedFolderId === folder.id && props.notesParentType === 'Folder', hasChildren, depth));
|
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;
|
return a.title.toLowerCase() < b.title.toLowerCase() ? -1 : +1;
|
||||||
});
|
});
|
||||||
const tagItems = [];
|
const tagItems = [];
|
||||||
const order = [];
|
const order: string[] = [];
|
||||||
for (let i = 0; i < tags.length; i++) {
|
for (let i = 0; i < tags.length; i++) {
|
||||||
const tag = tags[i];
|
const tag = tags[i];
|
||||||
order.push(tag.id);
|
order.push(tag.id);
|
||||||
|
@ -38,7 +38,10 @@ export const allForDisplay = async (options: FolderLoadOptions = {}) => {
|
|||||||
export const refreshFolders = async (dispatch: Dispatch) => {
|
export const refreshFolders = async (dispatch: Dispatch) => {
|
||||||
refreshCalls_.push(true);
|
refreshCalls_.push(true);
|
||||||
try {
|
try {
|
||||||
const folders = await allForDisplay({ includeConflictFolder: true });
|
const folders = await allForDisplay({
|
||||||
|
includeConflictFolder: true,
|
||||||
|
includeTrash: true,
|
||||||
|
});
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'FOLDER_UPDATE_ALL',
|
type: 'FOLDER_UPDATE_ALL',
|
||||||
|
@ -13,7 +13,7 @@ import { getEncryptionEnabled } from '../services/synchronizer/syncInfoUtils';
|
|||||||
import JoplinError from '../JoplinError';
|
import JoplinError from '../JoplinError';
|
||||||
import { LoadOptions, SaveOptions } from './utils/types';
|
import { LoadOptions, SaveOptions } from './utils/types';
|
||||||
import { State as ShareState } from '../services/share/reducer';
|
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 { sprintf } = require('sprintf-js');
|
||||||
const moment = require('moment');
|
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'] });
|
const previousItems = await this.loadItemsByTypeAndIds(this.modelType(), ids, { fields: ['share_id', 'id'] });
|
||||||
checkIfItemsCanBeChanged(this.modelType(), options.changeSource, previousItems, this.syncShareCache);
|
checkIfItemsCanBeChanged(this.modelType(), options.changeSource, previousItems, this.syncShareCache);
|
||||||
}
|
}
|
||||||
@ -338,6 +338,15 @@ export default class BaseItem extends BaseModel {
|
|||||||
return r['total'];
|
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) {
|
public static remoteDeletedItem(syncTarget: number, itemId: string) {
|
||||||
return this.db().exec('DELETE FROM deleted_items WHERE item_id = ? AND sync_target = ?', [itemId, syncTarget]);
|
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
|
// List of keys that won't be encrypted - mostly foreign keys required to link items
|
||||||
// with each others and timestamp required for synchronisation.
|
// 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 = {};
|
const reducedItem: any = {};
|
||||||
|
|
||||||
for (let i = 0; i < keepKeys.length; i++) {
|
for (let i = 0; i < keepKeys.length; i++) {
|
||||||
@ -917,7 +926,7 @@ export default class BaseItem extends BaseModel {
|
|||||||
|
|
||||||
const isNew = this.isNew(o, options);
|
const isNew = this.isNew(o, options);
|
||||||
|
|
||||||
if (needsReadOnlyChecks(this.modelType(), options.changeSource, this.syncShareCache)) {
|
if (needsShareReadOnlyChecks(this.modelType(), options.changeSource, this.syncShareCache)) {
|
||||||
if (!isNew) {
|
if (!isNew) {
|
||||||
const previousItem = await this.loadItemByTypeAndId(this.modelType(), o.id, { fields: ['id', 'share_id'] });
|
const previousItem = await this.loadItemByTypeAndId(this.modelType(), o.id, { fields: ['id', 'share_id'] });
|
||||||
checkIfItemCanBeChanged(this.modelType(), options.changeSource, previousItem, this.syncShareCache);
|
checkIfItemCanBeChanged(this.modelType(), options.changeSource, previousItem, this.syncShareCache);
|
||||||
|
@ -323,4 +323,34 @@ describe('models/Folder', () => {
|
|||||||
cleanup();
|
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 syncDebugLog from '../services/synchronizer/syncDebugLog';
|
||||||
import ResourceService from '../services/ResourceService';
|
import ResourceService from '../services/ResourceService';
|
||||||
import { LoadOptions } from './utils/types';
|
import { LoadOptions } from './utils/types';
|
||||||
|
import { getTrashFolder, getTrashFolderId } from '../services/trash';
|
||||||
const { substrWithEllipsis } = require('../string-utils.js');
|
const { substrWithEllipsis } = require('../string-utils.js');
|
||||||
|
|
||||||
const logger = Logger.create('models/Folder');
|
const logger = Logger.create('models/Folder');
|
||||||
@ -45,25 +46,30 @@ export default class Folder extends BaseItem {
|
|||||||
return field in fieldsToLabels ? fieldsToLabels[field] : field;
|
return field in fieldsToLabels ? fieldsToLabels[field] : field;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static noteIds(parentId: string, options: any = null) {
|
public static async notes(parentId: string, options: LoadOptions = null) {
|
||||||
options = { includeConflicts: false, ...options };
|
options = {
|
||||||
|
includeConflicts: false,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
|
||||||
const where = ['parent_id = ?'];
|
const where = ['parent_id = ?'];
|
||||||
if (!options.includeConflicts) {
|
if (!options.includeConflicts) {
|
||||||
where.push('is_conflict = 0');
|
where.push('is_conflict = 0');
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.db()
|
if (!options.includeDeleted) {
|
||||||
.selectAll(`SELECT id FROM notes WHERE ${where.join(' AND ')}`, [parentId])
|
where.push('deleted_time = 0');
|
||||||
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
|
}
|
||||||
.then((rows: any[]) => {
|
|
||||||
const output = [];
|
return this.modelSelectAll(`SELECT ${this.selectFields(options)} FROM notes WHERE ${where.join(' AND ')}`, [parentId]);
|
||||||
for (let i = 0; i < rows.length; i++) {
|
}
|
||||||
const row = rows[i];
|
|
||||||
output.push(row.id);
|
public static async noteIds(parentId: string, options: LoadOptions = null) {
|
||||||
}
|
const notes = await this.notes(parentId, {
|
||||||
return output;
|
fields: ['id'],
|
||||||
});
|
...options,
|
||||||
|
});
|
||||||
|
return notes.map(n => n.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async subFolderIds(parentId: string) {
|
public static async subFolderIds(parentId: string) {
|
||||||
@ -81,6 +87,11 @@ export default class Folder extends BaseItem {
|
|||||||
return this.db().exec(query);
|
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) {
|
public static async deleteAllByShareId(shareId: string, deleteOptions: DeleteOptions = null) {
|
||||||
const tableNameToClasses: Record<string, any> = {
|
const tableNameToClasses: Record<string, any> = {
|
||||||
'folders': Folder,
|
'folders': Folder,
|
||||||
@ -102,12 +113,18 @@ export default class Folder extends BaseItem {
|
|||||||
...options,
|
...options,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (folderId === getTrashFolderId()) throw new Error('The trash folder cannot be deleted');
|
||||||
|
|
||||||
|
const toTrash = !!options.toTrash;
|
||||||
|
|
||||||
const folder = await Folder.load(folderId);
|
const folder = await Folder.load(folderId);
|
||||||
if (!folder) return; // noop
|
if (!folder) return; // noop
|
||||||
|
|
||||||
if (options.deleteChildren) {
|
if (options.deleteChildren) {
|
||||||
const childrenDeleteOptions: DeleteOptions = {
|
const childrenDeleteOptions: DeleteOptions = {
|
||||||
disableReadOnlyCheck: options.disableReadOnlyCheck,
|
disableReadOnlyCheck: options.disableReadOnlyCheck,
|
||||||
|
deleteChildren: true,
|
||||||
|
toTrash,
|
||||||
};
|
};
|
||||||
|
|
||||||
const noteIds = await Folder.noteIds(folderId);
|
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({
|
this.dispatch({
|
||||||
type: 'FOLDER_DELETE',
|
type: 'FOLDER_DELETE',
|
||||||
@ -136,13 +160,15 @@ export default class Folder extends BaseItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static conflictFolder(): FolderEntity {
|
public static conflictFolder(): FolderEntity {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type_: this.TYPE_FOLDER,
|
type_: this.TYPE_FOLDER,
|
||||||
id: this.conflictFolderId(),
|
id: this.conflictFolderId(),
|
||||||
parent_id: '',
|
parent_id: '',
|
||||||
title: this.conflictFolderTitle(),
|
title: this.conflictFolderTitle(),
|
||||||
updated_time: time.unixMs(),
|
updated_time: now,
|
||||||
user_updated_time: time.unixMs(),
|
user_updated_time: now,
|
||||||
share_id: '',
|
share_id: '',
|
||||||
is_shared: 0,
|
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
|
// 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
|
// Note: this only calculates the overall number of nodes for this folder and all its descendants
|
||||||
public static async addNoteCounts(folders: any[], includeCompletedTodos = true) {
|
public static async addNoteCounts(folders: FolderEntity[], includeCompletedTodos = true) {
|
||||||
const foldersById: any = {};
|
// 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) {
|
for (const f of folders) {
|
||||||
foldersById[f.id] = f;
|
foldersById[f.id] = f;
|
||||||
|
|
||||||
if (this.conflictFolderId() === f.id) {
|
if (this.conflictFolderId() === f.id) {
|
||||||
f.note_count = await Note.conflictedCount();
|
foldersById[f.id].note_count = await Note.conflictedCount();
|
||||||
} else {
|
} 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)');
|
if (!includeCompletedTodos) where.push('(notes.is_todo = 0 OR notes.todo_completed = 0)');
|
||||||
|
|
||||||
const sql = `
|
const sql = `
|
||||||
@ -172,9 +207,14 @@ export default class Folder extends BaseItem {
|
|||||||
GROUP BY folders.id
|
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
|
// 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;
|
let parentId = noteCount.folder_id;
|
||||||
do {
|
do {
|
||||||
const folder = foldersById[parentId];
|
const folder = foldersById[parentId];
|
||||||
@ -251,23 +291,41 @@ export default class Folder extends BaseItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static async all(options: FolderLoadOptions = null) {
|
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) {
|
if (options && options.includeConflictFolder) {
|
||||||
const conflictCount = await Note.conflictedCount();
|
const conflictCount = await Note.conflictedCount();
|
||||||
if (conflictCount) output.push(this.conflictFolder());
|
if (conflictCount) output.push(this.conflictFolder());
|
||||||
}
|
}
|
||||||
|
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async childrenIds(folderId: string) {
|
public static async childrenIds(folderId: string, options: LoadOptions = null) {
|
||||||
const folders = await this.db().selectAll('SELECT id FROM folders WHERE parent_id = ?', [folderId]);
|
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[] = [];
|
let output: string[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < folders.length; i++) {
|
for (let i = 0; i < folders.length; i++) {
|
||||||
const f = folders[i];
|
const f = folders[i];
|
||||||
output.push(f.id);
|
output.push(f.id);
|
||||||
const subChildrenIds = await this.childrenIds(f.id);
|
const subChildrenIds = await this.childrenIds(f.id, options);
|
||||||
output = output.concat(subChildrenIds);
|
output = output.concat(subChildrenIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -593,7 +651,11 @@ export default class Folder extends BaseItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static async allAsTree(folders: FolderEntity[] = null, options: any = null) {
|
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) {
|
if (options && options.includeNotes) {
|
||||||
for (const folder of all) {
|
for (const folder of all) {
|
||||||
@ -722,8 +784,13 @@ export default class Folder extends BaseItem {
|
|||||||
return output;
|
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> {
|
public static load(id: string, options: LoadOptions = null): Promise<FolderEntity> {
|
||||||
if (id === this.conflictFolderId()) return Promise.resolve(this.conflictFolder());
|
if (id === this.conflictFolderId()) return Promise.resolve(this.conflictFolder());
|
||||||
|
if (id === getTrashFolderId()) return Promise.resolve(getTrashFolder());
|
||||||
return super.load(id, options);
|
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
|
// Ensures that any folder added to the state has all the required
|
||||||
// properties, in particular "share_id" and "parent_id', which are
|
// properties, in particular "share_id" and "parent_id', which are
|
||||||
// required in various parts of the code.
|
// 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);
|
savedFolder = await this.load(savedFolder.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -827,6 +894,20 @@ export default class Folder extends BaseItem {
|
|||||||
return savedFolder;
|
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 {
|
public static serializeIcon(icon: FolderIcon): string {
|
||||||
return icon ? JSON.stringify(icon) : '';
|
return icon ? JSON.stringify(icon) : '';
|
||||||
}
|
}
|
||||||
|
@ -2,7 +2,7 @@ import Setting from './Setting';
|
|||||||
import BaseModel from '../BaseModel';
|
import BaseModel from '../BaseModel';
|
||||||
import shim from '../shim';
|
import shim from '../shim';
|
||||||
import markdownUtils from '../markdownUtils';
|
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 Folder from './Folder';
|
||||||
import Note from './Note';
|
import Note from './Note';
|
||||||
import Tag from './Tag';
|
import Tag from './Tag';
|
||||||
@ -12,6 +12,8 @@ import { ResourceEntity } from '../services/database/types';
|
|||||||
import { toForwardSlashes } from '../path-utils';
|
import { toForwardSlashes } from '../path-utils';
|
||||||
import * as ArrayUtils from '../ArrayUtils';
|
import * as ArrayUtils from '../ArrayUtils';
|
||||||
import { ErrorCode } from '../errors';
|
import { ErrorCode } from '../errors';
|
||||||
|
import SearchEngine from '../services/search/SearchEngine';
|
||||||
|
import { getTrashFolderId } from '../services/trash';
|
||||||
|
|
||||||
async function allItems() {
|
async function allItems() {
|
||||||
const folders = await Folder.all();
|
const folders = await Folder.all();
|
||||||
@ -497,4 +499,86 @@ describe('models/Note', () => {
|
|||||||
cleanup();
|
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 BaseModel, { DeleteOptions, ModelType } from '../BaseModel';
|
||||||
import BaseItem from './BaseItem';
|
import BaseItem from './BaseItem';
|
||||||
|
import type FolderClass from './Folder';
|
||||||
import ItemChange from './ItemChange';
|
import ItemChange from './ItemChange';
|
||||||
import Setting from './Setting';
|
import Setting from './Setting';
|
||||||
import shim from '../shim';
|
import shim from '../shim';
|
||||||
import time from '../time';
|
import time from '../time';
|
||||||
import markdownUtils from '../markdownUtils';
|
import markdownUtils from '../markdownUtils';
|
||||||
import { NoteEntity } from '../services/database/types';
|
import { FolderEntity, NoteEntity } from '../services/database/types';
|
||||||
import Tag from './Tag';
|
import Tag from './Tag';
|
||||||
const { sprintf } = require('sprintf-js');
|
const { sprintf } = require('sprintf-js');
|
||||||
import Resource from './Resource';
|
import Resource from './Resource';
|
||||||
@ -13,8 +14,9 @@ import syncDebugLog from '../services/synchronizer/syncDebugLog';
|
|||||||
import { toFileProtocolPath, toForwardSlashes } from '../path-utils';
|
import { toFileProtocolPath, toForwardSlashes } from '../path-utils';
|
||||||
const { pregQuote, substrWithEllipsis } = require('../string-utils.js');
|
const { pregQuote, substrWithEllipsis } = require('../string-utils.js');
|
||||||
const { _ } = require('../locale');
|
const { _ } = require('../locale');
|
||||||
import { pull, unique } from '../ArrayUtils';
|
import { pull, removeElement, unique } from '../ArrayUtils';
|
||||||
import { LoadOptions, SaveOptions } from './utils/types';
|
import { LoadOptions, SaveOptions } from './utils/types';
|
||||||
|
import { getDisplayParentId, getTrashFolderId } from '../services/trash';
|
||||||
const urlUtils = require('../urlUtils.js');
|
const urlUtils = require('../urlUtils.js');
|
||||||
const { isImageMimeType } = require('../resourceUtils');
|
const { isImageMimeType } = require('../resourceUtils');
|
||||||
const { MarkupToHtml } = require('@joplin/renderer');
|
const { MarkupToHtml } = require('@joplin/renderer');
|
||||||
@ -33,7 +35,7 @@ interface PreviewsOptions {
|
|||||||
anywherePattern?: string;
|
anywherePattern?: string;
|
||||||
itemTypes?: string[];
|
itemTypes?: string[];
|
||||||
limit?: number;
|
limit?: number;
|
||||||
includeDeleted?: boolean;
|
titlePattern?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class Note extends BaseItem {
|
export default class Note extends BaseItem {
|
||||||
@ -328,7 +330,7 @@ export default class Note extends BaseItem {
|
|||||||
public static previewFields(options: any = null) {
|
public static previewFields(options: any = null) {
|
||||||
options = { includeTimestamps: true, ...options };
|
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) {
|
if (options.includeTimestamps) {
|
||||||
output.push('updated_time');
|
output.push('updated_time');
|
||||||
@ -358,7 +360,7 @@ export default class Note extends BaseItem {
|
|||||||
return results.length ? results[0] : null;
|
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
|
// Note: ordering logic must be duplicated in sortNotes(), which is used
|
||||||
// to sort already loaded notes.
|
// to sort already loaded notes.
|
||||||
|
|
||||||
@ -370,16 +372,43 @@ export default class Note extends BaseItem {
|
|||||||
if (!options.uncompletedTodosOnTop) options.uncompletedTodosOnTop = false;
|
if (!options.uncompletedTodosOnTop) options.uncompletedTodosOnTop = false;
|
||||||
if (!('showCompletedTodos' in options)) options.showCompletedTodos = true;
|
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
|
// Conflicts are always displayed regardless of options, since otherwise
|
||||||
// it's confusing to have conflicts but with an empty conflict folder.
|
// 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()) {
|
if (parentId === Folder.conflictFolderId()) {
|
||||||
options.conditions.push('is_conflict = 1');
|
options.conditions.push('is_conflict = 1');
|
||||||
|
} else if (withinTrash) {
|
||||||
|
options.conditions.push('deleted_time > 0');
|
||||||
} else {
|
} else {
|
||||||
options.conditions.push('is_conflict = 0');
|
options.conditions.push('is_conflict = 0');
|
||||||
|
options.conditions.push('deleted_time = 0');
|
||||||
if (parentId && parentId !== ALL_NOTES_FILTER_ID) {
|
if (parentId && parentId !== ALL_NOTES_FILTER_ID) {
|
||||||
options.conditions.push('parent_id = ?');
|
options.conditions.push('parent_id = ?');
|
||||||
options.conditionsParams.push(parentId);
|
options.conditionsParams.push(parentId);
|
||||||
@ -407,11 +436,11 @@ export default class Note extends BaseItem {
|
|||||||
options.conditions.push('todo_completed <= 0');
|
options.conditions.push('todo_completed <= 0');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.uncompletedTodosOnTop && hasTodos) {
|
if (!withinTrash && options.uncompletedTodosOnTop && hasTodos) {
|
||||||
let cond = options.conditions.slice();
|
let cond = options.conditions.slice();
|
||||||
cond.push('is_todo = 1');
|
cond.push('is_todo = 1');
|
||||||
cond.push('(todo_completed <= 0 OR todo_completed IS NULL)');
|
cond.push('(todo_completed <= 0 OR todo_completed IS NULL)');
|
||||||
let tempOptions = { ...options };
|
let tempOptions: PreviewsOptions = { ...options };
|
||||||
tempOptions.conditions = cond;
|
tempOptions.conditions = cond;
|
||||||
|
|
||||||
const uncompletedTodos = await this.search(tempOptions);
|
const uncompletedTodos = await this.search(tempOptions);
|
||||||
@ -441,9 +470,32 @@ export default class Note extends BaseItem {
|
|||||||
options.conditions.push('is_todo = 1');
|
options.conditions.push('is_todo = 1');
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await this.search(options);
|
let results = await this.search(options);
|
||||||
this.handleTitleNaturalSorting(results, 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;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -554,14 +606,19 @@ export default class Note extends BaseItem {
|
|||||||
public static async moveToFolder(noteId: string, folderId: string) {
|
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()));
|
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.
|
// When moving a note to a different folder, the user timestamp is not
|
||||||
// However updated_time is updated so that the note can be synced later on.
|
// 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,
|
id: noteId,
|
||||||
parent_id: folderId,
|
parent_id: folderId,
|
||||||
is_conflict: 0,
|
is_conflict: 0,
|
||||||
conflict_original_id: '',
|
conflict_original_id: '',
|
||||||
|
deleted_time: 0,
|
||||||
updated_time: time.unixMs(),
|
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) o.source = Setting.value('appName');
|
||||||
if (isNew && !o.source_application) o.source_application = Setting.value('appId');
|
if (isNew && !o.source_application) o.source_application = Setting.value('appId');
|
||||||
if (isNew && !('order' in o)) o.order = Date.now();
|
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;
|
const changeSource = options && options.changeSource ? options.changeSource : null;
|
||||||
|
|
||||||
@ -736,14 +794,23 @@ export default class Note extends BaseItem {
|
|||||||
|
|
||||||
syncDebugLog.info('Save Note: N:', o);
|
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) {
|
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({
|
this.dispatch({
|
||||||
type: 'NOTE_UPDATE_ONE',
|
type: 'NOTE_UPDATE_ONE',
|
||||||
note: note,
|
note: savedNote,
|
||||||
provisional: isProvisional,
|
provisional: isProvisional,
|
||||||
ignoreProvisionalFlag: ignoreProvisionalFlag,
|
ignoreProvisionalFlag: ignoreProvisionalFlag,
|
||||||
changedFields: changedFields,
|
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) {
|
if ('todo_due' in o || 'todo_completed' in o || 'is_todo' in o || 'is_conflict' in o) {
|
||||||
this.dispatch({
|
this.dispatch({
|
||||||
type: 'EVENT_NOTE_ALARM_FIELD_CHANGE',
|
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) {
|
public static async batchDelete(ids: string[], options: DeleteOptions = null) {
|
||||||
|
if (!ids.length) return;
|
||||||
|
|
||||||
ids = ids.slice();
|
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) {
|
while (ids.length) {
|
||||||
const processIds = ids.splice(0, 50);
|
const processIds = ids.splice(0, 50);
|
||||||
|
|
||||||
const notes = await Note.byIds(processIds);
|
const notes = await Note.byIds(processIds);
|
||||||
const beforeChangeItems: any = {};
|
const beforeChangeItems: any = {};
|
||||||
for (const note of notes) {
|
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++) {
|
for (let i = 0; i < processIds.length; i++) {
|
||||||
const id = processIds[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({
|
this.dispatch({
|
||||||
type: 'NOTE_DELETE',
|
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 = '';
|
let msg = '';
|
||||||
if (noteIds.length === 1) {
|
if (noteIds.length === 1) {
|
||||||
const note = await Note.load(noteIds[0]);
|
const note = await Note.load(noteIds[0]);
|
||||||
if (!note) return null;
|
if (!note) return null;
|
||||||
msg = _('Delete note "%s"?', substrWithEllipsis(note.title, 0, 32));
|
msg = _('Permanently delete note "%s"?', substrWithEllipsis(note.title, 0, 32));
|
||||||
} else {
|
} else {
|
||||||
msg = _('Delete these %d notes?', noteIds.length);
|
msg = _('Permanently delete these %d notes?', noteIds.length);
|
||||||
}
|
}
|
||||||
return msg;
|
return msg;
|
||||||
}
|
}
|
||||||
|
@ -1803,6 +1803,31 @@ class Setting extends BaseModel {
|
|||||||
label: () => _('Voice typing language files (URL)'),
|
label: () => _('Voice typing language files (URL)'),
|
||||||
section: 'note',
|
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_ };
|
this.metadata_ = { ...this.buildInMetadata_ };
|
||||||
|
@ -57,6 +57,26 @@ describe('models/Tag', () => {
|
|||||||
expect(notesTag3.map(n => n.id).sort()).toEqual([].sort());
|
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 () => {
|
it('should not allow renaming tag to existing tag names', async () => {
|
||||||
const folder1 = await Folder.save({ title: 'folder1' });
|
const folder1 = await Folder.save({ title: 'folder1' });
|
||||||
const note1 = await Note.save({ title: 'ma note', parent_id: folder1.id });
|
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) {
|
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 = [];
|
const output = [];
|
||||||
for (let i = 0; i < rows.length; i++) {
|
for (let i = 0; i < rows.length; i++) {
|
||||||
output.push(rows[i].note_id);
|
output.push(rows[i].note_id);
|
||||||
@ -105,7 +110,13 @@ export default class Tag extends BaseItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static async hasNote(tagId: string, noteId: string) {
|
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;
|
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 {
|
export interface WhereQuery {
|
||||||
sql: string;
|
sql: string;
|
||||||
params: any[];
|
params?: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: this method might return more fields than was requested as it will
|
// 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}
|
OFFSET ${offset}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// console.info('SQL', sql, sqlParams);
|
|
||||||
|
|
||||||
const rows = await db.selectAll(sql, sqlParams);
|
const rows = await db.selectAll(sql, sqlParams);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -5,18 +5,20 @@ import JoplinError from '../../JoplinError';
|
|||||||
import { State as ShareState } from '../../services/share/reducer';
|
import { State as ShareState } from '../../services/share/reducer';
|
||||||
import ItemChange from '../ItemChange';
|
import ItemChange from '../ItemChange';
|
||||||
import Setting from '../Setting';
|
import Setting from '../Setting';
|
||||||
|
import { checkObjectHasProperties } from '@joplin/utils/object';
|
||||||
|
|
||||||
const logger = Logger.create('models/utils/readOnly');
|
const logger = Logger.create('models/utils/readOnly');
|
||||||
|
|
||||||
export interface ItemSlice {
|
export interface ItemSlice {
|
||||||
id?: string;
|
id?: string;
|
||||||
share_id: string;
|
share_id: string;
|
||||||
|
deleted_time: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// This function can be called to wrap any read-only-related code. It should be
|
// This function can be called to wrap code that related to share permission read-only checks. It
|
||||||
// fast and allows an early exit for cases that don't apply, for example if not
|
// 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.
|
// 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 (disableReadOnlyCheck) return false;
|
||||||
if (Setting.value('sync.target') !== 10) return false;
|
if (Setting.value('sync.target') !== 10) return false;
|
||||||
if (changeSource === ItemChange.SOURCE_SYNC) 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) => {
|
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 (!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);
|
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) => {
|
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'] });
|
const parentFolder = await Folder.load(parentId, { fields: ['id', 'share_id'] });
|
||||||
|
|
||||||
if (!parentFolder) {
|
if (!parentFolder) {
|
||||||
@ -58,16 +60,27 @@ export const checkIfItemCanBeAddedToFolder = async (itemType: ModelType, Folder:
|
|||||||
return;
|
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);
|
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 => {
|
// Originally all these functions were there to handle share permissions - a note, folder or
|
||||||
if (!needsReadOnlyChecks(itemType, changeSource, shareState)) return false;
|
// 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
|
// Item is not shared
|
||||||
if (!item.share_id) return false;
|
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> => {
|
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;
|
// if (!needsShareReadOnlyChecks(itemType, changeSource, shareState)) return false;
|
||||||
const item: ItemSlice = await BaseItem.loadItem(itemType, itemId, { fields: ['id', 'share_id'] });
|
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);
|
if (!item) throw new JoplinError(`No such item: ${itemType}: ${itemId}`, ErrorCode.NotFound);
|
||||||
return itemIsReadOnlySync(itemType, changeSource, item, userId, shareState);
|
return itemIsReadOnlySync(itemType, changeSource, item, userId, shareState);
|
||||||
};
|
};
|
||||||
|
@ -29,10 +29,13 @@ export interface LoadOptions {
|
|||||||
caseInsensitive?: boolean;
|
caseInsensitive?: boolean;
|
||||||
}[];
|
}[];
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
includeConflicts?: boolean;
|
||||||
|
includeDeleted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FolderLoadOptions extends LoadOptions {
|
export interface FolderLoadOptions extends LoadOptions {
|
||||||
includeConflictFolder?: boolean;
|
includeConflictFolder?: boolean;
|
||||||
|
includeTrash?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SaveOptions {
|
export interface SaveOptions {
|
||||||
|
@ -7,9 +7,10 @@ import BaseModel from './BaseModel';
|
|||||||
import { Store } from 'redux';
|
import { Store } from 'redux';
|
||||||
import { ProfileConfig } from './services/profileConfig/types';
|
import { ProfileConfig } from './services/profileConfig/types';
|
||||||
import * as ArrayUtils from './ArrayUtils';
|
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 { getListRendererIds } from './services/noteList/renderers';
|
||||||
import { ProcessResultsRow } from './services/search/SearchEngine';
|
import { ProcessResultsRow } from './services/search/SearchEngine';
|
||||||
|
import { getDisplayParentId } from './services/trash';
|
||||||
const fastDeepEqual = require('fast-deep-equal');
|
const fastDeepEqual = require('fast-deep-equal');
|
||||||
const { ALL_NOTES_FILTER_ID } = require('./reserved-ids');
|
const { ALL_NOTES_FILTER_ID } = require('./reserved-ids');
|
||||||
const { createSelectorCreator, defaultMemoize } = require('reselect');
|
const { createSelectorCreator, defaultMemoize } = require('reselect');
|
||||||
@ -53,8 +54,14 @@ interface StateResourceFetcher {
|
|||||||
toFetchCount: number;
|
toFetchCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StateLastDeletion {
|
||||||
|
noteIds: string[];
|
||||||
|
folderIds: string[];
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface State {
|
export interface State {
|
||||||
notes: any[];
|
notes: NoteEntity[];
|
||||||
noteSelectionEnabled?: boolean;
|
noteSelectionEnabled?: boolean;
|
||||||
notesSource: string;
|
notesSource: string;
|
||||||
notesParentType: string;
|
notesParentType: string;
|
||||||
@ -102,6 +109,8 @@ export interface State {
|
|||||||
profileConfig: ProfileConfig;
|
profileConfig: ProfileConfig;
|
||||||
noteListRendererIds: string[];
|
noteListRendererIds: string[];
|
||||||
noteListLastSortTime: number;
|
noteListLastSortTime: number;
|
||||||
|
lastDeletion: StateLastDeletion;
|
||||||
|
lastDeletionNotificationTime: number;
|
||||||
mustUpgradeAppMessage: string;
|
mustUpgradeAppMessage: string;
|
||||||
|
|
||||||
// Extra reducer keys go here:
|
// Extra reducer keys go here:
|
||||||
@ -177,6 +186,12 @@ export const defaultState: State = {
|
|||||||
profileConfig: null,
|
profileConfig: null,
|
||||||
noteListRendererIds: getListRendererIds(),
|
noteListRendererIds: getListRendererIds(),
|
||||||
noteListLastSortTime: 0,
|
noteListLastSortTime: 0,
|
||||||
|
lastDeletion: {
|
||||||
|
noteIds: [],
|
||||||
|
folderIds: [],
|
||||||
|
timestamp: 0,
|
||||||
|
},
|
||||||
|
lastDeletionNotificationTime: 0,
|
||||||
mustUpgradeAppMessage: '',
|
mustUpgradeAppMessage: '',
|
||||||
|
|
||||||
pluginService: pluginServiceDefaultState,
|
pluginService: pluginServiceDefaultState,
|
||||||
@ -839,6 +854,19 @@ const reducer = produce((draft: Draft<State> = defaultState, action: any) => {
|
|||||||
}
|
}
|
||||||
break;
|
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':
|
case 'NOTE_PROVISIONAL_FLAG_CLEAR':
|
||||||
{
|
{
|
||||||
const newIds = ArrayUtils.removeElement(draft.provisionalNoteIds, action.id);
|
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.
|
// update it within the note array if it already exists.
|
||||||
case 'NOTE_UPDATE_ONE':
|
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 isViewingAllNotes = (draft.notesParentType === 'SmartFilter' && draft.selectedSmartFilterId === ALL_NOTES_FILTER_ID);
|
||||||
const isViewingConflictFolder = draft.notesParentType === 'Folder' && draft.selectedFolderId === Folder.conflictFolderId();
|
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 (note.is_conflict && isViewingConflictFolder) return true;
|
||||||
if (!('parent_id' in modNote) || note.parent_id === folderId) return true;
|
const noteDisplayParentId = getDisplayParentId(note, draft.folders.find(f => f.id === note.parent_id));
|
||||||
return false;
|
return folderId === noteDisplayParentId;
|
||||||
};
|
};
|
||||||
|
|
||||||
let movedNotePreviousIndex = 0;
|
let movedNotePreviousIndex = 0;
|
||||||
@ -883,7 +911,7 @@ const reducer = produce((draft: Draft<State> = defaultState, action: any) => {
|
|||||||
newNotes.splice(i, 1);
|
newNotes.splice(i, 1);
|
||||||
noteFolderHasChanged = true;
|
noteFolderHasChanged = true;
|
||||||
movedNotePreviousIndex = i;
|
movedNotePreviousIndex = i;
|
||||||
} else if (isViewingAllNotes || noteIsInFolder(modNote, n.parent_id)) {
|
} else if (isViewingAllNotes || noteIsInFolder(modNote, draft.selectedFolderId)) {
|
||||||
// Note is still in the same folder
|
// Note is still in the same folder
|
||||||
// Merge the properties that have changed (in modNote) into
|
// Merge the properties that have changed (in modNote) into
|
||||||
// the object we already have.
|
// the object we already have.
|
||||||
@ -891,7 +919,7 @@ const reducer = produce((draft: Draft<State> = defaultState, action: any) => {
|
|||||||
|
|
||||||
for (const n in modNote) {
|
for (const n in modNote) {
|
||||||
if (!modNote.hasOwnProperty(n)) continue;
|
if (!modNote.hasOwnProperty(n)) continue;
|
||||||
newNotes[i][n] = modNote[n];
|
(newNotes[i] as any)[n] = (modNote as any)[n];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Note has moved to a different folder
|
// Note has moved to a different folder
|
||||||
|
@ -60,6 +60,7 @@ const defaultKeymapItems = {
|
|||||||
{ accelerator: 'Option+Cmd+1', command: 'switchProfile1' },
|
{ accelerator: 'Option+Cmd+1', command: 'switchProfile1' },
|
||||||
{ accelerator: 'Option+Cmd+2', command: 'switchProfile2' },
|
{ accelerator: 'Option+Cmd+2', command: 'switchProfile2' },
|
||||||
{ accelerator: 'Option+Cmd+3', command: 'switchProfile3' },
|
{ accelerator: 'Option+Cmd+3', command: 'switchProfile3' },
|
||||||
|
{ accelerator: 'Option+Cmd+Backspace', command: 'permanentlyDeleteNote' },
|
||||||
],
|
],
|
||||||
default: [
|
default: [
|
||||||
{ accelerator: 'Ctrl+N', command: 'newNote' },
|
{ accelerator: 'Ctrl+N', command: 'newNote' },
|
||||||
@ -106,6 +107,7 @@ const defaultKeymapItems = {
|
|||||||
{ accelerator: 'Ctrl+Alt+1', command: 'switchProfile1' },
|
{ accelerator: 'Ctrl+Alt+1', command: 'switchProfile1' },
|
||||||
{ accelerator: 'Ctrl+Alt+2', command: 'switchProfile2' },
|
{ accelerator: 'Ctrl+Alt+2', command: 'switchProfile2' },
|
||||||
{ accelerator: 'Ctrl+Alt+3', command: 'switchProfile3' },
|
{ 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();
|
expect(await Resource.load(resource.id)).toBeTruthy();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// it('should auto-delete resource even if the associated note was deleted immediately', (async () => {
|
it('should still create associations for notes in the trash', async () => {
|
||||||
// // Previously, when a resource was be attached to a note, then the
|
const note = await Note.save({});
|
||||||
// // note was immediately deleted, the ResourceService would not have
|
await shim.attachFileToNote(note, `${supportDir}/photo.jpg`);
|
||||||
// // time to quick in an index the resource/note relation. It means
|
await Note.delete(note.id, { toTrash: true });
|
||||||
// // that when doing the orphan resource deletion job, those
|
await resourceService().indexNoteResources();
|
||||||
// // resources would permanently stay behind.
|
|
||||||
// // https://github.com/laurent22/joplin/issues/932
|
|
||||||
|
|
||||||
// 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({});
|
// Also check that the resources are not deleted as orphan
|
||||||
// note = await shim.attachFileToNote(note, `${supportDir}/photo.jpg`);
|
await resourceService().deleteOrphanResources(0);
|
||||||
// const resource = (await Resource.all())[0];
|
expect((await NoteResource.all()).length).toBe(1);
|
||||||
|
});
|
||||||
// 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);
|
|
||||||
// }));
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@ -6,6 +6,7 @@ import { isRootSharedFolder, isSharedFolderOwner } from '../share/reducer';
|
|||||||
import { FolderEntity, NoteEntity } from '../database/types';
|
import { FolderEntity, NoteEntity } from '../database/types';
|
||||||
import { itemIsReadOnlySync, ItemSlice } from '../../models/utils/readOnly';
|
import { itemIsReadOnlySync, ItemSlice } from '../../models/utils/readOnly';
|
||||||
import ItemChange from '../../models/ItemChange';
|
import ItemChange from '../../models/ItemChange';
|
||||||
|
import { getTrashFolderId } from '../trash';
|
||||||
|
|
||||||
export interface WhenClauseContextOptions {
|
export interface WhenClauseContextOptions {
|
||||||
commandFolderId?: string;
|
commandFolderId?: string;
|
||||||
@ -13,29 +14,34 @@ export interface WhenClauseContextOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface WhenClauseContext {
|
export interface WhenClauseContext {
|
||||||
notesAreBeingSaved: boolean;
|
allSelectedNotesAreDeleted: boolean;
|
||||||
syncStarted: boolean;
|
folderIsDeleted: boolean;
|
||||||
inConflictFolder: boolean;
|
folderIsReadOnly: 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;
|
|
||||||
folderIsShared: boolean;
|
folderIsShared: boolean;
|
||||||
folderIsShareRoot: boolean;
|
folderIsShareRoot: boolean;
|
||||||
joplinServerConnected: boolean;
|
folderIsShareRootAndNotOwnedByUser: boolean;
|
||||||
joplinCloudAccountType: number;
|
folderIsShareRootAndOwnedByUser: boolean;
|
||||||
|
folderIsTrash: boolean;
|
||||||
hasMultiProfiles: 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;
|
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 {
|
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 selectedNoteIds = state.selectedNoteIds || [];
|
||||||
const selectedNoteId = selectedNoteIds.length === 1 ? selectedNoteIds[0] : null;
|
const selectedNoteId = selectedNoteIds.length === 1 ? selectedNoteIds[0] : null;
|
||||||
const selectedNote: NoteEntity = selectedNoteId ? BaseModel.byId(state.notes, selectedNoteId) : 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 commandFolderId = options.commandFolderId || state.selectedFolderId;
|
||||||
const commandFolder: FolderEntity = commandFolderId ? BaseModel.byId(state.folders, commandFolderId) : null;
|
const commandFolder: FolderEntity = commandFolderId ? BaseModel.byId(state.folders, commandFolderId) : null;
|
||||||
@ -61,6 +68,7 @@ export default function stateToWhenClauseContext(state: State, options: WhenClau
|
|||||||
|
|
||||||
// Current location
|
// Current location
|
||||||
inConflictFolder: state.selectedFolderId === Folder.conflictFolderId(),
|
inConflictFolder: state.selectedFolderId === Folder.conflictFolderId(),
|
||||||
|
inTrash: state.selectedFolderId === getTrashFolderId() || commandFolder && !!commandFolder.deleted_time,
|
||||||
|
|
||||||
// Note selection
|
// Note selection
|
||||||
oneNoteSelected: !!selectedNote,
|
oneNoteSelected: !!selectedNote,
|
||||||
@ -68,6 +76,9 @@ export default function stateToWhenClauseContext(state: State, options: WhenClau
|
|||||||
multipleNotesSelected: selectedNoteIds.length > 1,
|
multipleNotesSelected: selectedNoteIds.length > 1,
|
||||||
noNotesSelected: !selectedNoteIds.length,
|
noNotesSelected: !selectedNoteIds.length,
|
||||||
|
|
||||||
|
// Selected notes properties
|
||||||
|
allSelectedNotesAreDeleted: !selectedNotes.find(n => !n.deleted_time),
|
||||||
|
|
||||||
// Note history
|
// Note history
|
||||||
historyhasBackwardNotes: state.backwardHistoryNotes && state.backwardHistoryNotes.length > 0,
|
historyhasBackwardNotes: state.backwardHistoryNotes && state.backwardHistoryNotes.length > 0,
|
||||||
historyhasForwardNotes: state.forwardHistoryNotes && state.forwardHistoryNotes.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,
|
noteTodoCompleted: selectedNote ? !!selectedNote.todo_completed : false,
|
||||||
noteIsMarkdown: selectedNote ? selectedNote.markup_language === MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN : false,
|
noteIsMarkdown: selectedNote ? selectedNote.markup_language === MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN : false,
|
||||||
noteIsHtml: selectedNote ? selectedNote.markup_language === MarkupToHtml.MARKUP_LANGUAGE_HTML : 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
|
// Current context folder
|
||||||
folderIsShareRoot: commandFolder ? isRootSharedFolder(commandFolder) : false,
|
folderIsShareRoot: commandFolder ? isRootSharedFolder(commandFolder) : false,
|
||||||
folderIsShareRootAndNotOwnedByUser: commandFolder ? isRootSharedFolder(commandFolder) && !isSharedFolderOwner(state, commandFolder.id) : false,
|
folderIsShareRootAndNotOwnedByUser: commandFolder ? isRootSharedFolder(commandFolder) && !isSharedFolderOwner(state, commandFolder.id) : false,
|
||||||
folderIsShareRootAndOwnedByUser: commandFolder ? isRootSharedFolder(commandFolder) && isSharedFolderOwner(state, commandFolder.id) : false,
|
folderIsShareRootAndOwnedByUser: commandFolder ? isRootSharedFolder(commandFolder) && isSharedFolderOwner(state, commandFolder.id) : false,
|
||||||
folderIsShared: commandFolder ? !!commandFolder.share_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']),
|
joplinServerConnected: [9, 10].includes(settings['sync.target']),
|
||||||
joplinCloudAccountType: settings['sync.target'] === 10 ? settings['sync.10.accountType'] : 0,
|
joplinCloudAccountType: settings['sync.target'] === 10 ? settings['sync.10.accountType'] : 0,
|
||||||
|
|
||||||
hasMultiProfiles: state.profileConfig && state.profileConfig.profiles.length > 1,
|
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;
|
let api: Api = null;
|
||||||
|
|
||||||
describe('services_rest_Api', () => {
|
describe('services/rest/Api', () => {
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
api = new Api();
|
api = new Api();
|
||||||
@ -71,7 +71,7 @@ describe('services_rest_Api', () => {
|
|||||||
|
|
||||||
it('should delete folders', (async () => {
|
it('should delete folders', (async () => {
|
||||||
const f1 = await Folder.save({ title: 'mon carnet' });
|
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);
|
const f1b = await Folder.load(f1.id);
|
||||||
expect(!f1b).toBe(true);
|
expect(!f1b).toBe(true);
|
||||||
|
@ -48,6 +48,9 @@ interface RequestQuery {
|
|||||||
|
|
||||||
// Event cursor
|
// Event cursor
|
||||||
cursor?: string;
|
cursor?: string;
|
||||||
|
|
||||||
|
// For note deletion
|
||||||
|
permanent?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Request {
|
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 defaultAction from '../utils/defaultAction';
|
||||||
import paginatedResults from '../utils/paginatedResults';
|
import paginatedResults from '../utils/paginatedResults';
|
||||||
import BaseModel from '../../../BaseModel';
|
import BaseModel from '../../../BaseModel';
|
||||||
@ -9,17 +9,20 @@ import { allForDisplay } from '../../../folders-screen-utils';
|
|||||||
const { ErrorNotFound } = require('../utils/errors');
|
const { ErrorNotFound } = require('../utils/errors');
|
||||||
|
|
||||||
export default async function(request: Request, id: string = null, link: string = null) {
|
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) {
|
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);
|
const output = await Folder.allAsTree(folders);
|
||||||
return output;
|
return output;
|
||||||
} else {
|
} 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') {
|
if (link && link === 'notes') {
|
||||||
const folder = await Folder.load(id);
|
const folder = await Folder.load(id);
|
||||||
return paginatedResults(BaseModel.TYPE_NOTE, request, { sql: 'parent_id = ?', params: [folder.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);
|
return defaultAction(BaseModel.TYPE_FOLDER, request, id, link);
|
||||||
}
|
}
|
||||||
|
@ -3,6 +3,9 @@ import shim from '../../../shim';
|
|||||||
import { downloadMediaFile } from './notes';
|
import { downloadMediaFile } from './notes';
|
||||||
import Setting from '../../../models/Setting';
|
import Setting from '../../../models/Setting';
|
||||||
import { readFile, readdir, remove, writeFile } from 'fs-extra';
|
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 md5 = require('md5');
|
||||||
|
|
||||||
const imagePath = `${__dirname}/../../../images/SideMenuHeader.png`;
|
const imagePath = `${__dirname}/../../../images/SideMenuHeader.png`;
|
||||||
@ -10,8 +13,10 @@ const jpgBase64Content = '/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgICAgMCAgIDAwMDBA
|
|||||||
|
|
||||||
describe('routes/notes', () => {
|
describe('routes/notes', () => {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
jest.resetAllMocks();
|
jest.resetAllMocks();
|
||||||
|
await setupDatabase(1);
|
||||||
|
await switchClient(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
@ -19,7 +24,7 @@ describe('routes/notes', () => {
|
|||||||
'htp/asdfasf.com',
|
'htp/asdfasf.com',
|
||||||
'https//joplinapp.org',
|
'https//joplinapp.org',
|
||||||
])('should not return a local file for invalid protocols', async (invalidUrl) => {
|
])('should not return a local file for invalid protocols', async (invalidUrl) => {
|
||||||
await expect(downloadMediaFile(invalidUrl)).resolves.toBe('');
|
expect(await downloadMediaFile(invalidUrl)).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
@ -127,4 +132,17 @@ describe('routes/notes', () => {
|
|||||||
await remove(response);
|
await remove(response);
|
||||||
spy.mockRestore();
|
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;
|
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);
|
return defaultAction(BaseModel.TYPE_NOTE, request, id, link);
|
||||||
}
|
}
|
||||||
|
@ -5,8 +5,9 @@ import paginatedResults from './paginatedResults';
|
|||||||
import readonlyProperties from './readonlyProperties';
|
import readonlyProperties from './readonlyProperties';
|
||||||
import requestFields from './requestFields';
|
import requestFields from './requestFields';
|
||||||
import BaseItem from '../../../models/BaseItem';
|
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
|
if (link) throw new ErrorNotFound(); // Default action doesn't support links at all for now
|
||||||
|
|
||||||
const ModelClass = BaseItem.getClassByItemType(modelType);
|
const ModelClass = BaseItem.getClassByItemType(modelType);
|
||||||
@ -23,7 +24,7 @@ export default async function(modelType: number, request: Request, id: string =
|
|||||||
fields: requestFields(request, modelType, defaultFields),
|
fields: requestFields(request, modelType, defaultFields),
|
||||||
});
|
});
|
||||||
} else {
|
} 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[] {
|
function defaultFieldsByModelType(modelType: number): string[] {
|
||||||
const ModelClass = BaseItem.getClassByItemType(modelType);
|
const ModelClass = BaseItem.getClassByItemType(modelType);
|
||||||
const possibleFields = ['id', 'parent_id', 'title'];
|
const possibleFields = ['id', 'parent_id', 'title', 'deleted_time'];
|
||||||
const output = [];
|
const output = [];
|
||||||
for (const f of possibleFields) {
|
for (const f of possibleFields) {
|
||||||
if (ModelClass.hasField(f)) output.push(f);
|
if (ModelClass.hasField(f)) output.push(f);
|
||||||
|
@ -12,6 +12,17 @@ const newSearchEngine = () => {
|
|||||||
return engine;
|
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', () => {
|
describe('SearchEngine.resources', () => {
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@ -39,12 +50,7 @@ describe('SearchEngine.resources', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return notes associated with indexed resources', (async () => {
|
it('should return notes associated with indexed resources', (async () => {
|
||||||
const note1 = await Note.save({});
|
const { note, resource } = await createNoteAndResource();
|
||||||
await Note.save({});
|
|
||||||
await shim.attachFileToNote(note1, `${ocrSampleDir}/testocr.png`);
|
|
||||||
const resource = (await Resource.all())[0];
|
|
||||||
|
|
||||||
await resourceService().indexNoteResources();
|
|
||||||
|
|
||||||
const ocrService = newOcrService();
|
const ocrService = newOcrService();
|
||||||
await ocrService.processResources();
|
await ocrService.processResources();
|
||||||
@ -54,13 +60,29 @@ describe('SearchEngine.resources', () => {
|
|||||||
|
|
||||||
const results = await searchEngine.search('lazy fox');
|
const results = await searchEngine.search('lazy fox');
|
||||||
expect(results.length).toBe(1);
|
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_id).toBe(resource.id);
|
||||||
expect(results[0].item_type).toBe(ModelType.Resource);
|
expect(results[0].item_type).toBe(ModelType.Resource);
|
||||||
|
|
||||||
await ocrService.dispose();
|
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 () => {
|
it('should delete normalized data when a resource is deleted', async () => {
|
||||||
const engine = newSearchEngine();
|
const engine = newSearchEngine();
|
||||||
|
|
||||||
|
@ -119,7 +119,7 @@ export default class SearchEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async doInitialNoteIndexing_() {
|
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 noteIds = notes.map(n => n.id);
|
||||||
|
|
||||||
const lastChangeId = await ItemChange.lastChangeId();
|
const lastChangeId = await ItemChange.lastChangeId();
|
||||||
@ -132,7 +132,7 @@ export default class SearchEngine {
|
|||||||
const notes = await Note.modelSelectAll(`
|
const notes = await Note.modelSelectAll(`
|
||||||
SELECT ${SearchEngine.relevantFields}
|
SELECT ${SearchEngine.relevantFields}
|
||||||
FROM notes
|
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 = [];
|
const queries = [];
|
||||||
|
|
||||||
for (let i = 0; i < notes.length; i++) {
|
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 noteIds = changes.map(a => a.item_id);
|
||||||
const notes = await Note.modelSelectAll(`
|
const notes = await Note.modelSelectAll(`
|
||||||
SELECT ${SearchEngine.relevantFields}
|
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++) {
|
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) {
|
for (const itemRow of itemRows) {
|
||||||
const notes = resourcesToNotes[itemRow.item_id];
|
const notes = resourcesToNotes[itemRow.item_id];
|
||||||
const note = notes && notes.length ? notes[0] : null;
|
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.id = note ? note.note_id : null;
|
||||||
itemRow.parent_id = note ? note.parent_id : null;
|
itemRow.parent_id = note ? note.parent_id : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!options.includeOrphanedResources) itemRows = itemRows.filter(r => !!r.id);
|
if (!options.includeOrphanedResources) itemRows = itemRows.filter(r => !!r.id);
|
||||||
|
itemRows = itemRows.filter(r => !deletedNoteIds.includes(r.id));
|
||||||
|
|
||||||
rows = rows.concat(itemRows);
|
rows = rows.concat(itemRows);
|
||||||
}
|
}
|
||||||
|
@ -7,7 +7,7 @@ const Note = require('../../models/Note').default;
|
|||||||
|
|
||||||
let searchEngine: any = null;
|
let searchEngine: any = null;
|
||||||
|
|
||||||
describe('services_SearchEngineUtils', () => {
|
describe('SearchEngineUtils', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await setupDatabaseAndSynchronizer(1);
|
await setupDatabaseAndSynchronizer(1);
|
||||||
await switchClient(1);
|
await switchClient(1);
|
||||||
|
@ -32,7 +32,7 @@ export default class SearchEngineUtils {
|
|||||||
const noteIds = results.map(n => n.id);
|
const noteIds = results.map(n => n.id);
|
||||||
|
|
||||||
// We need at least the note ID to be able to sort them below so if not
|
// 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.
|
// it can be removed afterwards.
|
||||||
let idWasAutoAdded = false;
|
let idWasAutoAdded = false;
|
||||||
const fields = options.fields ? options.fields : Note.previewFields().slice();
|
const fields = options.fields ? options.fields : Note.previewFields().slice();
|
||||||
|
@ -5,6 +5,7 @@ import Folder from '../../models/Folder';
|
|||||||
import Note from '../../models/Note';
|
import Note from '../../models/Note';
|
||||||
import BaseItem from '../../models/BaseItem';
|
import BaseItem from '../../models/BaseItem';
|
||||||
import { setEncryptionEnabled } from '../synchronizer/syncInfoUtils';
|
import { setEncryptionEnabled } from '../synchronizer/syncInfoUtils';
|
||||||
|
import { NoteEntity } from '../database/types';
|
||||||
|
|
||||||
describe('Synchronizer.conflicts', () => {
|
describe('Synchronizer.conflicts', () => {
|
||||||
|
|
||||||
@ -103,7 +104,7 @@ describe('Synchronizer.conflicts', () => {
|
|||||||
|
|
||||||
await Note.save({ title: 'note1', parent_id: folder1.id });
|
await Note.save({ title: 'note1', parent_id: folder1.id });
|
||||||
await synchronizerStart();
|
await synchronizerStart();
|
||||||
const items = await allNotesFolders();
|
const items: NoteEntity[] = await allNotesFolders();
|
||||||
expect(items.length).toBe(1);
|
expect(items.length).toBe(1);
|
||||||
expect(items[0].title).toBe('note1');
|
expect(items[0].title).toBe('note1');
|
||||||
expect(items[0].is_conflict).toBe(1);
|
expect(items[0].is_conflict).toBe(1);
|
||||||
|
@ -11,6 +11,7 @@ import BaseItem from '../../models/BaseItem';
|
|||||||
import Synchronizer from '../../Synchronizer';
|
import Synchronizer from '../../Synchronizer';
|
||||||
import { fetchSyncInfo, getEncryptionEnabled, localSyncInfo, setEncryptionEnabled } from '../synchronizer/syncInfoUtils';
|
import { fetchSyncInfo, getEncryptionEnabled, localSyncInfo, setEncryptionEnabled } from '../synchronizer/syncInfoUtils';
|
||||||
import { loadMasterKeysFromSettings, setupAndDisableEncryption, setupAndEnableEncryption } from '../e2ee/utils';
|
import { loadMasterKeysFromSettings, setupAndDisableEncryption, setupAndEnableEncryption } from '../e2ee/utils';
|
||||||
|
import { remoteNotesAndFolders } from '../../testing/test-utils-synchronizer';
|
||||||
|
|
||||||
let insideBeforeEach = false;
|
let insideBeforeEach = false;
|
||||||
|
|
||||||
@ -72,6 +73,25 @@ describe('Synchronizer.e2ee', () => {
|
|||||||
expect(!folder1_2.encryption_cipher_text).toBe(true);
|
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 () => {
|
it('should mark the key has having been used when synchronising the first time', (async () => {
|
||||||
setEncryptionEnabled(true);
|
setEncryptionEnabled(true);
|
||||||
await loadEncryptionMasterKey();
|
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
Reference in New Issue
Block a user