461 changes: 0 additions & 461 deletions packages/app-cli/app/app.js

This file was deleted.

1 change: 1 addition & 0 deletions packages/app-cli/app/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ class Application extends BaseApplication {
{ keys: ['tc'], type: 'function', command: 'toggle_console' },
{ keys: ['tm'], type: 'function', command: 'toggle_metadata' },
{ keys: ['ti'], type: 'function', command: 'toggle_ids' },
{ keys: ['r'], type: 'prompt', command: 'restore $n' },
{ keys: ['/'], type: 'prompt', command: 'search ""', cursorPosition: -2 },
{ keys: ['mn'], type: 'prompt', command: 'mknote ""', cursorPosition: -2 },
{ keys: ['mt'], type: 'prompt', command: 'mktodo ""', cursorPosition: -2 },
Expand Down
5 changes: 5 additions & 0 deletions packages/app-cli/app/command-apidoc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,11 @@ async function fetchAllNotes() {
lines.push('Remove the tag from the note.');
lines.push('');
}

if (model.type === BaseModel.TYPE_NOTE || model.type === BaseModel.TYPE_FOLDER) {
lines.push(`By default, the ${singular} will be moved **to the trash**. To permanently delete it, add the query parameter \`permanent=1\``);
lines.push('');
}
}

{
Expand Down
3 changes: 2 additions & 1 deletion packages/app-cli/app/command-dump.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import BaseCommand from './base-command';
import Folder from '@joplin/lib/models/Folder';
import Note from '@joplin/lib/models/Note';
import Tag from '@joplin/lib/models/Tag';
import { FolderEntity, NoteEntity } from '@joplin/lib/services/database/types';

class Command extends BaseCommand {
public override usage() {
Expand All @@ -17,7 +18,7 @@ class Command extends BaseCommand {
}

public override async action() {
let items = [];
let items: (NoteEntity | FolderEntity)[] = [];
const folders = await Folder.all();
for (let i = 0; i < folders.length; i++) {
const folder = folders[i];
Expand Down
7 changes: 4 additions & 3 deletions packages/app-cli/app/command-ls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import Setting from '@joplin/lib/models/Setting';
import Note from '@joplin/lib/models/Note';
const { sprintf } = require('sprintf-js');
import time from '@joplin/lib/time';
import { NoteEntity } from '@joplin/lib/services/database/types';
const { cliUtils } = require('./cli-utils.js');

class Command extends BaseCommand {
Expand Down Expand Up @@ -71,7 +72,7 @@ class Command extends BaseCommand {
let hasTodos = false;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.is_todo) {
if ((item as NoteEntity).is_todo) {
hasTodos = true;
break;
}
Expand Down Expand Up @@ -103,8 +104,8 @@ class Command extends BaseCommand {
}

if (hasTodos) {
if (item.is_todo) {
row.push(sprintf('[%s]', item.todo_completed ? 'X' : ' '));
if ((item as NoteEntity).is_todo) {
row.push(sprintf('[%s]', (item as NoteEntity).todo_completed ? 'X' : ' '));
} else {
row.push(' ');
}
Expand Down
26 changes: 26 additions & 0 deletions packages/app-cli/app/command-restore.ts
Original file line number Diff line number Diff line change
@@ -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;
6 changes: 4 additions & 2 deletions packages/app-cli/app/command-rmbook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import app from './app';
import { _ } from '@joplin/lib/locale';
import Folder from '@joplin/lib/models/Folder';
import BaseModel from '@joplin/lib/BaseModel';
const { substrWithEllipsis } = require('@joplin/lib/string-utils');

class Command extends BaseCommand {
public override usage() {
Expand All @@ -23,10 +24,11 @@ class Command extends BaseCommand {

const folder = await app().loadItem(BaseModel.TYPE_FOLDER, pattern);
if (!folder) throw new Error(_('Cannot find "%s".', pattern));
const ok = force ? true : await this.prompt(_('Delete notebook? All notes and sub-notebooks within this notebook will also be deleted.'), { booleanAnswerDefault: 'n' });
const msg = _('Move notebook "%s" to the trash?\n\nAll notes and sub-notebooks within this notebook will also be moved to the trash.', substrWithEllipsis(folder.title, 0, 32));
const ok = force ? true : await this.prompt(msg, { booleanAnswerDefault: 'n' });
if (!ok) return;

await Folder.delete(folder.id);
await Folder.delete(folder.id, { toTrash: true });
}
}

Expand Down
14 changes: 10 additions & 4 deletions packages/app-cli/app/command-rmnote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import app from './app';
import { _ } from '@joplin/lib/locale';
import Note from '@joplin/lib/models/Note';
import BaseModel from '@joplin/lib/BaseModel';
import { NoteEntity } from '@joplin/lib/services/database/types';

class Command extends BaseCommand {
public override usage() {
Expand All @@ -21,13 +22,18 @@ class Command extends BaseCommand {
const pattern = args['note-pattern'];
const force = args.options && args.options.force === true;

const notes = await app().loadItems(BaseModel.TYPE_NOTE, pattern);
const notes: NoteEntity[] = await app().loadItems(BaseModel.TYPE_NOTE, pattern);
if (!notes.length) throw new Error(_('Cannot find "%s".', pattern));

const ok = force ? true : await this.prompt(notes.length > 1 ? _('%d notes match this pattern. Delete them?', notes.length) : _('Delete note?'), { booleanAnswerDefault: 'n' });
let ok = true;
if (!force && notes.length > 1) {
ok = await this.prompt(_('%d notes match this pattern. Delete them?', notes.length), { booleanAnswerDefault: 'n' });
}

if (!ok) return;
const ids = notes.map((n: any) => n.id);
await Note.batchDelete(ids);

const ids = notes.map(n => n.id);
await Note.batchDelete(ids, { toTrash: true });
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
const Folder = require('@joplin/lib/models/Folder').default;
const Tag = require('@joplin/lib/models/Tag').default;
const BaseModel = require('@joplin/lib/BaseModel').default;
import Folder from '@joplin/lib/models/Folder';
import Tag from '@joplin/lib/models/Tag';
import BaseModel from '@joplin/lib/BaseModel';
import Setting from '@joplin/lib/models/Setting';
import { _ } from '@joplin/lib/locale';
import { FolderEntity } from '@joplin/lib/services/database/types';
import { getDisplayParentId, getTrashFolderId } from '@joplin/lib/services/trash';
const ListWidget = require('tkwidgets/ListWidget.js');
const Setting = require('@joplin/lib/models/Setting').default;
const _ = require('@joplin/lib/locale')._;

class FolderListWidget extends ListWidget {
constructor() {
export default class FolderListWidget extends ListWidget {

private folders_: FolderEntity[] = [];

public constructor() {
super();

this.tags_ = [];
this.folders_ = [];
this.searches_ = [];
this.selectedFolderId_ = null;
this.selectedTagId_ = null;
Expand All @@ -21,7 +25,7 @@ class FolderListWidget extends ListWidget {
this.trimItemTitle = false;
this.showIds = false;

this.itemRenderer = item => {
this.itemRenderer = (item: any) => {
const output = [];
if (item === '-') {
output.push('-'.repeat(this.innerWidth));
Expand All @@ -33,13 +37,12 @@ class FolderListWidget extends ListWidget {
}
output.push(Folder.displayTitle(item));

if (Setting.value('showNoteCounts')) {
if (Setting.value('showNoteCounts') && !item.deleted_time && item.id !== getTrashFolderId()) {
let noteCount = item.note_count;
// Subtract children note_count from parent folder.
if (this.folderHasChildren_(this.folders, item.id)) {
for (let i = 0; i < this.folders.length; i++) {
if (this.folders[i].parent_id === item.id) {
noteCount -= this.folders[i].note_count;
noteCount -= (this.folders[i] as any).note_count;
}
}
}
Expand All @@ -56,113 +59,121 @@ class FolderListWidget extends ListWidget {
};
}

folderDepth(folders, folderId) {
public folderDepth(folders: FolderEntity[], folderId: string) {
let output = 0;
while (true) {
const folder = BaseModel.byId(folders, folderId);
if (!folder || !folder.parent_id) return output;
const folderParentId = getDisplayParentId(folder, folders.find(f => f.id === folder.parent_id));
if (!folder || !folderParentId) return output;
output++;
folderId = folder.parent_id;
folderId = folderParentId;
}
}

get selectedFolderId() {
public get selectedFolderId() {
return this.selectedFolderId_;
}

set selectedFolderId(v) {
public set selectedFolderId(v) {
this.selectedFolderId_ = v;
this.updateIndexFromSelectedItemId();
this.invalidate();
}

get selectedSearchId() {
public get selectedSearchId() {
return this.selectedSearchId_;
}

set selectedSearchId(v) {
public set selectedSearchId(v) {
this.selectedSearchId_ = v;
this.updateIndexFromSelectedItemId();
this.invalidate();
}

get selectedTagId() {
public get selectedTagId() {
return this.selectedTagId_;
}

set selectedTagId(v) {
public set selectedTagId(v) {
this.selectedTagId_ = v;
this.updateIndexFromSelectedItemId();
this.invalidate();
}

get notesParentType() {
public get notesParentType() {
return this.notesParentType_;
}

set notesParentType(v) {
public set notesParentType(v) {
this.notesParentType_ = v;
this.updateIndexFromSelectedItemId();
this.invalidate();
}

get searches() {
public get searches() {
return this.searches_;
}

set searches(v) {
public set searches(v) {
this.searches_ = v;
this.updateItems_ = true;
this.updateIndexFromSelectedItemId();
this.invalidate();
}

get tags() {
public get tags() {
return this.tags_;
}

set tags(v) {
public set tags(v) {
this.tags_ = v;
this.updateItems_ = true;
this.updateIndexFromSelectedItemId();
this.invalidate();
}

get folders() {
public get folders() {
return this.folders_;
}

set folders(v) {
public set folders(v) {
this.folders_ = v;
this.updateItems_ = true;
this.updateIndexFromSelectedItemId();
this.invalidate();
}

toggleShowIds() {
public toggleShowIds() {
this.showIds = !this.showIds;
this.invalidate();
}

folderHasChildren_(folders, folderId) {
public folderHasChildren_(folders: FolderEntity[], folderId: string) {
for (let i = 0; i < folders.length; i++) {
const folder = folders[i];
if (folder.parent_id === folderId) return true;
const folderParentId = getDisplayParentId(folder, folders.find(f => f.id === folder.parent_id));
if (folderParentId === folderId) return true;
}
return false;
}

render() {
public render() {
if (this.updateItems_) {
this.logger().debug('Rebuilding items...', this.notesParentType, this.selectedJoplinItemId, this.selectedSearchId);
const wasSelectedItemId = this.selectedJoplinItemId;
const previousParentType = this.notesParentType;

let newItems = [];
const orderFolders = parentId => {
this.logger().info('FFFFFFFFFFFFF', JSON.stringify(this.folders, null, 4));

let newItems: any[] = [];
const orderFolders = (parentId: string) => {
this.logger().info('PARENT', parentId);
for (let i = 0; i < this.folders.length; i++) {
const f = this.folders[i];
const folderParentId = f.parent_id ? f.parent_id : '';
const originalParent = this.folders_.find(f => f.id === f.parent_id);

const folderParentId = getDisplayParentId(f, originalParent); // f.parent_id ? f.parent_id : '';
this.logger().info('FFF', f.title, folderParentId);
if (folderParentId === parentId) {
newItems.push(f);
if (this.folderHasChildren_(this.folders, f.id)) orderFolders(f.id);
Expand Down Expand Up @@ -192,25 +203,23 @@ class FolderListWidget extends ListWidget {
super.render();
}

get selectedJoplinItemId() {
public get selectedJoplinItemId() {
if (!this.notesParentType) return '';
if (this.notesParentType === 'Folder') return this.selectedFolderId;
if (this.notesParentType === 'Tag') return this.selectedTagId;
if (this.notesParentType === 'Search') return this.selectedSearchId;
throw new Error(`Unknown parent type: ${this.notesParentType}`);
}

get selectedJoplinItem() {
public get selectedJoplinItem() {
const id = this.selectedJoplinItemId;
const index = this.itemIndexByKey('id', id);
return this.itemAt(index);
}

updateIndexFromSelectedItemId(itemId = null) {
public updateIndexFromSelectedItemId(itemId: string = null) {
if (itemId === null) itemId = this.selectedJoplinItemId;
const index = this.itemIndexByKey('id', itemId);
this.currentIndex = index >= 0 ? index : 0;
}
}

module.exports = FolderListWidget;
21 changes: 11 additions & 10 deletions packages/app-desktop/bridge.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import ElectronAppWrapper from './ElectronAppWrapper';
import shim from '@joplin/lib/shim';
import { _, setLocale } from '@joplin/lib/locale';
import { BrowserWindow, nativeTheme, nativeImage, shell } from 'electron';
import { BrowserWindow, nativeTheme, nativeImage, dialog, shell, MessageBoxSyncOptions } from 'electron';
import { dirname, isUncPath, toSystemSlashes } from '@joplin/lib/path-utils';
import { fileUriToPath } from '@joplin/utils/url';
import { urlDecode } from '@joplin/lib/string-utils';
Expand All @@ -23,6 +23,10 @@ interface OpenDialogOptions {
filters?: any[];
}

interface MessageDialogOptions extends Omit<MessageBoxSyncOptions, 'message'> {
message?: string;
}

export class Bridge {

private electronWrapper_: ElectronAppWrapper;
Expand Down Expand Up @@ -228,7 +232,6 @@ export class Bridge {
}

public async showSaveDialog(options: any) {
const { dialog } = require('electron');
if (!options) options = {};
if (!('defaultPath' in options) && this.lastSelectedPaths_.file) options.defaultPath = this.lastSelectedPaths_.file;
const { filePath } = await dialog.showSaveDialog(this.window(), options);
Expand All @@ -239,7 +242,6 @@ export class Bridge {
}

public async showOpenDialog(options: OpenDialogOptions = null) {
const { dialog } = require('electron');
if (!options) options = {};
let fileType = 'file';
if (options.properties && options.properties.includes('openDirectory')) fileType = 'directory';
Expand All @@ -253,13 +255,12 @@ export class Bridge {
}

// Don't use this directly - call one of the showXxxxxxxMessageBox() instead
private showMessageBox_(window: any, options: any): number {
const { dialog } = require('electron');
private showMessageBox_(window: any, options: MessageDialogOptions): number {
if (!window) window = this.window();
return dialog.showMessageBoxSync(window, options);
return dialog.showMessageBoxSync(window, { message: '', ...options });
}

public showErrorMessageBox(message: string, options: any = null) {
public showErrorMessageBox(message: string, options: MessageDialogOptions = null) {
options = {
buttons: [_('OK')],
...options,
Expand All @@ -272,7 +273,7 @@ export class Bridge {
});
}

public showConfirmMessageBox(message: string, options: any = null) {
public showConfirmMessageBox(message: string, options: MessageDialogOptions = null) {
options = {
buttons: [_('OK'), _('Cancel')],
...options,
Expand All @@ -287,8 +288,8 @@ export class Bridge {
}

/* returns the index of the clicked button */
public showMessageBox(message: string, options: any = null) {
if (options === null) options = {};
public showMessageBox(message: string, options: MessageDialogOptions = null) {
if (options === null) options = { message: '' };

const result = this.showMessageBox_(this.window(), { type: 'question',
message: message,
Expand Down
24 changes: 24 additions & 0 deletions packages/app-desktop/commands/emptyTrash.ts
Original file line number Diff line number Diff line change
@@ -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();
},
};
};
2 changes: 2 additions & 0 deletions packages/app-desktop/commands/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// AUTO-GENERATED using `gulp buildScriptIndexes`
import * as copyDevCommand from './copyDevCommand';
import * as editProfileConfig from './editProfileConfig';
import * as emptyTrash from './emptyTrash';
import * as exportFolders from './exportFolders';
import * as exportNotes from './exportNotes';
import * as focusElement from './focusElement';
Expand All @@ -19,6 +20,7 @@ import * as toggleSafeMode from './toggleSafeMode';
const index: any[] = [
copyDevCommand,
editProfileConfig,
emptyTrash,
exportFolders,
exportNotes,
focusElement,
Expand Down
17 changes: 16 additions & 1 deletion packages/app-desktop/gui/MainScreen/MainScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import Sidebar from '../Sidebar/Sidebar';
import UserWebview from '../../services/plugins/UserWebview';
import UserWebviewDialog from '../../services/plugins/UserWebviewDialog';
import { ContainerType } from '@joplin/lib/services/plugins/WebviewController';
import { stateUtils } from '@joplin/lib/reducer';
import { StateLastDeletion, stateUtils } from '@joplin/lib/reducer';
import InteropServiceHelper from '../../InteropServiceHelper';
import { _ } from '@joplin/lib/locale';
import NoteListWrapper from '../NoteListWrapper/NoteListWrapper';
Expand Down Expand Up @@ -45,6 +45,8 @@ import restart from '../../services/restart';
const { connect } = require('react-redux');
import PromptDialog from '../PromptDialog';
import NotePropertiesDialog from '../NotePropertiesDialog';
import TrashNotification from '../TrashNotification/TrashNotification';

const PluginManager = require('@joplin/lib/services/PluginManager');
const ipcRenderer = require('electron').ipcRenderer;

Expand Down Expand Up @@ -83,6 +85,9 @@ interface Props {
processingShareInvitationResponse: boolean;
isResettingLayout: boolean;
listRendererId: string;
lastDeletion: StateLastDeletion;
lastDeletionNotificationTime: number;
selectedFolderId: string;
mustUpgradeAppMessage: string;
}

Expand Down Expand Up @@ -732,6 +737,7 @@ class MainScreenComponent extends React.Component<Props, State> {
themeId={this.props.themeId}
listRendererId={this.props.listRendererId}
startupPluginsLoaded={this.props.startupPluginsLoaded}
selectedFolderId={this.props.selectedFolderId}
/>;
},

Expand Down Expand Up @@ -880,6 +886,12 @@ class MainScreenComponent extends React.Component<Props, State> {

<PromptDialog autocomplete={promptOptions && 'autocomplete' in promptOptions ? promptOptions.autocomplete : null} defaultValue={promptOptions && promptOptions.value ? promptOptions.value : ''} themeId={this.props.themeId} style={styles.prompt} onClose={this.promptOnClose_} label={promptOptions ? promptOptions.label : ''} description={promptOptions ? promptOptions.description : null} visible={!!this.state.promptOptions} buttons={promptOptions && 'buttons' in promptOptions ? promptOptions.buttons : null} inputType={promptOptions && 'inputType' in promptOptions ? promptOptions.inputType : null} />

<TrashNotification
lastDeletion={this.props.lastDeletion}
lastDeletionNotificationTime={this.props.lastDeletionNotificationTime}
themeId={this.props.themeId}
dispatch={this.props.dispatch as any}
/>
{messageComp}
{layoutComp}
{pluginDialog}
Expand Down Expand Up @@ -918,6 +930,9 @@ const mapStateToProps = (state: AppState) => {
needApiAuth: state.needApiAuth,
isResettingLayout: state.isResettingLayout,
listRendererId: state.settings['notes.listRendererId'],
lastDeletion: state.lastDeletion,
lastDeletionNotificationTime: state.lastDeletionNotificationTime,
selectedFolderId: state.selectedFolderId,
mustUpgradeAppMessage: state.mustUpgradeAppMessage,
};
};
Expand Down
4 changes: 2 additions & 2 deletions packages/app-desktop/gui/MainScreen/commands/deleteFolder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,15 @@ export const runtime = (): CommandRuntime => {
const folder = await Folder.load(folderId);
if (!folder) throw new Error(`No such folder: ${folderId}`);

let deleteMessage = _('Delete notebook "%s"?\n\nAll notes and sub-notebooks within this notebook will also be deleted.', substrWithEllipsis(folder.title, 0, 32));
let deleteMessage = _('Move notebook "%s" to the trash?\n\nAll notes and sub-notebooks within this notebook will also be moved to the trash.', substrWithEllipsis(folder.title, 0, 32));
if (folderId === context.state.settings['sync.10.inboxId']) {
deleteMessage = _('Delete the Inbox notebook?\n\nIf you delete the inbox notebook, any email that\'s recently been sent to it may be lost.');
}

const ok = bridge().showConfirmMessageBox(deleteMessage);
if (!ok) return;

await Folder.delete(folderId);
await Folder.delete(folderId, { toTrash: true });
},
enabledCondition: '!folderIsReadOnly',
};
Expand Down
20 changes: 8 additions & 12 deletions packages/app-desktop/gui/MainScreen/commands/deleteNote.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import Note from '@joplin/lib/models/Note';
import bridge from '../../../services/bridge';

export const declaration: CommandDeclaration = {
name: 'deleteNote',
Expand All @@ -13,20 +12,17 @@ export const runtime = (): CommandRuntime => {
return {
execute: async (context: CommandContext, noteIds: string[] = null) => {
if (noteIds === null) noteIds = context.state.selectedNoteIds;

if (!noteIds.length) return;
await Note.batchDelete(noteIds, { toTrash: true });

const msg = await Note.deleteMessage(noteIds);
if (!msg) return;

const ok = bridge().showConfirmMessageBox(msg, {
buttons: [_('Delete'), _('Cancel')],
defaultId: 1,
context.dispatch({
type: 'ITEMS_TRASHED',
value: {
noteIds,
folderIds: [],
},
});

if (!ok) return;
await Note.batchDelete(noteIds);
},
enabledCondition: '!noteIsReadOnly',
enabledCondition: '!noteIsReadOnly && !inTrash && someNotesSelected',
};
};
3 changes: 2 additions & 1 deletion packages/app-desktop/gui/MainScreen/commands/editAlarm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { _ } from '@joplin/lib/locale';
import { stateUtils } from '@joplin/lib/reducer';
import Note from '@joplin/lib/models/Note';
import time from '@joplin/lib/time';
import { NoteEntity } from '@joplin/lib/services/database/types';

export const declaration: CommandDeclaration = {
name: 'editAlarm',
Expand All @@ -29,7 +30,7 @@ export const runtime = (comp: any): CommandRuntime => {
buttons: ['ok', 'cancel', 'clear'],
value: note.todo_due ? new Date(note.todo_due) : defaultDate,
onClose: async (answer: any, buttonType: string) => {
let newNote = null;
let newNote: NoteEntity = null;

if (buttonType === 'clear') {
newNote = {
Expand Down
6 changes: 6 additions & 0 deletions packages/app-desktop/gui/MainScreen/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@ import * as openItem from './openItem';
import * as openNote from './openNote';
import * as openPdfViewer from './openPdfViewer';
import * as openTag from './openTag';
import * as permanentlyDeleteNote from './permanentlyDeleteNote';
import * as print from './print';
import * as renameFolder from './renameFolder';
import * as renameTag from './renameTag';
import * as resetLayout from './resetLayout';
import * as restoreFolder from './restoreFolder';
import * as restoreNote from './restoreNote';
import * as revealResourceFile from './revealResourceFile';
import * as search from './search';
import * as setTags from './setTags';
Expand Down Expand Up @@ -66,10 +69,13 @@ const index: any[] = [
openNote,
openPdfViewer,
openTag,
permanentlyDeleteNote,
print,
renameFolder,
renameTag,
resetLayout,
restoreFolder,
restoreNote,
revealResourceFile,
search,
setTags,
Expand Down
4 changes: 3 additions & 1 deletion packages/app-desktop/gui/MainScreen/commands/newNote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { _ } from '@joplin/lib/locale';
import Setting from '@joplin/lib/models/Setting';
import Note from '@joplin/lib/models/Note';

export const newNoteEnabledConditions = 'oneFolderSelected && !inConflictFolder && !folderIsReadOnly && !folderIsTrash';

export const declaration: CommandDeclaration = {
name: 'newNote',
label: () => _('New note'),
Expand Down Expand Up @@ -36,6 +38,6 @@ export const runtime = (): CommandRuntime => {
type: 'NOTE_SORT',
});
},
enabledCondition: 'oneFolderSelected && !inConflictFolder && !folderIsReadOnly',
enabledCondition: newNoteEnabledConditions,
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@ export const runtime = (): CommandRuntime => {
parentId = parentId || context.state.selectedFolderId;
return CommandService.instance().execute('newFolder', parentId);
},
enabledCondition: '!folderIsReadOnly',
enabledCondition: '!folderIsReadOnly && !folderIsTrash',
};
};
3 changes: 2 additions & 1 deletion packages/app-desktop/gui/MainScreen/commands/newTodo.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import CommandService, { CommandContext, CommandDeclaration, CommandRuntime } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import { newNoteEnabledConditions } from './newNote';

export const declaration: CommandDeclaration = {
name: 'newTodo',
Expand All @@ -12,6 +13,6 @@ export const runtime = (): CommandRuntime => {
execute: async (_context: CommandContext, body = '') => {
return CommandService.instance().execute('newNote', body, true);
},
enabledCondition: 'oneFolderSelected && !inConflictFolder && !folderIsReadOnly',
enabledCondition: newNoteEnabledConditions,
};
};
Original file line number Diff line number Diff line change
@@ -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',
};
};
24 changes: 24 additions & 0 deletions packages/app-desktop/gui/MainScreen/commands/restoreFolder.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 23 additions & 0 deletions packages/app-desktop/gui/MainScreen/commands/restoreNote.ts
Original file line number Diff line number Diff line change
@@ -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',
};
};
2 changes: 1 addition & 1 deletion packages/app-desktop/gui/MainScreen/commands/setTags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,6 @@ export const runtime = (comp: any): CommandRuntime => {
},
});
},
enabledCondition: 'someNotesSelected',
enabledCondition: 'someNotesSelected && !inTrash',
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,6 @@ export const runtime = (comp: any): CommandRuntime => {
},
});
},
enabledCondition: 'joplinServerConnected && someNotesSelected',
enabledCondition: 'joplinServerConnected && someNotesSelected && !noteIsDeleted',
};
};
2 changes: 2 additions & 0 deletions packages/app-desktop/gui/MenuBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -837,6 +837,8 @@ function useMenu(props: Props) {
separator(),
menuItemDic.showNoteProperties,
menuItemDic.showNoteContentProperties,
separator(),
menuItemDic.permanentlyDeleteNote,
],
},
tools: {
Expand Down
4 changes: 3 additions & 1 deletion packages/app-desktop/gui/NoteEditor/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@ export async function htmlToMarkdown(markupLanguage: number, html: string, origi
export async function formNoteToNote(formNote: FormNote): Promise<any> {
return {
id: formNote.id,
// Should also include parent_id so that the reducer can know in which folder the note should go when saving
// Should also include parent_id and deleted_time so that the reducer
// can know in which folder the note should go when saving.
// https://discourse.joplinapp.org/t/experimental-wysiwyg-editor-in-joplin/6915/57?u=laurent
parent_id: formNote.parent_id,
deleted_time: formNote.deleted_time,
title: formNote.title,
body: formNote.body,
};
Expand Down
2 changes: 2 additions & 0 deletions packages/app-desktop/gui/NoteEditor/utils/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ export interface FormNote {
markup_language: number;
user_updated_time: number;
encryption_applied: number;
deleted_time: number;

hasChanged: boolean;

Expand Down Expand Up @@ -173,6 +174,7 @@ export function defaultFormNote(): FormNote {
return {
id: '',
parent_id: '',
deleted_time: 0,
title: '',
body: '',
is_todo: 0,
Expand Down
4 changes: 3 additions & 1 deletion packages/app-desktop/gui/NoteEditor/utils/useFormNote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import Note from '@joplin/lib/models/Note';
import { reg } from '@joplin/lib/registry';
import ResourceFetcher from '@joplin/lib/services/ResourceFetcher';
import DecryptionWorker from '@joplin/lib/services/DecryptionWorker';
import { NoteEntity } from '@joplin/lib/services/database/types';

export interface OnLoadEvent {
formNote: FormNote;
Expand Down Expand Up @@ -77,7 +78,7 @@ export default function useFormNote(dependencies: HookDependencies) {
// a new refresh.
const [formNoteRefreshScheduled, setFormNoteRefreshScheduled] = useState<number>(0);

async function initNoteState(n: any) {
async function initNoteState(n: NoteEntity) {
let originalCss = '';

if (n.markup_language === MarkupToHtml.MARKUP_LANGUAGE_HTML) {
Expand All @@ -91,6 +92,7 @@ export default function useFormNote(dependencies: HookDependencies) {
body: n.body,
is_todo: n.is_todo,
parent_id: n.parent_id,
deleted_time: n.deleted_time,
bodyWillChangeId: 0,
bodyChangeId: 0,
markup_language: n.markup_language,
Expand Down
7 changes: 6 additions & 1 deletion packages/app-desktop/gui/NoteList/NoteList2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import * as focusElementNoteList from './commands/focusElementNoteList';
import CommandService from '@joplin/lib/services/CommandService';
import useDragAndDrop from './utils/useDragAndDrop';
import usePrevious from '../hooks/usePrevious';
import { itemIsInTrash } from '@joplin/lib/services/trash';
import Folder from '@joplin/lib/models/Folder';
const { connect } = require('react-redux');

const commands = {
Expand Down Expand Up @@ -74,6 +76,7 @@ const NoteList = (props: Props) => {
props.uncompletedTodosOnTop,
props.showCompletedTodos,
props.notes,
props.selectedFolderInTrash,
);

const noteItemStyle = useMemo(() => {
Expand Down Expand Up @@ -136,6 +139,7 @@ const NoteList = (props: Props) => {
props.showCompletedTodos,
listRenderer.flow,
itemsPerLine,
props.selectedFolderInTrash,
);

const previousSelectedNoteIds = usePrevious(props.selectedNoteIds, []);
Expand Down Expand Up @@ -264,7 +268,7 @@ const NoteList = (props: Props) => {
};

const mapStateToProps = (state: AppState) => {
const selectedFolder: FolderEntity = state.notesParentType === 'Folder' ? BaseModel.byId(state.folders, state.selectedFolderId) : null;
const selectedFolder: FolderEntity = state.notesParentType === 'Folder' ? Folder.byId(state.folders, state.selectedFolderId) : null;
const userId = state.settings['sync.userId'];

return {
Expand All @@ -287,6 +291,7 @@ const mapStateToProps = (state: AppState) => {
customCss: state.customCss,
focusedField: state.focusedField,
parentFolderIsReadOnly: state.notesParentType === 'Folder' && selectedFolder ? itemIsReadOnlySync(ModelType.Folder, ItemChange.SOURCE_UNSPECIFIED, selectedFolder as ItemSlice, userId, state.shareService) : false,
selectedFolderInTrash: itemIsInTrash(selectedFolder),
};
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { _ } from '@joplin/lib/locale';
import Setting from '@joplin/lib/models/Setting';
import bridge from '../../../services/bridge';

const canManuallySortNotes = (notesParentType: string, noteSortOrder: string) => {
const canManuallySortNotes = (notesParentType: string, noteSortOrder: string, selectedFolderInTrash: boolean) => {
if (notesParentType !== 'Folder') return false;
if (selectedFolderInTrash) return false;

if (noteSortOrder !== 'order') {
const doIt = bridge().showConfirmMessageBox(_('To manually sort the notes, the sort order must be changed to "%s" in the menu "%s" > "%s"', _('Custom order'), _('View'), _('Sort notes by')), {
Expand Down
1 change: 1 addition & 0 deletions packages/app-desktop/gui/NoteList/utils/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export interface Props {
focusedField: string;
parentFolderIsReadOnly: boolean;
listRenderer: ListRenderer;
selectedFolderInTrash: boolean;
}

export enum BaseBreakpoint {
Expand Down
8 changes: 5 additions & 3 deletions packages/app-desktop/gui/NoteList/utils/useDragAndDrop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const useDragAndDrop = (
showCompletedTodos: boolean,
flow: ItemFlow,
itemsPerLine: number,
selectedFolderInTrash: boolean,
) => {
const [dragOverTargetNoteIndex, setDragOverTargetNoteIndex] = useState(null);

Expand Down Expand Up @@ -72,6 +73,7 @@ const useDragAndDrop = (

const onDragOver: DragEventHandler = useCallback(event => {
if (notesParentType !== 'Folder') return;
if (selectedFolderInTrash) return;

const dt = event.dataTransfer;

Expand All @@ -81,11 +83,11 @@ const useDragAndDrop = (
if (dragOverTargetNoteIndex === newIndex) return;
setDragOverTargetNoteIndex(newIndex);
}
}, [notesParentType, dragTargetNoteIndex, dragOverTargetNoteIndex]);
}, [notesParentType, dragTargetNoteIndex, dragOverTargetNoteIndex, selectedFolderInTrash]);

const onDrop: DragEventHandler = useCallback(async (event: any) => {
// TODO: check that parent type is folder
if (!canManuallySortNotes(notesParentType, noteSortOrder)) return;
if (!canManuallySortNotes(notesParentType, noteSortOrder, selectedFolderInTrash)) return;

const dt = event.dataTransfer;
setDragOverTargetNoteIndex(null);
Expand All @@ -94,7 +96,7 @@ const useDragAndDrop = (
const noteIds: string[] = JSON.parse(dt.getData('text/x-jop-note-ids'));

await Note.insertNotesAt(selectedFolderId, noteIds, targetNoteIndex, uncompletedTodosOnTop, showCompletedTodos);
}, [notesParentType, dragTargetNoteIndex, noteSortOrder, selectedFolderId, uncompletedTodosOnTop, showCompletedTodos]);
}, [notesParentType, dragTargetNoteIndex, noteSortOrder, selectedFolderId, uncompletedTodosOnTop, showCompletedTodos, selectedFolderInTrash]);

return { onDragStart, onDragOver, onDrop, dragOverTargetNoteIndex };
};
Expand Down
6 changes: 3 additions & 3 deletions packages/app-desktop/gui/NoteList/utils/useMoveNote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import { NoteEntity } from '@joplin/lib/services/database/types';
import { useCallback } from 'react';
import canManuallySortNotes from './canManuallySortNotes';

const useMoveNote = (notesParentType: string, noteSortOrder: string, selectedNoteIds: string[], selectedFolderId: string, uncompletedTodosOnTop: boolean, showCompletedTodos: boolean, notes: NoteEntity[]) => {
const useMoveNote = (notesParentType: string, noteSortOrder: string, selectedNoteIds: string[], selectedFolderId: string, uncompletedTodosOnTop: boolean, showCompletedTodos: boolean, notes: NoteEntity[], selectedFolderInTrash: boolean) => {
const moveNote = useCallback((direction: number, inc: number) => {
if (!canManuallySortNotes(notesParentType, noteSortOrder)) return;
if (!canManuallySortNotes(notesParentType, noteSortOrder, selectedFolderInTrash)) return;

const noteId = selectedNoteIds[0];
let targetNoteIndex = BaseModel.modelIndexById(notes, noteId);
Expand All @@ -17,7 +17,7 @@ const useMoveNote = (notesParentType: string, noteSortOrder: string, selectedNot
targetNoteIndex -= inc;
}
void Note.insertNotesAt(selectedFolderId, selectedNoteIds, targetNoteIndex, uncompletedTodosOnTop, showCompletedTodos);
}, [selectedFolderId, noteSortOrder, notes, notesParentType, selectedNoteIds, uncompletedTodosOnTop, showCompletedTodos]);
}, [selectedFolderId, noteSortOrder, notes, notesParentType, selectedNoteIds, uncompletedTodosOnTop, showCompletedTodos, selectedFolderInTrash]);

return moveNote;
};
Expand Down
4 changes: 3 additions & 1 deletion packages/app-desktop/gui/NoteList/utils/useOnKeyDown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@ const useOnKeyDown = (

if (noteIds.length && (key === 'Delete' || (key === 'Backspace' && event.metaKey))) {
event.preventDefault();
void CommandService.instance().execute('deleteNote', noteIds);
if (CommandService.instance().isEnabled('deleteNote')) {
void CommandService.instance().execute('deleteNote', noteIds);
}
}

if (noteIds.length && key === ' ') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { _ } from '@joplin/lib/locale';
const { connect } = require('react-redux');
import styled from 'styled-components';
import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext';
import { getTrashFolderId } from '@joplin/lib/services/trash';
import { Breakpoints } from '../NoteList/utils/types';

interface Props {
Expand Down Expand Up @@ -265,7 +266,7 @@ const mapStateToProps = (state: AppState) => {
const whenClauseContext = stateToWhenClauseContext(state);

return {
showNewNoteButtons: true,
showNewNoteButtons: state.selectedFolderId !== getTrashFolderId(),
newNoteButtonEnabled: CommandService.instance().isEnabled('newNote', whenClauseContext),
newTodoButtonEnabled: CommandService.instance().isEnabled('newTodo', whenClauseContext),
sortOrderButtonsVisible: state.settings['notes.sortOrder.buttonsVisible'],
Expand Down
11 changes: 8 additions & 3 deletions packages/app-desktop/gui/NoteListWrapper/NoteListWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import Logger from '@joplin/utils/Logger';
import { _ } from '@joplin/lib/locale';
import { BaseBreakpoint, Breakpoints } from '../NoteList/utils/types';
import { ButtonSize, buttonSizePx } from '../Button/Button';
import { getTrashFolderId } from '@joplin/lib/services/trash';

const logger = Logger.create('NoteListWrapper');

Expand All @@ -20,6 +21,7 @@ interface Props {
themeId: number;
listRendererId: string;
startupPluginsLoaded: boolean;
selectedFolderId: string;
}

const StyledRoot = styled.div`
Expand All @@ -31,7 +33,7 @@ const StyledRoot = styled.div`

// Even though these calculations mostly concern the NoteListControls component, we do them here
// because we need to know the height of that control to calculate the note list height.
const useNoteListControlsBreakpoints = (width: number, newNoteRef: React.MutableRefObject<any>) => {
const useNoteListControlsBreakpoints = (width: number, newNoteRef: React.MutableRefObject<any>, selectedFolderId: string) => {
const [dynamicBreakpoints, setDynamicBreakpoints] = useState<Breakpoints>({ Sm: BaseBreakpoint.Sm, Md: BaseBreakpoint.Md, Lg: BaseBreakpoint.Lg, Xl: BaseBreakpoint.Xl });

const getTextWidth = useCallback((text: string): number => {
Expand All @@ -47,9 +49,12 @@ const useNoteListControlsBreakpoints = (width: number, newNoteRef: React.Mutable
return ctx.measureText(text).width;
}, [newNoteRef]);

const showNewNoteButton = selectedFolderId !== getTrashFolderId();

// Initialize language-specific breakpoints
useEffect(() => {
if (!newNoteRef.current) return;
if (showNewNoteButton) return;

// Use the longest string to calculate the amount of extra width needed
const smAdditional = getTextWidth(_('note')) > getTextWidth(_('to-do')) ? getTextWidth(_('note')) : getTextWidth(_('to-do'));
Expand All @@ -61,7 +66,7 @@ const useNoteListControlsBreakpoints = (width: number, newNoteRef: React.Mutable
const Xl = BaseBreakpoint.Xl;

setDynamicBreakpoints({ Sm, Md, Lg, Xl });
}, [newNoteRef, getTextWidth]);
}, [newNoteRef, getTextWidth, showNewNoteButton]);

const breakpoint: number = useMemo(() => {
// Find largest breakpoint that width is less than
Expand Down Expand Up @@ -95,7 +100,7 @@ export default function NoteListWrapper(props: Props) {
const listRenderer = useListRenderer(props.listRendererId, props.startupPluginsLoaded);
const newNoteButtonRef = useRef(null);

const { breakpoint, dynamicBreakpoints, lineCount } = useNoteListControlsBreakpoints(props.size.width, newNoteButtonRef);
const { breakpoint, dynamicBreakpoints, lineCount } = useNoteListControlsBreakpoints(props.size.width, newNoteButtonRef, props.selectedFolderId);

const noteListControlsButtonSize = ButtonSize.Small;
const noteListControlsPadding = theme.mainPadding;
Expand Down
66 changes: 41 additions & 25 deletions packages/app-desktop/gui/NotePropertiesDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,21 @@ interface Props {
themeId: number;
}

interface FormNote {
id: string;
deleted_time: string;
location: string;
markup_language: string;
revisionsLink: string;
source_url: string;
todo_completed?: string;
user_created_time: string;
user_updated_time: string;
}

interface State {
editedKey: string;
formNote: any;
formNote: FormNote;
editedValue: any;
}

Expand Down Expand Up @@ -50,6 +62,7 @@ class NotePropertiesDialog extends React.Component<Props, State> {
id: _('ID'),
user_created_time: _('Created'),
user_updated_time: _('Updated'),
deleted_time: _('Deleted'),
todo_completed: _('Completed'),
location: _('Location'),
source_url: _('URL'),
Expand All @@ -64,7 +77,7 @@ class NotePropertiesDialog extends React.Component<Props, State> {

public componentDidUpdate() {
if (this.state.editedKey === null) {
this.okButton.current.focus();
if (this.okButton.current) this.okButton.current.focus();
}
}

Expand All @@ -78,6 +91,10 @@ class NotePropertiesDialog extends React.Component<Props, State> {
}
}

private isReadOnly() {
return this.state.formNote && !!this.state.formNote.deleted_time;
}

public latLongFromLocation(location: string) {
const o: any = {};
const l = location.split(',');
Expand All @@ -92,36 +109,35 @@ class NotePropertiesDialog extends React.Component<Props, State> {
}

public noteToFormNote(note: NoteEntity) {
const formNote: any = {};

formNote.user_updated_time = time.formatMsToLocal(note.user_updated_time);
formNote.user_created_time = time.formatMsToLocal(note.user_created_time);
const formNote: FormNote = {
id: note.id,
user_updated_time: time.formatMsToLocal(note.user_updated_time),
user_created_time: time.formatMsToLocal(note.user_created_time),
source_url: note.source_url,
location: '',
revisionsLink: note.id,
markup_language: Note.markupLanguageToLabel(note.markup_language),
deleted_time: note.deleted_time ? time.formatMsToLocal(note.deleted_time) : '',
};

if (note.todo_completed) {
formNote.todo_completed = time.formatMsToLocal(note.todo_completed);
}

formNote.source_url = note.source_url;

formNote.location = '';
if (Number(note.latitude) || Number(note.longitude)) {
formNote.location = `${note.latitude}, ${note.longitude}`;
}

formNote.revisionsLink = note.id;
formNote.markup_language = Note.markupLanguageToLabel(note.markup_language);
formNote.id = note.id;

return formNote;
}

public formNoteToNote(formNote: any) {
const note = { id: formNote.id, ...this.latLongFromLocation(formNote.location) };
public formNoteToNote(formNote: FormNote) {
const note: NoteEntity = { id: formNote.id, ...this.latLongFromLocation(formNote.location) };
note.user_created_time = time.formatLocalToMs(formNote.user_created_time);
note.user_updated_time = time.formatLocalToMs(formNote.user_updated_time);

if (formNote.todo_completed) {
note.todo_completed = time.formatMsToLocal(formNote.todo_completed);
note.todo_completed = time.formatLocalToMs(formNote.todo_completed);
}

note.source_url = formNote.source_url;
Expand Down Expand Up @@ -218,9 +234,9 @@ class NotePropertiesDialog extends React.Component<Props, State> {

if (this.state.editedKey.indexOf('_time') >= 0) {
const dt = time.anythingToDateTime(this.state.editedValue, new Date());
newFormNote[this.state.editedKey] = time.formatMsToLocal(dt.getTime());
(newFormNote as any)[this.state.editedKey] = time.formatMsToLocal(dt.getTime());
} else {
newFormNote[this.state.editedKey] = this.state.editedValue;
(newFormNote as any)[this.state.editedKey] = this.state.editedValue;
}

this.setState(
Expand All @@ -239,7 +255,7 @@ class NotePropertiesDialog extends React.Component<Props, State> {
public async cancelProperty() {
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
return new Promise((resolve: Function) => {
this.okButton.current.focus();
if (this.okButton.current) this.okButton.current.focus();
this.setState({
editedKey: null,
editedValue: null,
Expand All @@ -249,7 +265,7 @@ class NotePropertiesDialog extends React.Component<Props, State> {
});
}

public createNoteField(key: string, value: any) {
public createNoteField(key: keyof FormNote, value: any) {
const styles = this.styles(this.props.themeId);
const theme = themeStyle(this.props.themeId);
const labelComp = <label style={{ ...theme.textStyle, ...theme.controlBoxLabel }}>{this.formatLabel(key)}</label>;
Expand Down Expand Up @@ -351,7 +367,7 @@ class NotePropertiesDialog extends React.Component<Props, State> {
}
}

if (editCompHandler) {
if (editCompHandler && !this.isReadOnly()) {
editComp = (
<a href="#" onClick={editCompHandler} style={styles.editPropertyButton}>
<i className={`fas ${editCompIcon}`} aria-hidden="true"></i>
Expand Down Expand Up @@ -394,9 +410,9 @@ class NotePropertiesDialog extends React.Component<Props, State> {
const noteComps = [];

if (formNote) {
for (const key in formNote) {
if (!formNote.hasOwnProperty(key)) continue;
const comp = this.createNoteField(key, formNote[key]);
for (const key of Object.keys(formNote)) {
if (key === 'deleted_time' && !formNote.deleted_time) continue;
const comp = this.createNoteField(key as (keyof FormNote), (formNote as any)[key]);
noteComps.push(comp);
}
}
Expand All @@ -406,7 +422,7 @@ class NotePropertiesDialog extends React.Component<Props, State> {
<div style={theme.dialogBox}>
<div style={theme.dialogTitle}>{_('Note properties')}</div>
<div>{noteComps}</div>
<DialogButtonRow themeId={this.props.themeId} okButtonRef={this.okButton} onClick={this.buttonRow_click}/>
<DialogButtonRow themeId={this.props.themeId} okButtonShow={!this.isReadOnly()} okButtonRef={this.okButton} onClick={this.buttonRow_click}/>
</div>
</div>
);
Expand Down
11 changes: 11 additions & 0 deletions packages/app-desktop/gui/NotyfContext.tsx
Original file line number Diff line number Diff line change
@@ -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,
}),
);
257 changes: 141 additions & 116 deletions packages/app-desktop/gui/Sidebar/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,20 @@ import { AppState } from '../../app.reducer';
import { ModelType } from '@joplin/lib/BaseModel';
import BaseModel from '@joplin/lib/BaseModel';
import Folder from '@joplin/lib/models/Folder';
import Note from '@joplin/lib/models/Note';
import Tag from '@joplin/lib/models/Tag';
import Logger from '@joplin/utils/Logger';
import { FolderEntity, FolderIcon, FolderIconType } from '@joplin/lib/services/database/types';
import { FolderEntity, FolderIcon, FolderIconType, TagEntity } from '@joplin/lib/services/database/types';
import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext';
import { store } from '@joplin/lib/reducer';
import PerFolderSortOrderService from '../../services/sortOrder/PerFolderSortOrderService';
import { getFolderCallbackUrl, getTagCallbackUrl } from '@joplin/lib/callbackUrlUtils';
import FolderIconBox from '../FolderIconBox';
import onFolderDrop from '@joplin/lib/models/utils/onFolderDrop';
import { Theme } from '@joplin/lib/themes/type';
import { RuntimeProps } from './commands/focusElementSideBar';
const { connect } = require('react-redux');
import { renderFolders, renderTags } from '@joplin/lib/components/shared/side-menu-shared';
import { getTrashFolderIcon, getTrashFolderId } from '@joplin/lib/services/trash';
const { themeStyle } = require('@joplin/lib/theme');
const bridge = require('@electron/remote').require('./bridge').default;
const Menu = bridge().Menu;
Expand All @@ -42,7 +43,7 @@ interface Props {
themeId: number;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
dispatch: Function;
folders: any[];
folders: FolderEntity[];
collapsedFolderIds: string[];
notesParentType: string;
selectedFolderId: string;
Expand All @@ -51,7 +52,7 @@ interface Props {
decryptionWorker: any;
resourceFetcher: any;
syncReport: any;
tags: any[];
tags: TagEntity[];
syncStarted: boolean;
plugins: PluginStates;
folderHeaderIsExpanded: boolean;
Expand Down Expand Up @@ -97,11 +98,20 @@ function FolderItem(props: any) {
const { hasChildren, showFolderIcon, isExpanded, parentId, depth, selected, folderId, folderTitle, folderIcon, anchorRef, noteCount, onFolderDragStart_, onFolderDragOver_, onFolderDrop_, itemContextMenu, folderItem_click, onFolderToggleClick_, shareId } = props;

const noteCountComp = noteCount ? <StyledNoteCount className="note-count-label">{noteCount}</StyledNoteCount> : null;

const shareIcon = shareId && !parentId ? <StyledShareIcon className="fas fa-share-alt"></StyledShareIcon> : null;
const draggable = ![getTrashFolderId(), Folder.conflictFolderId()].includes(folderId);

const doRenderFolderIcon = () => {
if (folderId === getTrashFolderId()) {
return renderFolderIcon(getTrashFolderIcon(FolderIconType.FontAwesome));
}

if (!showFolderIcon) return null;
return renderFolderIcon(folderIcon);
};

return (
<StyledListItem depth={depth} selected={selected} className={`list-item-container list-item-depth-${depth} ${selected ? 'selected' : ''}`} onDragStart={onFolderDragStart_} onDragOver={onFolderDragOver_} onDrop={onFolderDrop_} draggable={true} data-folder-id={folderId}>
<StyledListItem depth={depth} selected={selected} className={`list-item-container list-item-depth-${depth} ${selected ? 'selected' : ''}`} onDragStart={onFolderDragStart_} onDragOver={onFolderDragOver_} onDrop={onFolderDrop_} draggable={draggable} data-folder-id={folderId}>
<ExpandLink themeId={props.themeId} hasChildren={hasChildren} folderId={folderId} onClick={onFolderToggleClick_} isExpanded={isExpanded}/>
<StyledListItemAnchor
ref={anchorRef}
Expand All @@ -119,7 +129,7 @@ function FolderItem(props: any) {
}}
onDoubleClick={onFolderToggleClick_}
>
{showFolderIcon ? renderFolderIcon(folderIcon) : null}<StyledSpanFix className="title" style={{ lineHeight: 0 }}>{folderTitle}</StyledSpanFix>
{doRenderFolderIcon()}<StyledSpanFix className="title" style={{ lineHeight: 0 }}>{folderTitle}</StyledSpanFix>
{shareIcon} {noteCountComp}
</StyledListItemAnchor>
</StyledListItem>
Expand Down Expand Up @@ -220,20 +230,13 @@ const SidebarComponent = (props: Props) => {
try {
if (dt.types.indexOf('text/x-jop-note-ids') >= 0) {
event.preventDefault();

if (!folderId) return;

const noteIds = JSON.parse(dt.getData('text/x-jop-note-ids'));
for (let i = 0; i < noteIds.length; i++) {
await Note.moveToFolder(noteIds[i], folderId);
}
await onFolderDrop(noteIds, [], folderId);
} else if (dt.types.indexOf('text/x-jop-folder-ids') >= 0) {
event.preventDefault();

const folderIds = JSON.parse(dt.getData('text/x-jop-folder-ids'));
for (let i = 0; i < folderIds.length; i++) {
await Folder.moveToFolder(folderIds[i], folderId);
}
await onFolderDrop([], folderIds, folderId);
}
} catch (error) {
logger.error(error);
Expand Down Expand Up @@ -296,131 +299,149 @@ const SidebarComponent = (props: Props) => {

const menu = new Menu();

let item = null;
if (itemType === BaseModel.TYPE_FOLDER) {
item = BaseModel.byId(props.folders, itemId);
}

if (itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied) {
if (itemId === getTrashFolderId()) {
menu.append(
new MenuItem(menuUtils.commandToStatefulMenuItem('newFolder', itemId)),
new MenuItem(menuUtils.commandToStatefulMenuItem('emptyTrash')),
);
menu.popup({ window: bridge().window() });
return;
}

let item = null;
if (itemType === BaseModel.TYPE_FOLDER) {
menu.append(
new MenuItem(menuUtils.commandToStatefulMenuItem('deleteFolder', itemId)),
);
} else {
menu.append(
new MenuItem({
label: deleteButtonLabel,
click: async () => {
const ok = bridge().showConfirmMessageBox(deleteMessage, {
buttons: [deleteButtonLabel, _('Cancel')],
defaultId: 1,
});
if (!ok) return;

if (itemType === BaseModel.TYPE_TAG) {
await Tag.untagAll(itemId);
} else if (itemType === BaseModel.TYPE_SEARCH) {
props.dispatch({
type: 'SEARCH_DELETE',
id: itemId,
});
}
},
}),
);
item = BaseModel.byId(props.folders, itemId);
}

if (itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied) {
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('openFolderDialog', { folderId: itemId })));

menu.append(new MenuItem({ type: 'separator' }));
const isDeleted = item ? !!item.deleted_time : false;

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;
if (!isDeleted) {
if (itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied) {
menu.append(
new MenuItem(menuUtils.commandToStatefulMenuItem('newFolder', itemId)),
);
}

exportMenu.append(
if (itemType === BaseModel.TYPE_FOLDER) {
menu.append(
new MenuItem(menuUtils.commandToStatefulMenuItem('deleteFolder', itemId)),
);
} else {
menu.append(
new MenuItem({
label: module.fullLabel(),
label: deleteButtonLabel,
click: async () => {
await InteropServiceHelper.export(props.dispatch, module, { sourceFolderIds: [itemId], plugins: pluginsRef.current });
const ok = bridge().showConfirmMessageBox(deleteMessage, {
buttons: [deleteButtonLabel, _('Cancel')],
defaultId: 1,
});
if (!ok) return;

if (itemType === BaseModel.TYPE_TAG) {
await Tag.untagAll(itemId);
} else if (itemType === BaseModel.TYPE_SEARCH) {
props.dispatch({
type: 'SEARCH_DELETE',
id: itemId,
});
}
},
}),
);
}

// We don't display the "Share notebook" menu item for sub-notebooks
// that are within a shared notebook. If user wants to do this,
// they'd have to move the notebook out of the shared notebook
// first.
const whenClause = stateToWhenClauseContext(state, { commandFolderId: itemId });
if (itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied) {
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('openFolderDialog', { folderId: itemId })));

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({
label: module.fullLabel(),
click: async () => {
await InteropServiceHelper.export(props.dispatch, module, { sourceFolderIds: [itemId], plugins: pluginsRef.current });
},
}),
);
}

if (CommandService.instance().isEnabled('showShareFolderDialog', whenClause)) {
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('showShareFolderDialog', itemId)));
}
// We don't display the "Share notebook" menu item for sub-notebooks
// that are within a shared notebook. If user wants to do this,
// they'd have to move the notebook out of the shared notebook
// first.
const whenClause = stateToWhenClauseContext(state, { commandFolderId: itemId });

if (CommandService.instance().isEnabled('leaveSharedFolder', whenClause)) {
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('leaveSharedFolder', itemId)));
}
if (CommandService.instance().isEnabled('showShareFolderDialog', whenClause)) {
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('showShareFolderDialog', itemId)));
}

menu.append(
new MenuItem({
label: _('Export'),
submenu: exportMenu,
}),
);
if (Setting.value('notes.perFolderSortOrderEnabled')) {
menu.append(new MenuItem({
...menuUtils.commandToStatefulMenuItem('togglePerFolderSortOrder', itemId),
type: 'checkbox',
checked: PerFolderSortOrderService.isSet(itemId),
}));
if (CommandService.instance().isEnabled('leaveSharedFolder', whenClause)) {
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('leaveSharedFolder', itemId)));
}

menu.append(
new MenuItem({
label: _('Export'),
submenu: exportMenu,
}),
);
if (Setting.value('notes.perFolderSortOrderEnabled')) {
menu.append(new MenuItem({
...menuUtils.commandToStatefulMenuItem('togglePerFolderSortOrder', itemId),
type: 'checkbox',
checked: PerFolderSortOrderService.isSet(itemId),
}));
}
}
}

if (itemType === BaseModel.TYPE_FOLDER) {
menu.append(
new MenuItem({
label: _('Copy external link'),
click: () => {
clipboard.writeText(getFolderCallbackUrl(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));
},
}),
);
}
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');
const pluginViews = pluginUtils.viewsByType(pluginsRef.current, 'menuItem');

for (const view of pluginViews) {
const location = view.location;
for (const view of pluginViews) {
const location = view.location;

if (itemType === ModelType.Tag && location === MenuItemLocation.TagContextMenu ||
itemType === ModelType.Folder && location === MenuItemLocation.FolderContextMenu
) {
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(view.commandName, itemId)),
new MenuItem(menuUtils.commandToStatefulMenuItem('restoreFolder', itemId)),
);
}
}
Expand Down Expand Up @@ -494,11 +515,15 @@ const SidebarComponent = (props: Props) => {
const isExpanded = props.collapsedFolderIds.indexOf(folder.id) < 0;
let noteCount = (folder as any).note_count;

// For now hide the count for folders in the trash because it doesn't work and getting it to
// work would be tricky.
if (folder.deleted_time || folder.id === getTrashFolderId()) noteCount = 0;

// Thunderbird count: Subtract children note_count from parent folder if it expanded.
if (isExpanded) {
for (let i = 0; i < props.folders.length; i++) {
if (props.folders[i].parent_id === folder.id) {
noteCount -= props.folders[i].note_count;
noteCount -= (props.folders[i] as any).note_count;
}
}
}
Expand Down
79 changes: 79 additions & 0 deletions packages/app-desktop/gui/TrashNotification/TrashNotification.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 27 additions & 0 deletions packages/app-desktop/gui/TrashNotification/style.scss
Original file line number Diff line number Diff line change
@@ -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);
}

}

}
}
1 change: 1 addition & 0 deletions packages/app-desktop/gui/menuCommandNames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export default function() {
'setTags',
'showLocalSearch',
'showNoteContentProperties',
'permanentlyDeleteNote',
'synchronize',
'textBold',
'textCode',
Expand Down
37 changes: 25 additions & 12 deletions packages/app-desktop/gui/utils/NoteListUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,13 @@ import Note from '@joplin/lib/models/Note';
import Setting from '@joplin/lib/models/Setting';
const { clipboard } = require('electron');
import { Dispatch } from 'redux';
import { NoteEntity } from '@joplin/lib/services/database/types';

const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;

interface ContextMenuProps {
notes: any[];
notes: NoteEntity[];
dispatch: Dispatch;
watchedNoteFiles: string[];
plugins: PluginStates;
Expand All @@ -32,18 +33,16 @@ export default class NoteListUtils {

const menuUtils = new MenuUtils(cmdService);

const notes = noteIds.map(id => BaseModel.byId(props.notes, id));
const notes: NoteEntity[] = noteIds.map(id => BaseModel.byId(props.notes, id));

const singleNoteId = noteIds.length === 1 ? noteIds[0] : null;

let hasEncrypted = false;
for (let i = 0; i < notes.length; i++) {
if (notes[i].encryption_applied) hasEncrypted = true;
}
const includeDeletedNotes = notes.find(n => !!n.deleted_time);
const includeEncryptedNotes = notes.find(n => !!n.encryption_applied);

const menu = new Menu();

if (!hasEncrypted) {
if (!includeEncryptedNotes && !includeDeletedNotes) {
menu.append(
new MenuItem(menuUtils.commandToStatefulMenuItem('setTags', noteIds) as any),
);
Expand Down Expand Up @@ -165,11 +164,25 @@ export default class NoteListUtils {
menu.append(exportMenuItem);
}

menu.append(
new MenuItem(
menuUtils.commandToStatefulMenuItem('deleteNote', noteIds) as any,
),
);
if (includeDeletedNotes) {
menu.append(
new MenuItem(
menuUtils.commandToStatefulMenuItem('restoreNote', noteIds) as any,
),
);

menu.append(
new MenuItem(
menuUtils.commandToStatefulMenuItem('permanentlyDeleteNote', noteIds) as any,
),
);
} else {
menu.append(
new MenuItem(
menuUtils.commandToStatefulMenuItem('deleteNote', noteIds) as any,
),
);
}

const pluginViewInfos = pluginUtils.viewInfosByType(props.plugins, 'menuItem');

Expand Down
6 changes: 6 additions & 0 deletions packages/app-desktop/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
<link rel="stylesheet" href="vendor/lib/smalltalk/css/smalltalk.css">
<link rel="stylesheet" href="vendor/lib/roboto-fontface/css/roboto/roboto-fontface.css">
<link rel="stylesheet" href="vendor/lib/codemirror/lib/codemirror.css">

<link rel="stylesheet" href="./node_modules/notyf/notyf.min.css">


<script src="./node_modules/tesseract.js/dist/tesseract.min.js"></script>

<style>
Expand Down Expand Up @@ -50,5 +54,7 @@
-webkit-user-drag: none;
}
</style>

<script src="./node_modules/notyf/notyf.min.js"></script>
</body>
</html>
1 change: 1 addition & 0 deletions packages/app-desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@
"node-fetch": "2.6.7",
"node-notifier": "10.0.1",
"node-rsa": "1.1.1",
"notyf": "3.10.0",
"pdfjs-dist": "3.11.174",
"pretty-bytes": "5.6.0",
"re-resizable": "6.9.11",
Expand Down
1 change: 1 addition & 0 deletions packages/app-desktop/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@
@use 'gui/Dropdown/style.scss' as dropdown-control;
@use 'gui/ShareFolderDialog/style.scss' as share-folder-dialog;
@use 'gui/NoteList/style.scss' as note-list;
@use 'gui/TrashNotification/style.scss' as trash-notification;
@use 'main.scss' as main;
56 changes: 46 additions & 10 deletions packages/app-mobile/components/ScreenHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ import { FolderEntity } from '@joplin/lib/services/database/types';
import { State } from '@joplin/lib/reducer';
import CustomButton from './CustomButton';
import FolderPicker from './FolderPicker';
import { getTrashFolderId, itemIsInTrash } from '@joplin/lib/services/trash';
import restoreItems from '@joplin/lib/services/trash/restoreItems';
import { ModelType } from '@joplin/lib/BaseModel';

// We need this to suppress the useless warning
// https://github.com/oblador/react-native-vector-icons/issues/1465
Expand Down Expand Up @@ -50,6 +53,8 @@ export interface MenuOptionType {
type DispatchCommandType=(event: { type: string })=> void;
interface ScreenHeaderProps {
selectedNoteIds: string[];
selectedFolderId: string;
notesParentType: string;
noteSelectionEnabled: boolean;
parentComponent: any;
showUndoButton: boolean;
Expand Down Expand Up @@ -269,19 +274,25 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
// Dialog needs to be displayed as a child of the parent component, otherwise
// it won't be visible within the header component.
const noteIds = this.props.selectedNoteIds;
this.props.dispatch({ type: 'NOTE_SELECTION_END' });

const msg = await Note.deleteMessage(noteIds);
if (!msg) return;

const ok = await dialogs.confirm(this.props.parentComponent, msg);
if (!ok) return;
try {
await Note.batchDelete(noteIds, { toTrash: true });
} catch (error) {
alert(_n('This note could not be deleted: %s', 'These notes could not be deleted: %s', noteIds.length, error.message));
}
}

private async restoreButton_press() {
// Dialog needs to be displayed as a child of the parent component, otherwise
// it won't be visible within the header component.
const noteIds = this.props.selectedNoteIds;
this.props.dispatch({ type: 'NOTE_SELECTION_END' });

try {
await Note.batchDelete(noteIds);
await restoreItems(ModelType.Note, noteIds);
} catch (error) {
alert(_n('This note could not be deleted: %s', 'These notes could not be deleted: %s', noteIds.length, error.message));
alert(`Could not restore note(s): ${error.message}`);
}
}

Expand Down Expand Up @@ -450,6 +461,24 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
);
}

function restoreButton(styles: any, onPress: OnPressCallback, disabled: boolean) {
return (
<CustomButton
onPress={onPress}
disabled={disabled}

themeId={themeId}
description={_('Restore')}
accessibilityHint={
disabled ? null : _('Restore')
}
contentStyle={disabled ? styles.iconButtonDisabled : styles.iconButton}
>
<Icon name="reload-circle" style={styles.topIcon} />
</CustomButton>
);
}

function duplicateButton(styles: any, onPress: OnPressCallback, disabled: boolean) {
return (
<CustomButton
Expand Down Expand Up @@ -485,6 +514,9 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
let key = 0;
const menuOptionComponents = [];

const selectedFolder = this.props.notesParentType === 'Folder' ? Folder.byId(this.props.folders, this.props.selectedFolderId) : null;
const selectedFolderInTrash = itemIsInTrash(selectedFolder);

if (!this.props.noteSelectionEnabled) {
for (let i = 0; i < this.props.menuOptions.length; i++) {
const o = this.props.menuOptions[i];
Expand Down Expand Up @@ -556,7 +588,7 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
}
}}
mustSelect={!!folderPickerOptions.mustSelect}
folders={this.props.folders}
folders={this.props.folders.filter(f => f.id !== getTrashFolderId())}
/>
);
} else {
Expand Down Expand Up @@ -591,8 +623,9 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
const backButtonComp = !showBackButton ? null : backButton(this.styles(), () => this.backButton_press(), backButtonDisabled);
const selectAllButtonComp = !showSelectAllButton ? null : selectAllButton(this.styles(), () => this.selectAllButton_press());
const searchButtonComp = !showSearchButton ? null : searchButton(this.styles(), () => this.searchButton_press());
const deleteButtonComp = this.props.noteSelectionEnabled ? deleteButton(this.styles(), () => this.deleteButton_press(), headerItemDisabled) : null;
const duplicateButtonComp = this.props.noteSelectionEnabled ? duplicateButton(this.styles(), () => this.duplicateButton_press(), headerItemDisabled) : null;
const deleteButtonComp = !selectedFolderInTrash && this.props.noteSelectionEnabled ? deleteButton(this.styles(), () => this.deleteButton_press(), headerItemDisabled) : null;
const restoreButtonComp = selectedFolderInTrash && this.props.noteSelectionEnabled ? restoreButton(this.styles(), () => this.restoreButton_press(), headerItemDisabled) : null;
const duplicateButtonComp = !selectedFolderInTrash && this.props.noteSelectionEnabled ? duplicateButton(this.styles(), () => this.duplicateButton_press(), headerItemDisabled) : null;
const sortButtonComp = !this.props.noteSelectionEnabled && this.props.sortButton_press ? sortButton(this.styles(), () => this.props.sortButton_press()) : null;
const windowHeight = Dimensions.get('window').height - 50;

Expand Down Expand Up @@ -637,6 +670,7 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
{selectAllButtonComp}
{searchButtonComp}
{deleteButtonComp}
{restoreButtonComp}
{duplicateButtonComp}
{sortButtonComp}
{menuComp}
Expand Down Expand Up @@ -667,6 +701,8 @@ const ScreenHeader = connect((state: State) => {
hasDisabledEncryptionItems: state.hasDisabledEncryptionItems,
noteSelectionEnabled: state.noteSelectionEnabled,
selectedNoteIds: state.selectedNoteIds,
selectedFolderId: state.selectedFolderId,
notesParentType: state.notesParentType,
showMissingMasterKeyMessage: showMissingMasterKeyMessage(syncInfo, state.notLoadedMasterKeys),
hasDisabledSyncItems: state.hasDisabledSyncItems,
shouldUpgradeSyncTarget: state.settings['sync.upgradeState'] === Setting.SYNC_UPGRADE_STATE_SHOULD_DO,
Expand Down
39 changes: 29 additions & 10 deletions packages/app-mobile/components/screens/Note.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const Clipboard = require('@react-native-community/clipboard').default;
const md5 = require('md5');
const { BackButtonService } = require('../../services/back-button.js');
import NavService, { OnNavigateCallback as OnNavigateCallback } from '@joplin/lib/services/NavService';
import BaseModel from '@joplin/lib/BaseModel';
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
import ActionButton from '../ActionButton';
const { fileExtension, safeFileExtension } = require('@joplin/lib/path-utils');
const mimeUtils = require('@joplin/lib/mime-utils.js').mime;
Expand Down Expand Up @@ -57,6 +57,9 @@ import { join } from 'path';
import { Dispatch } from 'redux';
import { RefObject } from 'react';
import { SelectionRange } from '../NoteEditor/types';
import { AppState } from '../../utils/types';
import restoreItems from '@joplin/lib/services/trash/restoreItems';
import { getDisplayParentTitle } from '@joplin/lib/services/trash';
const urlUtils = require('@joplin/lib/urlUtils');

const emptyArray: any[] = [];
Expand Down Expand Up @@ -134,7 +137,7 @@ interface Props {
}

interface State {
note: any;
note: NoteEntity;
mode: 'view'|'edit';
readOnly: boolean;
folder: FolderEntity|null;
Expand Down Expand Up @@ -677,12 +680,9 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
const note = this.state.note;
if (!note.id) return;

const ok = await dialogs.confirm(this, _('Delete note?'));
if (!ok) return;

const folderId = note.parent_id;

await Note.delete(note.id);
await Note.delete(note.id, { toTrash: true });

this.props.dispatch({
type: 'NAV_GO',
Expand Down Expand Up @@ -1220,6 +1220,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
const isTodo = note && !!note.is_todo;
const isSaved = note && note.id;
const readOnly = this.state.readOnly;
const isDeleted = !!this.state.note.deleted_time;

const cacheKey = md5([isTodo, isSaved].join('_'));
if (!this.menuOptionsCache_) this.menuOptionsCache_ = {};
Expand Down Expand Up @@ -1281,35 +1282,52 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
});
}

if (isSaved) {
if (isSaved && !isDeleted) {
output.push({
title: _('Tags'),
onPress: () => {
this.tags_onPress();
},
});
}

output.push({
title: isTodo ? _('Convert to note') : _('Convert to todo'),
onPress: () => {
this.toggleIsTodo_onPress();
},
disabled: readOnly,
});
if (isSaved) {

if (isSaved && !isDeleted) {
output.push({
title: _('Copy Markdown link'),
onPress: () => {
this.copyMarkdownLink_onPress();
},
});
}

output.push({
title: _('Properties'),
onPress: () => {
this.properties_onPress();
},
});

if (isDeleted) {
output.push({
title: _('Restore'),
onPress: async () => {
await restoreItems(ModelType.Note, [this.state.note.id]);
this.props.dispatch({
type: 'NAV_GO',
routeName: 'Notes',
});
},
});
}

output.push({
title: _('Delete'),
onPress: () => {
Expand Down Expand Up @@ -1550,6 +1568,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B

const renderActionButton = () => {
if (this.state.voiceTypingDialogShown) return null;
if (!this.state.note || !!this.state.note.deleted_time) return null;

const editButton = {
label: _('Edit'),
Expand Down Expand Up @@ -1615,7 +1634,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
undoButtonDisabled={!this.state.undoRedoButtonState.canUndo && this.state.undoRedoButtonState.canRedo}
onUndoButtonPress={this.screenHeader_undoButtonPress}
onRedoButtonPress={this.screenHeader_redoButtonPress}
title={this.state.folder ? this.state.folder.title : ''}
title={getDisplayParentTitle(this.state.note, this.state.folder)}
/>
{titleComp}
{bodyComponent}
Expand All @@ -1635,7 +1654,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
}
}

const NoteScreen = connect((state: any) => {
const NoteScreen = connect((state: AppState) => {
return {
noteId: state.selectedNoteIds.length ? state.selectedNoteIds[0] : null,
noteHash: state.selectedNoteHash,
Expand Down
3 changes: 3 additions & 0 deletions packages/app-mobile/components/screens/Notes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const { BaseScreenComponent } = require('../base-screen');
const { BackButtonService } = require('../../services/back-button.js');
import { AppState } from '../../utils/types';
import { NoteEntity } from '@joplin/lib/services/database/types';
import { itemIsInTrash } from '@joplin/lib/services/trash';
const { ALL_NOTES_FILTER_ID } = require('@joplin/lib/reserved-ids.js');

class NotesScreenComponent extends BaseScreenComponent<any> {
Expand Down Expand Up @@ -237,6 +238,8 @@ class NotesScreenComponent extends BaseScreenComponent<any> {
const thisComp = this;

const makeActionButtonComp = () => {
if (this.props.notesParentType === 'Folder' && itemIsInTrash(parent)) return null;

const getTargetFolderId = async () => {
if (!buttonFolderId && isAllNotes) {
return (await Folder.defaultFolder()).id;
Expand Down
3 changes: 2 additions & 1 deletion packages/app-mobile/components/screens/folder.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const { dialogs } = require('../../utils/dialogs.js');
const { _ } = require('@joplin/lib/locale');
const { default: FolderPicker } = require('../FolderPicker');
const TextInput = require('../TextInput').default;
const { getTrashFolderId } = require('@joplin/lib/services/trash');

class FolderScreenComponent extends BaseScreenComponent {
static navigationOptions() {
Expand Down Expand Up @@ -107,7 +108,7 @@ class FolderScreenComponent extends BaseScreenComponent {
<FolderPicker
themeId={this.props.themeId}
placeholder={_('Select parent notebook')}
folders={this.props.folders}
folders={this.props.folders.filter(f => f.id !== getTrashFolderId())}
selectedFolderId={this.state.folder.parent_id}
onValueChange={newValue => this.parent_changeValue(newValue)}
mustSelect
Expand Down
140 changes: 92 additions & 48 deletions packages/app-mobile/components/side-menu-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,15 @@ import NavService from '@joplin/lib/services/NavService';
import { _ } from '@joplin/lib/locale';
const { themeStyle } = require('./global-style.js');
import { renderFolders } from '@joplin/lib/components/shared/side-menu-shared';
import { FolderEntity, FolderIcon } from '@joplin/lib/services/database/types';
import { FolderEntity, FolderIcon, FolderIconType } from '@joplin/lib/services/database/types';
import { AppState } from '../utils/types';
import Setting from '@joplin/lib/models/Setting';
import { reg } from '@joplin/lib/registry';
import { ProfileConfig } from '@joplin/lib/services/profileConfig/types';
import { getTrashFolderIcon, getTrashFolderId } from '@joplin/lib/services/trash';
import restoreItems from '@joplin/lib/services/trash/restoreItems';
import { ModelType } from '@joplin/lib/BaseModel';
const { substrWithEllipsis } = require('@joplin/lib/string-utils');

// We need this to suppress the useless warning
// https://github.com/oblador/react-native-vector-icons/issues/1465
Expand Down Expand Up @@ -142,58 +146,96 @@ const SideMenuContentComponent = (props: Props) => {

const folder = folderOrAll as FolderEntity;

const generateFolderDeletion = () => {
const folderDeletion = (message: string) => {
Alert.alert('', message, [
{
text: _('OK'),
onPress: () => {
void Folder.delete(folder.id);
if (folder && folder.id === getTrashFolderId()) return;

const menuItems: any[] = [];

if (folder && !!folder.deleted_time) {
menuItems.push({
text: _('Restore'),
onPress: async () => {
await restoreItems(ModelType.Folder, [folder.id]);
},
style: 'destructive',
});

// Alert.alert(
// '',
// _('Notebook: %s', folder.title),
// [
// {
// text: _('Restore'),
// onPress: async () => {
// await restoreItems(ModelType.Folder, [folder.id]);
// },
// style: 'destructive',
// },
// {
// text: _('Cancel'),
// onPress: () => {},
// style: 'cancel',
// },
// ],
// {
// cancelable: false,
// },
// );
} else {
const generateFolderDeletion = () => {
const folderDeletion = (message: string) => {
Alert.alert('', message, [
{
text: _('OK'),
onPress: () => {
void Folder.delete(folder.id, { toTrash: true });
},
},
{
text: _('Cancel'),
onPress: () => { },
style: 'cancel',
},
},
{
text: _('Cancel'),
onPress: () => { },
style: 'cancel',
},
]);
]);
};

if (folder.id === props.inboxJopId) {
return folderDeletion(
_('Delete the Inbox notebook?\n\nIf you delete the inbox notebook, any email that\'s recently been sent to it may be lost.'),
);
}
return folderDeletion(_('Move notebook "%s" to the trash?\n\nAll notes and sub-notebooks within this notebook will also be moved to the trash.', substrWithEllipsis(folder.title, 0, 32)));
};

if (folder.id === props.inboxJopId) {
return folderDeletion(
_('Delete the Inbox notebook?\n\nIf you delete the inbox notebook, any email that\'s recently been sent to it may be lost.'),
);
}
return folderDeletion(_('Delete notebook "%s"?\n\nAll notes and sub-notebooks within this notebook will also be deleted.', folder.title));
};
menuItems.push({
text: _('Edit'),
onPress: () => {
props.dispatch({ type: 'SIDE_MENU_CLOSE' });

props.dispatch({
type: 'NAV_GO',
routeName: 'Folder',
folderId: folder.id,
});
},
});

menuItems.push({
text: _('Delete'),
onPress: generateFolderDeletion,
style: 'destructive',
});
}

menuItems.push({
text: _('Cancel'),
onPress: () => {},
style: 'cancel',
});

Alert.alert(
'',
_('Notebook: %s', folder.title),
[
{
text: _('Edit'),
onPress: () => {
props.dispatch({ type: 'SIDE_MENU_CLOSE' });

props.dispatch({
type: 'NAV_GO',
routeName: 'Folder',
folderId: folder.id,
});
},
},
{
text: _('Delete'),
onPress: generateFolderDeletion,
style: 'destructive',
},
{
text: _('Cancel'),
onPress: () => {},
style: 'cancel',
},
],
menuItems,
{
cancelable: false,
},
Expand Down Expand Up @@ -308,10 +350,12 @@ const SideMenuContentComponent = (props: Props) => {
if (actionDone === 'auth') props.dispatch({ type: 'SIDE_MENU_CLOSE' });
}, [performSync, props.dispatch]);

const renderFolderIcon = (theme: any, folderIcon: FolderIcon) => {
const renderFolderIcon = (folderId: string, theme: any, folderIcon: FolderIcon) => {
if (!folderIcon) {
if (alwaysShowFolderIcons) {
return <Icon name="folder-outline" style={styles_.emptyFolderIcon} />;
} else if (folderId === getTrashFolderId()) {
folderIcon = getTrashFolderIcon(FolderIconType.Emoji);
} else {
return null;
}
Expand Down Expand Up @@ -378,7 +422,7 @@ const SideMenuContentComponent = (props: Props) => {
}}
>
<View style={folderButtonStyle}>
{renderFolderIcon(theme, folderIcon)}
{renderFolderIcon(folder.id, theme, folderIcon)}
<Text numberOfLines={1} style={styles_.folderButtonText}>
{Folder.displayTitle(folder)}
</Text>
Expand Down
13 changes: 11 additions & 2 deletions packages/app-mobile/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,13 +121,13 @@ import { ReactNode } from 'react';
import { parseShareCache } from '@joplin/lib/services/share/reducer';
import autodetectTheme, { onSystemColorSchemeChange } from './utils/autodetectTheme';
import runOnDeviceFsDriverTests from './utils/fs-driver/runOnDeviceTests';
import { refreshFolders } from '@joplin/lib/folders-screen-utils';
import { refreshFolders, scheduleRefreshFolders } from '@joplin/lib/folders-screen-utils';

type SideMenuPosition = 'left' | 'right';

const logger = Logger.create('root');

let storeDispatch = function(_action: any) {};
let storeDispatch: any = function(_action: any) {};

const logReducerAction = function(action: any) {
if (['SIDE_MENU_OPEN_PERCENT', 'SYNC_REPORT_UPDATE'].indexOf(action.type) >= 0) return;
Expand All @@ -148,6 +148,7 @@ const generalMiddleware = (store: any) => (next: any) => async (action: any) =>

const result = next(action);
const newState = store.getState();
let doRefreshFolders = false;

await reduxSharedMiddleware(store, next, action, storeDispatch as any);

Expand All @@ -158,6 +159,10 @@ const generalMiddleware = (store: any) => (next: any) => async (action: any) =>
SearchEngine.instance().scheduleSyncTables();
}

if (['FOLDER_UPDATE_ONE'].indexOf(action.type) >= 0) {
doRefreshFolders = true;
}

if (['EVENT_NOTE_ALARM_FIELD_CHANGE', 'NOTE_DELETE'].indexOf(action.type) >= 0) {
await AlarmService.updateNoteNotification(action.id, action.type === 'NOTE_DELETE');
}
Expand Down Expand Up @@ -215,6 +220,10 @@ const generalMiddleware = (store: any) => (next: any) => async (action: any) =>
void ResourceFetcher.instance().autoAddResources();
}

if (doRefreshFolders) {
await scheduleRefreshFolders((action: any) => storeDispatch(action));
}

return result;
};

Expand Down
4 changes: 2 additions & 2 deletions packages/lib/ArrayUtils.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
export const unique = function(array: any[]) {
export const unique = function<T extends any>(array: T[]): T[] {
return array.filter((elem, index, self) => {
return index === self.indexOf(elem);
});
};

export const removeElement = function(array: any[], element: any) {
export const removeElement = function<T extends any>(array: T[], element: T): T[] {
const index = array.indexOf(element);
if (index < 0) return array;
const newArray = array.slice();
Expand Down
11 changes: 11 additions & 0 deletions packages/lib/BaseApplication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import RotatingLogs from './RotatingLogs';
import { NoteEntity } from './services/database/types';
import { join } from 'path';
import processStartFlags from './utils/processStartFlags';
import { setupAutoDeletion } from './services/trash/permanentlyDeleteOldItems';
import determineProfileAndBaseDir from './determineBaseAppDirs';

const appLogger: LoggerWrapper = Logger.create('App');
Expand Down Expand Up @@ -438,6 +439,14 @@ export default class BaseApplication {
doRefreshFolders = true;
}

// If a note gets deleted to the trash or gets restored we refresh the folders so that the
// note count can be updated.
if (this.hasGui() && ['NOTE_UPDATE_ONE'].includes(action.type)) {
if (action.changedFields && action.changedFields.includes('deleted_time')) {
doRefreshFolders = true;
}
}

if (action.type === 'HISTORY_BACKWARD' || action.type === 'HISTORY_FORWARD') {
refreshNotes = true;
refreshNotesUseSelectedNoteId = true;
Expand Down Expand Up @@ -822,6 +831,8 @@ export default class BaseApplication {
if (!currentFolder) currentFolder = await Folder.defaultFolder();
Setting.setValue('activeFolderId', currentFolder ? currentFolder.id : '');

await setupAutoDeletion();

await MigrationService.instance().run();

return argv;
Expand Down
11 changes: 10 additions & 1 deletion packages/lib/BaseModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ export interface DeleteOptions {
trackDeleted?: boolean;

disableReadOnlyCheck?: boolean;

// Tells whether the deleted item should be moved to the trash. By default
// it is permanently deleted.
toTrash?: boolean;

// If the item is to be moved to the trash, tell what should be the new
// parent. By default the item will be moved at the root of the trash. Note
// that caller must ensure that this parent ID is a deleted folder.
toTrashParentId?: string;
}

class BaseModel {
Expand Down Expand Up @@ -308,7 +317,7 @@ class BaseModel {
return this.modelSelectAll(q.sql, q.params);
}

public static async byIds(ids: string[], options: any = null) {
public static async byIds(ids: string[], options: LoadOptions = null) {
if (!ids.length) return [];
if (!options) options = {};
if (!options.fields) options.fields = '*';
Expand Down
94 changes: 94 additions & 0 deletions packages/lib/components/shared/side-menu-shared.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});

});
35 changes: 23 additions & 12 deletions packages/lib/components/shared/side-menu-shared.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Folder from '../../models/Folder';
import BaseModel from '../../BaseModel';
import { FolderEntity, TagEntity } from '../../services/database/types';
import { getDisplayParentId, getTrashFolderId } from '../../services/trash';

interface Props {
folders: FolderEntity[];
Expand All @@ -11,35 +12,45 @@ interface Props {
tags?: TagEntity[];
}

type RenderFolderItem = (folder: FolderEntity, selected: boolean, hasChildren: boolean, depth: number)=> any;
type RenderTagItem = (tag: TagEntity, selected: boolean)=> any;
export type RenderFolderItem = (folder: FolderEntity, selected: boolean, hasChildren: boolean, depth: number)=> any;
export type RenderTagItem = (tag: TagEntity, selected: boolean)=> any;

function folderHasChildren_(folders: FolderEntity[], folderId: string) {
if (folderId === getTrashFolderId()) {
return !!folders.find(f => !!f.deleted_time);
}

for (let i = 0; i < folders.length; i++) {
const folder = folders[i];
if (folder.parent_id === folderId) return true;
const folderParentId = getDisplayParentId(folder, folders.find(f => f.id === folder.parent_id));
if (folderParentId === folderId) return true;
}

return false;
}

function folderIsVisible(folders: FolderEntity[], folderId: string, collapsedFolderIds: string[]) {
if (!collapsedFolderIds || !collapsedFolderIds.length) return true;
function folderIsCollapsed(folders: FolderEntity[], folderId: string, collapsedFolderIds: string[]) {
if (!collapsedFolderIds || !collapsedFolderIds.length) return false;

while (true) {
const folder = BaseModel.byId(folders, folderId);
const folder: FolderEntity = BaseModel.byId(folders, folderId);
if (!folder) throw new Error(`No folder with id ${folder.id}`);
if (!folder.parent_id) return true;
if (collapsedFolderIds.indexOf(folder.parent_id) >= 0) return false;
folderId = folder.parent_id;
const folderParentId = getDisplayParentId(folder, folders.find(f => f.id === folder.parent_id));
if (!folderParentId) return false;
if (collapsedFolderIds.indexOf(folderParentId) >= 0) return true;
folderId = folderParentId;
}
}

function renderFoldersRecursive_(props: Props, renderItem: RenderFolderItem, items: any[], parentId: string, depth: number, order: string[]) {
const folders = props.folders;
for (let i = 0; i < folders.length; i++) {
const folder = folders[i];
if (!Folder.idsEqual(folder.parent_id, parentId)) continue;
if (!folderIsVisible(props.folders, folder.id, props.collapsedFolderIds)) continue;

const folderParentId = getDisplayParentId(folder, props.folders.find(f => f.id === folder.parent_id));

if (!Folder.idsEqual(folderParentId, parentId)) continue;
if (folderIsCollapsed(props.folders, folder.id, props.collapsedFolderIds)) continue;
const hasChildren = folderHasChildren_(folders, folder.id);
order.push(folder.id);
items.push(renderItem(folder, props.selectedFolderId === folder.id && props.notesParentType === 'Folder', hasChildren, depth));
Expand Down Expand Up @@ -75,7 +86,7 @@ export const renderTags = (props: Props, renderItem: RenderTagItem) => {
return a.title.toLowerCase() < b.title.toLowerCase() ? -1 : +1;
});
const tagItems = [];
const order = [];
const order: string[] = [];
for (let i = 0; i < tags.length; i++) {
const tag = tags[i];
order.push(tag.id);
Expand Down
5 changes: 4 additions & 1 deletion packages/lib/folders-screen-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ export const allForDisplay = async (options: FolderLoadOptions = {}) => {
export const refreshFolders = async (dispatch: Dispatch) => {
refreshCalls_.push(true);
try {
const folders = await allForDisplay({ includeConflictFolder: true });
const folders = await allForDisplay({
includeConflictFolder: true,
includeTrash: true,
});

dispatch({
type: 'FOLDER_UPDATE_ALL',
Expand Down
17 changes: 13 additions & 4 deletions packages/lib/models/BaseItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { getEncryptionEnabled } from '../services/synchronizer/syncInfoUtils';
import JoplinError from '../JoplinError';
import { LoadOptions, SaveOptions } from './utils/types';
import { State as ShareState } from '../services/share/reducer';
import { checkIfItemCanBeAddedToFolder, checkIfItemCanBeChanged, checkIfItemsCanBeChanged, needsReadOnlyChecks } from './utils/readOnly';
import { checkIfItemCanBeAddedToFolder, checkIfItemCanBeChanged, checkIfItemsCanBeChanged, needsShareReadOnlyChecks } from './utils/readOnly';

const { sprintf } = require('sprintf-js');
const moment = require('moment');
Expand Down Expand Up @@ -293,7 +293,7 @@ export default class BaseItem extends BaseModel {
});
}

if (needsReadOnlyChecks(this.modelType(), options.changeSource, this.syncShareCache, options.disableReadOnlyCheck)) {
if (needsShareReadOnlyChecks(this.modelType(), options.changeSource, this.syncShareCache, options.disableReadOnlyCheck)) {
const previousItems = await this.loadItemsByTypeAndIds(this.modelType(), ids, { fields: ['share_id', 'id'] });
checkIfItemsCanBeChanged(this.modelType(), options.changeSource, previousItems, this.syncShareCache);
}
Expand Down Expand Up @@ -338,6 +338,15 @@ export default class BaseItem extends BaseModel {
return r['total'];
}

public static async allItemsInTrash() {
const noteRows = await this.db().selectAll('SELECT id FROM notes WHERE deleted_time != 0');
const folderRows = await this.db().selectAll('SELECT id FROM folders WHERE deleted_time != 0');
return {
noteIds: noteRows.map(r => r.id),
folderIds: folderRows.map(r => r.id),
};
}

public static remoteDeletedItem(syncTarget: number, itemId: string) {
return this.db().exec('DELETE FROM deleted_items WHERE item_id = ? AND sync_target = ?', [itemId, syncTarget]);
}
Expand Down Expand Up @@ -488,7 +497,7 @@ export default class BaseItem extends BaseModel {

// List of keys that won't be encrypted - mostly foreign keys required to link items
// with each others and timestamp required for synchronisation.
const keepKeys = ['id', 'note_id', 'tag_id', 'parent_id', 'share_id', 'updated_time', 'type_'];
const keepKeys = ['id', 'note_id', 'tag_id', 'parent_id', 'share_id', 'updated_time', 'deleted_time', 'type_'];
const reducedItem: any = {};

for (let i = 0; i < keepKeys.length; i++) {
Expand Down Expand Up @@ -917,7 +926,7 @@ export default class BaseItem extends BaseModel {

const isNew = this.isNew(o, options);

if (needsReadOnlyChecks(this.modelType(), options.changeSource, this.syncShareCache)) {
if (needsShareReadOnlyChecks(this.modelType(), options.changeSource, this.syncShareCache)) {
if (!isNew) {
const previousItem = await this.loadItemByTypeAndId(this.modelType(), o.id, { fields: ['id', 'share_id'] });
checkIfItemCanBeChanged(this.modelType(), options.changeSource, previousItem, this.syncShareCache);
Expand Down
30 changes: 30 additions & 0 deletions packages/lib/models/Folder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,4 +323,34 @@ describe('models/Folder', () => {
cleanup();
});

it('should allow deleting a folder to trash', async () => {
const folder1 = await Folder.save({});
const folder2 = await Folder.save({});
const note1 = await Note.save({ parent_id: folder1.id });
const note2 = await Note.save({ parent_id: folder1.id });
const note3 = await Note.save({ parent_id: folder2.id });

const beforeTime = Date.now();
await Folder.delete(folder1.id, { toTrash: true, deleteChildren: true });

expect((await Folder.load(folder1.id)).deleted_time).toBeGreaterThanOrEqual(beforeTime);
expect((await Folder.load(folder2.id)).deleted_time).toBe(0);
expect((await Note.load(note1.id)).deleted_time).toBeGreaterThanOrEqual(beforeTime);
expect((await Note.load(note2.id)).deleted_time).toBeGreaterThanOrEqual(beforeTime);
expect((await Note.load(note3.id)).deleted_time).toBe(0);
});

it('should delete and set the parent ID', async () => {
const folder1 = await Folder.save({});
const folder2 = await Folder.save({});

await Folder.delete(folder1.id, { toTrash: true });
await Folder.delete(folder2.id, { toTrash: true, toTrashParentId: folder1.id });

expect((await Folder.load(folder2.id)).parent_id).toBe(folder1.id);

// But it should not allow moving a folder to itself
await expectThrow(async () => Folder.delete(folder2.id, { toTrash: true, toTrashParentId: folder2.id }));
});

});
Loading