mirror of https://github.com/laurent22/joplin.git
All: Add support for share permissions (#8491)
parent
b5193e1174
commit
77482a0c95
|
@ -142,6 +142,7 @@ packages/app-desktop/gui/DialogButtonRow.js
|
||||||
packages/app-desktop/gui/DialogButtonRow/useKeyboardHandler.js
|
packages/app-desktop/gui/DialogButtonRow/useKeyboardHandler.js
|
||||||
packages/app-desktop/gui/DialogTitle.js
|
packages/app-desktop/gui/DialogTitle.js
|
||||||
packages/app-desktop/gui/DropboxLoginScreen.js
|
packages/app-desktop/gui/DropboxLoginScreen.js
|
||||||
|
packages/app-desktop/gui/Dropdown/Dropdown.js
|
||||||
packages/app-desktop/gui/EditFolderDialog/Dialog.js
|
packages/app-desktop/gui/EditFolderDialog/Dialog.js
|
||||||
packages/app-desktop/gui/EditFolderDialog/IconSelector.js
|
packages/app-desktop/gui/EditFolderDialog/IconSelector.js
|
||||||
packages/app-desktop/gui/EmojiBox.js
|
packages/app-desktop/gui/EmojiBox.js
|
||||||
|
@ -162,6 +163,9 @@ packages/app-desktop/gui/KeymapConfig/utils/useKeymap.js
|
||||||
packages/app-desktop/gui/MainScreen/MainScreen.js
|
packages/app-desktop/gui/MainScreen/MainScreen.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/addProfile.js
|
packages/app-desktop/gui/MainScreen/commands/addProfile.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/commandPalette.js
|
packages/app-desktop/gui/MainScreen/commands/commandPalette.js
|
||||||
|
packages/app-desktop/gui/MainScreen/commands/deleteFolder.js
|
||||||
|
packages/app-desktop/gui/MainScreen/commands/deleteNote.js
|
||||||
|
packages/app-desktop/gui/MainScreen/commands/duplicateNote.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/editAlarm.js
|
packages/app-desktop/gui/MainScreen/commands/editAlarm.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/exportPdf.js
|
packages/app-desktop/gui/MainScreen/commands/exportPdf.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/gotoAnything.js
|
packages/app-desktop/gui/MainScreen/commands/gotoAnything.js
|
||||||
|
@ -196,6 +200,7 @@ packages/app-desktop/gui/MainScreen/commands/showSpellCheckerMenu.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/toggleEditors.js
|
packages/app-desktop/gui/MainScreen/commands/toggleEditors.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/toggleLayoutMoveMode.js
|
packages/app-desktop/gui/MainScreen/commands/toggleLayoutMoveMode.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/toggleNoteList.js
|
packages/app-desktop/gui/MainScreen/commands/toggleNoteList.js
|
||||||
|
packages/app-desktop/gui/MainScreen/commands/toggleNoteType.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderField.js
|
packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderField.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderReverse.js
|
packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderReverse.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/togglePerFolderSortOrder.js
|
packages/app-desktop/gui/MainScreen/commands/togglePerFolderSortOrder.js
|
||||||
|
@ -502,12 +507,14 @@ packages/lib/commands/openMasterPasswordDialog.js
|
||||||
packages/lib/commands/synchronize.js
|
packages/lib/commands/synchronize.js
|
||||||
packages/lib/components/EncryptionConfigScreen/utils.js
|
packages/lib/components/EncryptionConfigScreen/utils.js
|
||||||
packages/lib/components/shared/note-screen-shared.js
|
packages/lib/components/shared/note-screen-shared.js
|
||||||
|
packages/lib/components/shared/reduxSharedMiddleware.js
|
||||||
packages/lib/database-driver-better-sqlite.js
|
packages/lib/database-driver-better-sqlite.js
|
||||||
packages/lib/database.js
|
packages/lib/database.js
|
||||||
packages/lib/debug/DebugService.js
|
packages/lib/debug/DebugService.js
|
||||||
packages/lib/dom.js
|
packages/lib/dom.js
|
||||||
packages/lib/dummy.test.js
|
packages/lib/dummy.test.js
|
||||||
packages/lib/errorUtils.js
|
packages/lib/errorUtils.js
|
||||||
|
packages/lib/errors.js
|
||||||
packages/lib/eventManager.js
|
packages/lib/eventManager.js
|
||||||
packages/lib/file-api-driver-joplinServer.js
|
packages/lib/file-api-driver-joplinServer.js
|
||||||
packages/lib/file-api-driver-memory.js
|
packages/lib/file-api-driver-memory.js
|
||||||
|
@ -562,6 +569,7 @@ packages/lib/models/settings/FileHandler.js
|
||||||
packages/lib/models/utils/itemCanBeEncrypted.js
|
packages/lib/models/utils/itemCanBeEncrypted.js
|
||||||
packages/lib/models/utils/paginatedFeed.js
|
packages/lib/models/utils/paginatedFeed.js
|
||||||
packages/lib/models/utils/paginationToSql.js
|
packages/lib/models/utils/paginationToSql.js
|
||||||
|
packages/lib/models/utils/readOnly.js
|
||||||
packages/lib/models/utils/types.js
|
packages/lib/models/utils/types.js
|
||||||
packages/lib/models/utils/userData.js
|
packages/lib/models/utils/userData.js
|
||||||
packages/lib/models/utils/userData.test.js
|
packages/lib/models/utils/userData.test.js
|
||||||
|
@ -765,7 +773,10 @@ packages/lib/services/synchronizer/syncInfoUtils.test.js
|
||||||
packages/lib/services/synchronizer/synchronizer_LockHandler.test.js
|
packages/lib/services/synchronizer/synchronizer_LockHandler.test.js
|
||||||
packages/lib/services/synchronizer/synchronizer_MigrationHandler.test.js
|
packages/lib/services/synchronizer/synchronizer_MigrationHandler.test.js
|
||||||
packages/lib/services/synchronizer/tools.js
|
packages/lib/services/synchronizer/tools.js
|
||||||
|
packages/lib/services/synchronizer/utils/handleConflictAction.js
|
||||||
packages/lib/services/synchronizer/utils/handleSyncStartupOperation.js
|
packages/lib/services/synchronizer/utils/handleSyncStartupOperation.js
|
||||||
|
packages/lib/services/synchronizer/utils/resourceRemotePath.js
|
||||||
|
packages/lib/services/synchronizer/utils/syncDeleteStep.js
|
||||||
packages/lib/services/synchronizer/utils/types.js
|
packages/lib/services/synchronizer/utils/types.js
|
||||||
packages/lib/shim.js
|
packages/lib/shim.js
|
||||||
packages/lib/testing/syncTargetUtils.js
|
packages/lib/testing/syncTargetUtils.js
|
||||||
|
|
|
@ -127,6 +127,7 @@ packages/app-desktop/gui/DialogButtonRow.js
|
||||||
packages/app-desktop/gui/DialogButtonRow/useKeyboardHandler.js
|
packages/app-desktop/gui/DialogButtonRow/useKeyboardHandler.js
|
||||||
packages/app-desktop/gui/DialogTitle.js
|
packages/app-desktop/gui/DialogTitle.js
|
||||||
packages/app-desktop/gui/DropboxLoginScreen.js
|
packages/app-desktop/gui/DropboxLoginScreen.js
|
||||||
|
packages/app-desktop/gui/Dropdown/Dropdown.js
|
||||||
packages/app-desktop/gui/EditFolderDialog/Dialog.js
|
packages/app-desktop/gui/EditFolderDialog/Dialog.js
|
||||||
packages/app-desktop/gui/EditFolderDialog/IconSelector.js
|
packages/app-desktop/gui/EditFolderDialog/IconSelector.js
|
||||||
packages/app-desktop/gui/EmojiBox.js
|
packages/app-desktop/gui/EmojiBox.js
|
||||||
|
@ -147,6 +148,9 @@ packages/app-desktop/gui/KeymapConfig/utils/useKeymap.js
|
||||||
packages/app-desktop/gui/MainScreen/MainScreen.js
|
packages/app-desktop/gui/MainScreen/MainScreen.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/addProfile.js
|
packages/app-desktop/gui/MainScreen/commands/addProfile.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/commandPalette.js
|
packages/app-desktop/gui/MainScreen/commands/commandPalette.js
|
||||||
|
packages/app-desktop/gui/MainScreen/commands/deleteFolder.js
|
||||||
|
packages/app-desktop/gui/MainScreen/commands/deleteNote.js
|
||||||
|
packages/app-desktop/gui/MainScreen/commands/duplicateNote.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/editAlarm.js
|
packages/app-desktop/gui/MainScreen/commands/editAlarm.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/exportPdf.js
|
packages/app-desktop/gui/MainScreen/commands/exportPdf.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/gotoAnything.js
|
packages/app-desktop/gui/MainScreen/commands/gotoAnything.js
|
||||||
|
@ -181,6 +185,7 @@ packages/app-desktop/gui/MainScreen/commands/showSpellCheckerMenu.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/toggleEditors.js
|
packages/app-desktop/gui/MainScreen/commands/toggleEditors.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/toggleLayoutMoveMode.js
|
packages/app-desktop/gui/MainScreen/commands/toggleLayoutMoveMode.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/toggleNoteList.js
|
packages/app-desktop/gui/MainScreen/commands/toggleNoteList.js
|
||||||
|
packages/app-desktop/gui/MainScreen/commands/toggleNoteType.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderField.js
|
packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderField.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderReverse.js
|
packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderReverse.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/togglePerFolderSortOrder.js
|
packages/app-desktop/gui/MainScreen/commands/togglePerFolderSortOrder.js
|
||||||
|
@ -487,12 +492,14 @@ packages/lib/commands/openMasterPasswordDialog.js
|
||||||
packages/lib/commands/synchronize.js
|
packages/lib/commands/synchronize.js
|
||||||
packages/lib/components/EncryptionConfigScreen/utils.js
|
packages/lib/components/EncryptionConfigScreen/utils.js
|
||||||
packages/lib/components/shared/note-screen-shared.js
|
packages/lib/components/shared/note-screen-shared.js
|
||||||
|
packages/lib/components/shared/reduxSharedMiddleware.js
|
||||||
packages/lib/database-driver-better-sqlite.js
|
packages/lib/database-driver-better-sqlite.js
|
||||||
packages/lib/database.js
|
packages/lib/database.js
|
||||||
packages/lib/debug/DebugService.js
|
packages/lib/debug/DebugService.js
|
||||||
packages/lib/dom.js
|
packages/lib/dom.js
|
||||||
packages/lib/dummy.test.js
|
packages/lib/dummy.test.js
|
||||||
packages/lib/errorUtils.js
|
packages/lib/errorUtils.js
|
||||||
|
packages/lib/errors.js
|
||||||
packages/lib/eventManager.js
|
packages/lib/eventManager.js
|
||||||
packages/lib/file-api-driver-joplinServer.js
|
packages/lib/file-api-driver-joplinServer.js
|
||||||
packages/lib/file-api-driver-memory.js
|
packages/lib/file-api-driver-memory.js
|
||||||
|
@ -547,6 +554,7 @@ packages/lib/models/settings/FileHandler.js
|
||||||
packages/lib/models/utils/itemCanBeEncrypted.js
|
packages/lib/models/utils/itemCanBeEncrypted.js
|
||||||
packages/lib/models/utils/paginatedFeed.js
|
packages/lib/models/utils/paginatedFeed.js
|
||||||
packages/lib/models/utils/paginationToSql.js
|
packages/lib/models/utils/paginationToSql.js
|
||||||
|
packages/lib/models/utils/readOnly.js
|
||||||
packages/lib/models/utils/types.js
|
packages/lib/models/utils/types.js
|
||||||
packages/lib/models/utils/userData.js
|
packages/lib/models/utils/userData.js
|
||||||
packages/lib/models/utils/userData.test.js
|
packages/lib/models/utils/userData.test.js
|
||||||
|
@ -750,7 +758,10 @@ packages/lib/services/synchronizer/syncInfoUtils.test.js
|
||||||
packages/lib/services/synchronizer/synchronizer_LockHandler.test.js
|
packages/lib/services/synchronizer/synchronizer_LockHandler.test.js
|
||||||
packages/lib/services/synchronizer/synchronizer_MigrationHandler.test.js
|
packages/lib/services/synchronizer/synchronizer_MigrationHandler.test.js
|
||||||
packages/lib/services/synchronizer/tools.js
|
packages/lib/services/synchronizer/tools.js
|
||||||
|
packages/lib/services/synchronizer/utils/handleConflictAction.js
|
||||||
packages/lib/services/synchronizer/utils/handleSyncStartupOperation.js
|
packages/lib/services/synchronizer/utils/handleSyncStartupOperation.js
|
||||||
|
packages/lib/services/synchronizer/utils/resourceRemotePath.js
|
||||||
|
packages/lib/services/synchronizer/utils/syncDeleteStep.js
|
||||||
packages/lib/services/synchronizer/utils/types.js
|
packages/lib/services/synchronizer/utils/types.js
|
||||||
packages/lib/shim.js
|
packages/lib/shim.js
|
||||||
packages/lib/testing/syncTargetUtils.js
|
packages/lib/testing/syncTargetUtils.js
|
||||||
|
|
|
@ -140,6 +140,7 @@ A community maintained list of these distributions can be found here: [Unofficia
|
||||||
- [Server: File URL Format](https://github.com/laurent22/joplin/blob/dev/readme/spec/server_file_url_format.md)
|
- [Server: File URL Format](https://github.com/laurent22/joplin/blob/dev/readme/spec/server_file_url_format.md)
|
||||||
- [Server: Delta Sync](https://github.com/laurent22/joplin/blob/dev/readme/spec/server_delta_sync.md)
|
- [Server: Delta Sync](https://github.com/laurent22/joplin/blob/dev/readme/spec/server_delta_sync.md)
|
||||||
- [Server: Sharing](https://github.com/laurent22/joplin/blob/dev/readme/spec/server_sharing.md)
|
- [Server: Sharing](https://github.com/laurent22/joplin/blob/dev/readme/spec/server_sharing.md)
|
||||||
|
- [Read-only items](https://github.com/laurent22/joplin/blob/dev/readme/spec/read_only.md)
|
||||||
|
|
||||||
- Google Summer of Code 2022
|
- Google Summer of Code 2022
|
||||||
|
|
||||||
|
|
|
@ -23,6 +23,6 @@ export const runtime = (): CommandRuntime => {
|
||||||
bridge().showErrorMessageBox(_('Error opening note in editor: %s', error.message));
|
bridge().showErrorMessageBox(_('Error opening note in editor: %s', error.message));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabledCondition: 'oneNoteSelected',
|
enabledCondition: 'oneNoteSelected && !noteIsReadOnly',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -15,6 +15,6 @@ export const runtime = (): CommandRuntime => {
|
||||||
noteId = noteId || stateUtils.selectedNoteId(context.state);
|
noteId = noteId || stateUtils.selectedNoteId(context.state);
|
||||||
void ExternalEditWatcher.instance().stopWatching(noteId);
|
void ExternalEditWatcher.instance().stopWatching(noteId);
|
||||||
},
|
},
|
||||||
enabledCondition: 'oneNoteSelected',
|
enabledCondition: 'oneNoteSelected && !noteIsReadOnly',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -2,6 +2,7 @@ import CommandService, { CommandRuntime, CommandDeclaration } from '@joplin/lib/
|
||||||
import { _ } from '@joplin/lib/locale';
|
import { _ } from '@joplin/lib/locale';
|
||||||
import { stateUtils } from '@joplin/lib/reducer';
|
import { stateUtils } from '@joplin/lib/reducer';
|
||||||
import { DesktopCommandContext } from '../services/commands/types';
|
import { DesktopCommandContext } from '../services/commands/types';
|
||||||
|
import { enabledCondition } from '../gui/NoteEditor/editorCommandDeclarations';
|
||||||
|
|
||||||
export const declaration: CommandDeclaration = {
|
export const declaration: CommandDeclaration = {
|
||||||
name: 'toggleExternalEditing',
|
name: 'toggleExternalEditing',
|
||||||
|
@ -22,7 +23,7 @@ export const runtime = (): CommandRuntime => {
|
||||||
void CommandService.instance().execute('startExternalEditing', noteId);
|
void CommandService.instance().execute('startExternalEditing', noteId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabledCondition: 'oneNoteSelected',
|
enabledCondition: enabledCondition(declaration.name),
|
||||||
mapStateToTitle: (state: any) => {
|
mapStateToTitle: (state: any) => {
|
||||||
const noteId = stateUtils.selectedNoteId(state);
|
const noteId = stateUtils.selectedNoteId(state);
|
||||||
return state.watchedNoteFiles.includes(noteId) ? _('Stop') : '';
|
return state.watchedNoteFiles.includes(noteId) ? _('Stop') : '';
|
||||||
|
|
|
@ -0,0 +1,53 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useMemo, useCallback } from 'react';
|
||||||
|
|
||||||
|
export type DropdownOptions = Record<string, string>;
|
||||||
|
export enum DropdownVariant {
|
||||||
|
Default = 1,
|
||||||
|
NoBorder,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChangeEvent {
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChangeEventHandler = (event: ChangeEvent)=> void;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
options: DropdownOptions;
|
||||||
|
variant?: DropdownVariant;
|
||||||
|
className?: string;
|
||||||
|
onChange?: ChangeEventHandler;
|
||||||
|
value?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Dropdown = (props: Props) => {
|
||||||
|
const renderOptions = () => {
|
||||||
|
const optionComps = [];
|
||||||
|
for (const [value, label] of Object.entries(props.options)) {
|
||||||
|
optionComps.push(<option key={value} value={value}>{label}</option>);
|
||||||
|
}
|
||||||
|
return optionComps;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onChange = useCallback((event: any) => {
|
||||||
|
props.onChange({ value: event.target.value });
|
||||||
|
}, [props.onChange]);
|
||||||
|
|
||||||
|
const classNames = useMemo(() => {
|
||||||
|
const variant = props.variant || DropdownVariant.Default;
|
||||||
|
const output = [
|
||||||
|
'dropdown-control',
|
||||||
|
`-variant${variant}`,
|
||||||
|
];
|
||||||
|
if (props.className) output.push(props.className);
|
||||||
|
return output.join(' ');
|
||||||
|
}, [props.variant, props.className]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<select disabled={props.disabled} className={classNames} onChange={onChange} value={props.value}>
|
||||||
|
{renderOptions()}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,14 @@
|
||||||
|
.dropdown-control {
|
||||||
|
border: 1px solid var(--joplin-border-color4);
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: var(--joplin-font-size)px;
|
||||||
|
color: var(--joplin-color);
|
||||||
|
padding: 0 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background-color: var(--joplin-background-color4);
|
||||||
|
min-height: 26px;
|
||||||
|
|
||||||
|
&.-variant2 {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
|
@ -63,6 +63,8 @@ export default function(props: Props) {
|
||||||
const folder: FolderEntity = {
|
const folder: FolderEntity = {
|
||||||
title: folderTitle,
|
title: folderTitle,
|
||||||
icon: Folder.serializeIcon(folderIcon),
|
icon: Folder.serializeIcon(folderIcon),
|
||||||
|
is_shared: 0,
|
||||||
|
share_id: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isNew) folder.id = props.folderId;
|
if (!isNew) folder.id = props.folderId;
|
||||||
|
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||||
|
import { _ } from '@joplin/lib/locale';
|
||||||
|
import bridge from '../../../services/bridge';
|
||||||
|
import Folder from '@joplin/lib/models/Folder';
|
||||||
|
const { substrWithEllipsis } = require('@joplin/lib/string-utils');
|
||||||
|
|
||||||
|
export const declaration: CommandDeclaration = {
|
||||||
|
name: 'deleteFolder',
|
||||||
|
label: () => _('Delete notebook'),
|
||||||
|
};
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
|
||||||
|
const ok = bridge().showConfirmMessageBox(_('Delete notebook "%s"?\n\nAll notes and sub-notebooks within this notebook will also be deleted.', substrWithEllipsis(folder.title, 0, 32)), {
|
||||||
|
buttons: [_('Delete'), _('Cancel')],
|
||||||
|
defaultId: 1,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
await Folder.delete(folderId);
|
||||||
|
},
|
||||||
|
enabledCondition: '!folderIsReadOnly',
|
||||||
|
};
|
||||||
|
};
|
|
@ -0,0 +1,32 @@
|
||||||
|
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',
|
||||||
|
label: () => _('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.deleteMessage(noteIds);
|
||||||
|
if (!msg) return;
|
||||||
|
|
||||||
|
const ok = bridge().showConfirmMessageBox(msg, {
|
||||||
|
buttons: [_('Delete'), _('Cancel')],
|
||||||
|
defaultId: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ok) return;
|
||||||
|
await Note.batchDelete(noteIds);
|
||||||
|
},
|
||||||
|
enabledCondition: '!noteIsReadOnly',
|
||||||
|
};
|
||||||
|
};
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||||
|
import { _ } from '@joplin/lib/locale';
|
||||||
|
import Note from '@joplin/lib/models/Note';
|
||||||
|
|
||||||
|
export const declaration: CommandDeclaration = {
|
||||||
|
name: 'duplicateNote',
|
||||||
|
label: () => _('Duplicate'),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const runtime = (): CommandRuntime => {
|
||||||
|
return {
|
||||||
|
execute: async (context: CommandContext, noteIds: string[] = null) => {
|
||||||
|
if (noteIds === null) noteIds = context.state.selectedNoteIds;
|
||||||
|
|
||||||
|
for (let i = 0; i < noteIds.length; i++) {
|
||||||
|
const note = await Note.load(noteIds[i]);
|
||||||
|
await Note.duplicate(noteIds[i], {
|
||||||
|
uniqueTitle: _('%s - Copy', note.title),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabledCondition: '!noteIsReadOnly',
|
||||||
|
};
|
||||||
|
};
|
|
@ -1,6 +1,9 @@
|
||||||
// AUTO-GENERATED using `gulp buildCommandIndex`
|
// AUTO-GENERATED using `gulp buildCommandIndex`
|
||||||
import * as addProfile from './addProfile';
|
import * as addProfile from './addProfile';
|
||||||
import * as commandPalette from './commandPalette';
|
import * as commandPalette from './commandPalette';
|
||||||
|
import * as deleteFolder from './deleteFolder';
|
||||||
|
import * as deleteNote from './deleteNote';
|
||||||
|
import * as duplicateNote from './duplicateNote';
|
||||||
import * as editAlarm from './editAlarm';
|
import * as editAlarm from './editAlarm';
|
||||||
import * as exportPdf from './exportPdf';
|
import * as exportPdf from './exportPdf';
|
||||||
import * as gotoAnything from './gotoAnything';
|
import * as gotoAnything from './gotoAnything';
|
||||||
|
@ -34,6 +37,7 @@ import * as showSpellCheckerMenu from './showSpellCheckerMenu';
|
||||||
import * as toggleEditors from './toggleEditors';
|
import * as toggleEditors from './toggleEditors';
|
||||||
import * as toggleLayoutMoveMode from './toggleLayoutMoveMode';
|
import * as toggleLayoutMoveMode from './toggleLayoutMoveMode';
|
||||||
import * as toggleNoteList from './toggleNoteList';
|
import * as toggleNoteList from './toggleNoteList';
|
||||||
|
import * as toggleNoteType from './toggleNoteType';
|
||||||
import * as toggleNotesSortOrderField from './toggleNotesSortOrderField';
|
import * as toggleNotesSortOrderField from './toggleNotesSortOrderField';
|
||||||
import * as toggleNotesSortOrderReverse from './toggleNotesSortOrderReverse';
|
import * as toggleNotesSortOrderReverse from './toggleNotesSortOrderReverse';
|
||||||
import * as togglePerFolderSortOrder from './togglePerFolderSortOrder';
|
import * as togglePerFolderSortOrder from './togglePerFolderSortOrder';
|
||||||
|
@ -43,6 +47,9 @@ import * as toggleVisiblePanes from './toggleVisiblePanes';
|
||||||
const index:any[] = [
|
const index:any[] = [
|
||||||
addProfile,
|
addProfile,
|
||||||
commandPalette,
|
commandPalette,
|
||||||
|
deleteFolder,
|
||||||
|
deleteNote,
|
||||||
|
duplicateNote,
|
||||||
editAlarm,
|
editAlarm,
|
||||||
exportPdf,
|
exportPdf,
|
||||||
gotoAnything,
|
gotoAnything,
|
||||||
|
@ -76,6 +83,7 @@ const index:any[] = [
|
||||||
toggleEditors,
|
toggleEditors,
|
||||||
toggleLayoutMoveMode,
|
toggleLayoutMoveMode,
|
||||||
toggleNoteList,
|
toggleNoteList,
|
||||||
|
toggleNoteType,
|
||||||
toggleNotesSortOrderField,
|
toggleNotesSortOrderField,
|
||||||
toggleNotesSortOrderReverse,
|
toggleNotesSortOrderReverse,
|
||||||
togglePerFolderSortOrder,
|
togglePerFolderSortOrder,
|
||||||
|
|
|
@ -44,6 +44,6 @@ export const runtime = (comp: any): CommandRuntime => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
enabledCondition: 'someNotesSelected',
|
enabledCondition: 'someNotesSelected && !noteIsReadOnly',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -18,5 +18,6 @@ export const runtime = (): CommandRuntime => {
|
||||||
|
|
||||||
void CommandService.instance().execute('openFolderDialog', options);
|
void CommandService.instance().execute('openFolderDialog', options);
|
||||||
},
|
},
|
||||||
|
enabledCondition: '!folderIsReadOnly',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -30,6 +30,6 @@ export const runtime = (): CommandRuntime => {
|
||||||
id: newNote.id,
|
id: newNote.id,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
enabledCondition: 'oneFolderSelected && !inConflictFolder',
|
enabledCondition: 'oneFolderSelected && !inConflictFolder && !folderIsReadOnly',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -13,5 +13,6 @@ export const runtime = (): CommandRuntime => {
|
||||||
parentId = parentId || context.state.selectedFolderId;
|
parentId = parentId || context.state.selectedFolderId;
|
||||||
return CommandService.instance().execute('newFolder', parentId);
|
return CommandService.instance().execute('newFolder', parentId);
|
||||||
},
|
},
|
||||||
|
enabledCondition: '!folderIsReadOnly',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -12,6 +12,6 @@ export const runtime = (): CommandRuntime => {
|
||||||
execute: async (_context: CommandContext, body = '') => {
|
execute: async (_context: CommandContext, body = '') => {
|
||||||
return CommandService.instance().execute('newNote', body, true);
|
return CommandService.instance().execute('newNote', body, true);
|
||||||
},
|
},
|
||||||
enabledCondition: 'oneFolderSelected && !inConflictFolder',
|
enabledCondition: 'oneFolderSelected && !inConflictFolder && !folderIsReadOnly',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -33,5 +33,6 @@ export const runtime = (): CommandRuntime => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
enabledCondition: '!folderIsReadOnly',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||||
|
import { _ } from '@joplin/lib/locale';
|
||||||
|
import Note from '@joplin/lib/models/Note';
|
||||||
|
import eventManager from '@joplin/lib/eventManager';
|
||||||
|
|
||||||
|
export const declaration: CommandDeclaration = {
|
||||||
|
name: 'toggleNoteType',
|
||||||
|
label: () => _('Switch between note and to-do type'),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const runtime = (): CommandRuntime => {
|
||||||
|
return {
|
||||||
|
execute: async (context: CommandContext, noteIds: string[] = null) => {
|
||||||
|
if (noteIds === null) noteIds = context.state.selectedNoteIds;
|
||||||
|
|
||||||
|
for (let i = 0; i < noteIds.length; i++) {
|
||||||
|
const note = await Note.load(noteIds[i]);
|
||||||
|
const newNote = await Note.save(Note.toggleIsTodo(note), { userSideValidation: true });
|
||||||
|
const eventNote = {
|
||||||
|
id: newNote.id,
|
||||||
|
is_todo: newNote.is_todo,
|
||||||
|
todo_due: newNote.todo_due,
|
||||||
|
todo_completed: newNote.todo_completed,
|
||||||
|
};
|
||||||
|
eventManager.emit('noteTypeToggle', { noteId: note.id, note: eventNote });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabledCondition: '!noteIsReadOnly',
|
||||||
|
};
|
||||||
|
};
|
|
@ -893,7 +893,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
|
||||||
mode={props.contentMarkupLanguage === MarkupToHtml.MARKUP_LANGUAGE_HTML ? 'xml' : 'joplin-markdown'}
|
mode={props.contentMarkupLanguage === MarkupToHtml.MARKUP_LANGUAGE_HTML ? 'xml' : 'joplin-markdown'}
|
||||||
codeMirrorTheme={styles.editor.codeMirrorTheme}
|
codeMirrorTheme={styles.editor.codeMirrorTheme}
|
||||||
style={styles.editor}
|
style={styles.editor}
|
||||||
readOnly={props.visiblePanes.indexOf('editor') < 0}
|
readOnly={props.disabled || props.visiblePanes.indexOf('editor') < 0}
|
||||||
autoMatchBraces={matchBracesOptions}
|
autoMatchBraces={matchBracesOptions}
|
||||||
keyMap={props.keyboardMode}
|
keyMap={props.keyboardMode}
|
||||||
plugins={props.plugins}
|
plugins={props.plugins}
|
||||||
|
@ -927,7 +927,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
|
||||||
<ErrorBoundary message="The text editor encountered a fatal error and could not continue. The error might be due to a plugin, so please try to disable some of them and try again.">
|
<ErrorBoundary message="The text editor encountered a fatal error and could not continue. The error might be due to a plugin, so please try to disable some of them and try again.">
|
||||||
<div style={styles.root} ref={rootRef}>
|
<div style={styles.root} ref={rootRef}>
|
||||||
<div style={styles.rowToolbar}>
|
<div style={styles.rowToolbar}>
|
||||||
<Toolbar themeId={props.themeId} />
|
<Toolbar themeId={props.themeId}/>
|
||||||
{props.noteToolbar}
|
{props.noteToolbar}
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.rowEditorViewer}>
|
<div style={styles.rowEditorViewer}>
|
||||||
|
|
|
@ -167,6 +167,7 @@ function Editor(props: EditorProps, ref: any) {
|
||||||
|
|
||||||
const safeOptions: Record<string, any> = {
|
const safeOptions: Record<string, any> = {
|
||||||
value: props.value,
|
value: props.value,
|
||||||
|
readOnly: props.readOnly,
|
||||||
};
|
};
|
||||||
|
|
||||||
const unsafeOptions: Record<string, any> = {
|
const unsafeOptions: Record<string, any> = {
|
||||||
|
|
|
@ -11,6 +11,7 @@ const { buildStyle } = require('@joplin/lib/theme');
|
||||||
interface ToolbarProps {
|
interface ToolbarProps {
|
||||||
themeId: number;
|
themeId: number;
|
||||||
toolbarButtonInfos: ToolbarButtonInfo[];
|
toolbarButtonInfos: ToolbarButtonInfo[];
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function styles_(props: ToolbarProps) {
|
function styles_(props: ToolbarProps) {
|
||||||
|
@ -28,7 +29,7 @@ const toolbarButtonUtils = new ToolbarButtonUtils(CommandService.instance());
|
||||||
|
|
||||||
function Toolbar(props: ToolbarProps) {
|
function Toolbar(props: ToolbarProps) {
|
||||||
const styles = styles_(props);
|
const styles = styles_(props);
|
||||||
return <ToolbarBase style={styles.root} items={props.toolbarButtonInfos} />;
|
return <ToolbarBase style={styles.root} items={props.toolbarButtonInfos} disabled={!!props.disabled} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapStateToProps = (state: AppState) => {
|
const mapStateToProps = (state: AppState) => {
|
||||||
|
|
|
@ -31,7 +31,7 @@ import usePrevious from '../hooks/usePrevious';
|
||||||
import Setting from '@joplin/lib/models/Setting';
|
import Setting from '@joplin/lib/models/Setting';
|
||||||
import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext';
|
import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext';
|
||||||
import ExternalEditWatcher from '@joplin/lib/services/ExternalEditWatcher';
|
import ExternalEditWatcher from '@joplin/lib/services/ExternalEditWatcher';
|
||||||
|
import { itemIsReadOnly } from '@joplin/lib/models/utils/readOnly';
|
||||||
const { themeStyle } = require('@joplin/lib/theme');
|
const { themeStyle } = require('@joplin/lib/theme');
|
||||||
const { substrWithEllipsis } = require('@joplin/lib/string-utils');
|
const { substrWithEllipsis } = require('@joplin/lib/string-utils');
|
||||||
import NoteSearchBar from '../NoteSearchBar';
|
import NoteSearchBar from '../NoteSearchBar';
|
||||||
|
@ -40,6 +40,12 @@ import Note from '@joplin/lib/models/Note';
|
||||||
import Folder from '@joplin/lib/models/Folder';
|
import Folder from '@joplin/lib/models/Folder';
|
||||||
const bridge = require('@electron/remote').require('./bridge').default;
|
const bridge = require('@electron/remote').require('./bridge').default;
|
||||||
import NoteRevisionViewer from '../NoteRevisionViewer';
|
import NoteRevisionViewer from '../NoteRevisionViewer';
|
||||||
|
import { readFromSettings } from '@joplin/lib/services/share/reducer';
|
||||||
|
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||||
|
import { ModelType } from '@joplin/lib/BaseModel';
|
||||||
|
import BaseItem from '@joplin/lib/models/BaseItem';
|
||||||
|
import { ErrorCode } from '@joplin/lib/errors';
|
||||||
|
import ItemChange from '@joplin/lib/models/ItemChange';
|
||||||
|
|
||||||
const commands = [
|
const commands = [
|
||||||
require('./commands/showRevisions'),
|
require('./commands/showRevisions'),
|
||||||
|
@ -51,6 +57,7 @@ function NoteEditor(props: NoteEditorProps) {
|
||||||
const [showRevisions, setShowRevisions] = useState(false);
|
const [showRevisions, setShowRevisions] = useState(false);
|
||||||
const [titleHasBeenManuallyChanged, setTitleHasBeenManuallyChanged] = useState(false);
|
const [titleHasBeenManuallyChanged, setTitleHasBeenManuallyChanged] = useState(false);
|
||||||
const [scrollWhenReady, setScrollWhenReady] = useState<ScrollOptions>(null);
|
const [scrollWhenReady, setScrollWhenReady] = useState<ScrollOptions>(null);
|
||||||
|
const [isReadOnly, setIsReadOnly] = useState<boolean>(false);
|
||||||
|
|
||||||
const editorRef = useRef<any>();
|
const editorRef = useRef<any>();
|
||||||
const titleInputRef = useRef<any>();
|
const titleInputRef = useRef<any>();
|
||||||
|
@ -279,6 +286,23 @@ function NoteEditor(props: NoteEditorProps) {
|
||||||
// }
|
// }
|
||||||
// }, [props.dispatch]);
|
// }, [props.dispatch]);
|
||||||
|
|
||||||
|
useAsyncEffect(async event => {
|
||||||
|
if (!formNote.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await itemIsReadOnly(BaseItem, ModelType.Note, ItemChange.SOURCE_UNSPECIFIED, formNote.id, props.syncUserId, props.shareCache);
|
||||||
|
if (event.cancelled) return;
|
||||||
|
setIsReadOnly(result);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === ErrorCode.NotFound) {
|
||||||
|
// Can happen if the note has been deleted but a render is
|
||||||
|
// triggered anyway. It can be ignored.
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [formNote.id, props.syncUserId, props.shareCache]);
|
||||||
|
|
||||||
const onBodyWillChange = useCallback((event: any) => {
|
const onBodyWillChange = useCallback((event: any) => {
|
||||||
handleProvisionalFlag();
|
handleProvisionalFlag();
|
||||||
|
|
||||||
|
@ -406,7 +430,7 @@ function NoteEditor(props: NoteEditorProps) {
|
||||||
htmlToMarkdown: htmlToMarkdown,
|
htmlToMarkdown: htmlToMarkdown,
|
||||||
markupToHtml: markupToHtml,
|
markupToHtml: markupToHtml,
|
||||||
allAssets: allAssets,
|
allAssets: allAssets,
|
||||||
disabled: false,
|
disabled: isReadOnly,
|
||||||
themeId: props.themeId,
|
themeId: props.themeId,
|
||||||
dispatch: props.dispatch,
|
dispatch: props.dispatch,
|
||||||
noteToolbar: null,
|
noteToolbar: null,
|
||||||
|
@ -570,6 +594,7 @@ function NoteEditor(props: NoteEditorProps) {
|
||||||
noteTitle={formNote.title}
|
noteTitle={formNote.title}
|
||||||
noteUserUpdatedTime={formNote.user_updated_time}
|
noteUserUpdatedTime={formNote.user_updated_time}
|
||||||
onTitleChange={onTitleChange}
|
onTitleChange={onTitleChange}
|
||||||
|
disabled={isReadOnly}
|
||||||
/>
|
/>
|
||||||
{renderSearchInfo()}
|
{renderSearchInfo()}
|
||||||
<div style={{ display: 'flex', flex: 1, paddingLeft: theme.editorPaddingLeft, maxHeight: '100%' }}>
|
<div style={{ display: 'flex', flex: 1, paddingLeft: theme.editorPaddingLeft, maxHeight: '100%' }}>
|
||||||
|
@ -629,7 +654,9 @@ const mapStateToProps = (state: AppState) => {
|
||||||
], whenClauseContext)[0],
|
], whenClauseContext)[0],
|
||||||
contentMaxWidth: state.settings['style.editor.contentMaxWidth'],
|
contentMaxWidth: state.settings['style.editor.contentMaxWidth'],
|
||||||
isSafeMode: state.settings.isSafeMode,
|
isSafeMode: state.settings.isSafeMode,
|
||||||
useCustomPdfViewer: false, // state.settings.useCustomPdfViewer,
|
useCustomPdfViewer: false,
|
||||||
|
syncUserId: state.settings['sync.userId'],
|
||||||
|
shareCache: readFromSettings(state),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -38,6 +38,7 @@ interface Props {
|
||||||
isProvisional: boolean;
|
isProvisional: boolean;
|
||||||
titleInputRef: any;
|
titleInputRef: any;
|
||||||
onTitleChange(event: ChangeEvent<HTMLInputElement>): void;
|
onTitleChange(event: ChangeEvent<HTMLInputElement>): void;
|
||||||
|
disabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function styles_(props: Props) {
|
function styles_(props: Props) {
|
||||||
|
@ -98,6 +99,7 @@ export default function NoteTitleBar(props: Props) {
|
||||||
return <NoteToolbar
|
return <NoteToolbar
|
||||||
themeId={props.themeId}
|
themeId={props.themeId}
|
||||||
style={styles.toolbarStyle}
|
style={styles.toolbarStyle}
|
||||||
|
disabled={props.disabled}
|
||||||
/>;
|
/>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -109,6 +111,7 @@ export default function NoteTitleBar(props: Props) {
|
||||||
ref={props.titleInputRef}
|
ref={props.titleInputRef}
|
||||||
placeholder={props.isProvisional ? _('Creating new %s...', props.noteIsTodo ? _('to-do') : _('note')) : ''}
|
placeholder={props.isProvisional ? _('Creating new %s...', props.noteIsTodo ? _('to-do') : _('note')) : ''}
|
||||||
style={styles.titleInput}
|
style={styles.titleInput}
|
||||||
|
readOnly={props.disabled}
|
||||||
onChange={props.onTitleChange}
|
onChange={props.onTitleChange}
|
||||||
onKeyDown={onTitleKeydown}
|
onKeyDown={onTitleKeydown}
|
||||||
value={props.noteTitle}
|
value={props.noteTitle}
|
||||||
|
|
|
@ -1,5 +1,11 @@
|
||||||
import { CommandDeclaration } from '@joplin/lib/services/CommandService';
|
import { CommandDeclaration } from '@joplin/lib/services/CommandService';
|
||||||
import { _ } from '@joplin/lib/locale';
|
import { _ } from '@joplin/lib/locale';
|
||||||
|
import { joplinCommandToTinyMceCommands } from './NoteBody/TinyMCE/utils/joplinCommandToTinyMceCommands';
|
||||||
|
|
||||||
|
export const enabledCondition = (commandName: string) => {
|
||||||
|
const markdownEditorOnly = !Object.keys(joplinCommandToTinyMceCommands).includes(commandName);
|
||||||
|
return `(!modalDialogVisible || gotoAnythingVisible) ${markdownEditorOnly ? '&& markdownEditorPaneVisible' : ''} && oneNoteSelected && noteIsMarkdown && !noteIsReadOnly`;
|
||||||
|
};
|
||||||
|
|
||||||
const declarations: CommandDeclaration[] = [
|
const declarations: CommandDeclaration[] = [
|
||||||
{
|
{
|
||||||
|
|
|
@ -7,10 +7,13 @@ const Menu = bridge().Menu;
|
||||||
const MenuItem = bridge().MenuItem;
|
const MenuItem = bridge().MenuItem;
|
||||||
import Resource from '@joplin/lib/models/Resource';
|
import Resource from '@joplin/lib/models/Resource';
|
||||||
import BaseItem from '@joplin/lib/models/BaseItem';
|
import BaseItem from '@joplin/lib/models/BaseItem';
|
||||||
import BaseModel from '@joplin/lib/BaseModel';
|
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
|
||||||
import { processPastedHtml } from './resourceHandling';
|
import { processPastedHtml } from './resourceHandling';
|
||||||
import { NoteEntity, ResourceEntity } from '@joplin/lib/services/database/types';
|
import { NoteEntity, ResourceEntity } from '@joplin/lib/services/database/types';
|
||||||
import { TinyMceEditorEvents } from '../NoteBody/TinyMCE/utils/types';
|
import { TinyMceEditorEvents } from '../NoteBody/TinyMCE/utils/types';
|
||||||
|
import { itemIsReadOnlySync, ItemSlice } from '@joplin/lib/models/utils/readOnly';
|
||||||
|
import Setting from '@joplin/lib/models/Setting';
|
||||||
|
import ItemChange from '@joplin/lib/models/ItemChange';
|
||||||
const fs = require('fs-extra');
|
const fs = require('fs-extra');
|
||||||
const { writeFile } = require('fs-extra');
|
const { writeFile } = require('fs-extra');
|
||||||
const { clipboard } = require('electron');
|
const { clipboard } = require('electron');
|
||||||
|
@ -50,7 +53,11 @@ export async function openItemById(itemId: string, dispatch: Function, hash = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ResourceEditWatcher.instance().openAndWatch(resource.id);
|
if (itemIsReadOnlySync(ModelType.Resource, ItemChange.SOURCE_UNSPECIFIED, resource as ItemSlice, Setting.value('sync.userId'), BaseItem.syncShareCache)) {
|
||||||
|
await ResourceEditWatcher.instance().openAsReadOnly(resource.id);
|
||||||
|
} else {
|
||||||
|
await ResourceEditWatcher.instance().openAndWatch(resource.id);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
bridge().showErrorMessageBox(error.message);
|
bridge().showErrorMessageBox(error.message);
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
import AsyncActionQueue from '@joplin/lib/AsyncActionQueue';
|
import AsyncActionQueue from '@joplin/lib/AsyncActionQueue';
|
||||||
import { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils';
|
import { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils';
|
||||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||||
|
import { State as ShareState } from '@joplin/lib/services/share/reducer';
|
||||||
import { MarkupLanguage } from '@joplin/renderer';
|
import { MarkupLanguage } from '@joplin/renderer';
|
||||||
import { RenderResult, RenderResultPluginAsset } from '@joplin/renderer/MarkupToHtml';
|
import { RenderResult, RenderResultPluginAsset } from '@joplin/renderer/MarkupToHtml';
|
||||||
import { MarkupToHtmlOptions } from './useMarkupToHtml';
|
import { MarkupToHtmlOptions } from './useMarkupToHtml';
|
||||||
|
@ -45,6 +46,8 @@ export interface NoteEditorProps {
|
||||||
contentMaxWidth: number;
|
contentMaxWidth: number;
|
||||||
isSafeMode: boolean;
|
isSafeMode: boolean;
|
||||||
useCustomPdfViewer: boolean;
|
useCustomPdfViewer: boolean;
|
||||||
|
shareCache: ShareState;
|
||||||
|
syncUserId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NoteBodyEditorProps {
|
export interface NoteBodyEditorProps {
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { FormNote, ScrollOptionTypes } from './types';
|
import { FormNote, ScrollOptionTypes } from './types';
|
||||||
import editorCommandDeclarations from '../editorCommandDeclarations';
|
import editorCommandDeclarations, { enabledCondition } from '../editorCommandDeclarations';
|
||||||
import CommandService, { CommandDeclaration, CommandRuntime, CommandContext } from '@joplin/lib/services/CommandService';
|
import CommandService, { CommandDeclaration, CommandRuntime, CommandContext } from '@joplin/lib/services/CommandService';
|
||||||
import time from '@joplin/lib/time';
|
import time from '@joplin/lib/time';
|
||||||
import { reg } from '@joplin/lib/registry';
|
import { reg } from '@joplin/lib/registry';
|
||||||
import { joplinCommandToTinyMceCommands } from '../NoteBody/TinyMCE/utils/joplinCommandToTinyMceCommands';
|
|
||||||
|
|
||||||
const commandsWithDependencies = [
|
const commandsWithDependencies = [
|
||||||
require('../commands/showLocalSearch'),
|
require('../commands/showLocalSearch'),
|
||||||
|
@ -30,8 +29,6 @@ interface HookDependencies {
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||||
function editorCommandRuntime(declaration: CommandDeclaration, editorRef: any, setFormNote: Function): CommandRuntime {
|
function editorCommandRuntime(declaration: CommandDeclaration, editorRef: any, setFormNote: Function): CommandRuntime {
|
||||||
const markdownEditorOnly = !Object.keys(joplinCommandToTinyMceCommands).includes(declaration.name);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
execute: async (_context: CommandContext, ...args: any[]) => {
|
execute: async (_context: CommandContext, ...args: any[]) => {
|
||||||
if (!editorRef.current) {
|
if (!editorRef.current) {
|
||||||
|
@ -73,7 +70,7 @@ function editorCommandRuntime(declaration: CommandDeclaration, editorRef: any, s
|
||||||
// currently selected text.
|
// currently selected text.
|
||||||
//
|
//
|
||||||
// https://github.com/laurent22/joplin/issues/5707
|
// https://github.com/laurent22/joplin/issues/5707
|
||||||
enabledCondition: `(!modalDialogVisible || gotoAnythingVisible) ${markdownEditorOnly ? '&& markdownEditorPaneVisible' : ''} && oneNoteSelected && noteIsMarkdown`,
|
enabledCondition: enabledCondition(declaration.name),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@ import eventManager from '@joplin/lib/eventManager';
|
||||||
import NoteListUtils from '../utils/NoteListUtils';
|
import NoteListUtils from '../utils/NoteListUtils';
|
||||||
import { _ } from '@joplin/lib/locale';
|
import { _ } from '@joplin/lib/locale';
|
||||||
import time from '@joplin/lib/time';
|
import time from '@joplin/lib/time';
|
||||||
import BaseModel from '@joplin/lib/BaseModel';
|
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
|
||||||
import bridge from '../../services/bridge';
|
import bridge from '../../services/bridge';
|
||||||
import Setting from '@joplin/lib/models/Setting';
|
import Setting from '@joplin/lib/models/Setting';
|
||||||
import NoteListItem from '../NoteListItem';
|
import NoteListItem from '../NoteListItem';
|
||||||
|
@ -19,6 +19,9 @@ import Note from '@joplin/lib/models/Note';
|
||||||
import Folder from '@joplin/lib/models/Folder';
|
import Folder from '@joplin/lib/models/Folder';
|
||||||
import { Props } from './types';
|
import { Props } from './types';
|
||||||
import usePrevious from '../hooks/usePrevious';
|
import usePrevious from '../hooks/usePrevious';
|
||||||
|
import { itemIsReadOnlySync, ItemSlice } from '@joplin/lib/models/utils/readOnly';
|
||||||
|
import { FolderEntity } from '@joplin/lib/services/database/types';
|
||||||
|
import ItemChange from '@joplin/lib/models/ItemChange';
|
||||||
|
|
||||||
const commands = [
|
const commands = [
|
||||||
require('./commands/focusElementNoteList'),
|
require('./commands/focusElementNoteList'),
|
||||||
|
@ -186,7 +189,7 @@ const NoteListComponent = (props: Props) => {
|
||||||
setDragOverTargetNoteIndex(null);
|
setDragOverTargetNoteIndex(null);
|
||||||
|
|
||||||
const targetNoteIndex = dragTargetNoteIndex_(event);
|
const targetNoteIndex = dragTargetNoteIndex_(event);
|
||||||
const noteIds = JSON.parse(dt.getData('text/x-jop-note-ids'));
|
const noteIds: string[] = JSON.parse(dt.getData('text/x-jop-note-ids'));
|
||||||
|
|
||||||
void Note.insertNotesAt(props.selectedFolderId, noteIds, targetNoteIndex, props.uncompletedTodosOnTop, props.showCompletedTodos);
|
void Note.insertNotesAt(props.selectedFolderId, noteIds, targetNoteIndex, props.uncompletedTodosOnTop, props.showCompletedTodos);
|
||||||
};
|
};
|
||||||
|
@ -223,7 +226,9 @@ const NoteListComponent = (props: Props) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const noteItem_dragStart = (event: any) => {
|
const noteItem_dragStart = useCallback((event: any) => {
|
||||||
|
if (props.parentFolderIsReadOnly) return false;
|
||||||
|
|
||||||
let noteIds = [];
|
let noteIds = [];
|
||||||
|
|
||||||
// Here there is two cases:
|
// Here there is two cases:
|
||||||
|
@ -236,13 +241,14 @@ const NoteListComponent = (props: Props) => {
|
||||||
if (clickedNoteId) noteIds.push(clickedNoteId);
|
if (clickedNoteId) noteIds.push(clickedNoteId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!noteIds.length) return;
|
if (!noteIds.length) return false;
|
||||||
|
|
||||||
event.dataTransfer.setDragImage(new Image(), 1, 1);
|
event.dataTransfer.setDragImage(new Image(), 1, 1);
|
||||||
event.dataTransfer.clearData();
|
event.dataTransfer.clearData();
|
||||||
event.dataTransfer.setData('text/x-jop-note-ids', JSON.stringify(noteIds));
|
event.dataTransfer.setData('text/x-jop-note-ids', JSON.stringify(noteIds));
|
||||||
event.dataTransfer.effectAllowed = 'move';
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
};
|
return true;
|
||||||
|
}, [props.parentFolderIsReadOnly, props.selectedNoteIds]);
|
||||||
|
|
||||||
const renderItem = useCallback((item: any, index: number) => {
|
const renderItem = useCallback((item: any, index: number) => {
|
||||||
const highlightedWords = () => {
|
const highlightedWords = () => {
|
||||||
|
@ -278,6 +284,7 @@ const NoteListComponent = (props: Props) => {
|
||||||
onNoteDragOver={noteItem_noteDragOver}
|
onNoteDragOver={noteItem_noteDragOver}
|
||||||
onTitleClick={noteItem_titleClick}
|
onTitleClick={noteItem_titleClick}
|
||||||
onContextMenu={itemContextMenu}
|
onContextMenu={itemContextMenu}
|
||||||
|
draggable={!props.parentFolderIsReadOnly}
|
||||||
/>;
|
/>;
|
||||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||||
}, [style, props.themeId, width, itemHeight, dragOverTargetNoteIndex, props.provisionalNoteIds, props.selectedNoteIds, props.watchedNoteFiles,
|
}, [style, props.themeId, width, itemHeight, dragOverTargetNoteIndex, props.provisionalNoteIds, props.selectedNoteIds, props.watchedNoteFiles,
|
||||||
|
@ -286,6 +293,7 @@ const NoteListComponent = (props: Props) => {
|
||||||
props.searches,
|
props.searches,
|
||||||
props.selectedSearchId,
|
props.selectedSearchId,
|
||||||
props.highlightedWords,
|
props.highlightedWords,
|
||||||
|
props.parentFolderIsReadOnly,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const previousSelectedNoteIds = usePrevious(props.selectedNoteIds, []);
|
const previousSelectedNoteIds = usePrevious(props.selectedNoteIds, []);
|
||||||
|
@ -393,7 +401,8 @@ const NoteListComponent = (props: Props) => {
|
||||||
if (noteIds.length && (keyCode === 46 || (keyCode === 8 && event.metaKey))) {
|
if (noteIds.length && (keyCode === 46 || (keyCode === 8 && event.metaKey))) {
|
||||||
// DELETE / CMD+Backspace
|
// DELETE / CMD+Backspace
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
await NoteListUtils.confirmDeleteNotes(noteIds);
|
void CommandService.instance().execute('deleteNote', noteIds);
|
||||||
|
// await NoteListUtils.confirmDeleteNotes(noteIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (noteIds.length && keyCode === 32) {
|
if (noteIds.length && keyCode === 32) {
|
||||||
|
@ -541,6 +550,9 @@ const NoteListComponent = (props: Props) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapStateToProps = (state: AppState) => {
|
const mapStateToProps = (state: AppState) => {
|
||||||
|
const selectedFolder: FolderEntity = state.notesParentType === 'Folder' ? BaseModel.byId(state.folders, state.selectedFolderId) : null;
|
||||||
|
const userId = state.settings['sync.userId'];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
notes: state.notes,
|
notes: state.notes,
|
||||||
folders: state.folders,
|
folders: state.folders,
|
||||||
|
@ -560,6 +572,7 @@ const mapStateToProps = (state: AppState) => {
|
||||||
plugins: state.pluginService.plugins,
|
plugins: state.pluginService.plugins,
|
||||||
customCss: state.customCss,
|
customCss: state.customCss,
|
||||||
focusedField: state.focusedField,
|
focusedField: state.focusedField,
|
||||||
|
parentFolderIsReadOnly: state.notesParentType === 'Folder' && selectedFolder ? itemIsReadOnlySync(ModelType.Folder, ItemChange.SOURCE_UNSPECIFIED, selectedFolder as ItemSlice, userId, state.shareService) : false,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -25,4 +25,5 @@ export interface Props {
|
||||||
provisionalNoteIds: string[];
|
provisionalNoteIds: string[];
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
focusedField: string;
|
focusedField: string;
|
||||||
|
parentFolderIsReadOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,8 @@ import Note from '@joplin/lib/models/Note';
|
||||||
import { notesSortOrderNextField } from '../../services/sortOrder/notesSortOrderUtils';
|
import { notesSortOrderNextField } from '../../services/sortOrder/notesSortOrderUtils';
|
||||||
import { _ } from '@joplin/lib/locale';
|
import { _ } from '@joplin/lib/locale';
|
||||||
const { connect } = require('react-redux');
|
const { connect } = require('react-redux');
|
||||||
const styled = require('styled-components').default;
|
import styled from 'styled-components';
|
||||||
|
import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext';
|
||||||
|
|
||||||
enum BaseBreakpoint {
|
enum BaseBreakpoint {
|
||||||
Sm = 75,
|
Sm = 75,
|
||||||
|
@ -27,6 +28,8 @@ interface Props {
|
||||||
height: number;
|
height: number;
|
||||||
width: number;
|
width: number;
|
||||||
onContentHeightChange: (sameRow: boolean)=> void;
|
onContentHeightChange: (sameRow: boolean)=> void;
|
||||||
|
newNoteButtonEnabled: boolean;
|
||||||
|
newTodoButtonEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Breakpoints {
|
interface Breakpoints {
|
||||||
|
@ -255,6 +258,7 @@ function NoteListControls(props: Props) {
|
||||||
level={ButtonLevel.Primary}
|
level={ButtonLevel.Primary}
|
||||||
size={ButtonSize.Small}
|
size={ButtonSize.Small}
|
||||||
onClick={onNewNoteButtonClick}
|
onClick={onNewNoteButtonClick}
|
||||||
|
disabled={!props.newNoteButtonEnabled}
|
||||||
/>
|
/>
|
||||||
<StyledButton ref={newTodoRef}
|
<StyledButton ref={newTodoRef}
|
||||||
className="new-todo-button"
|
className="new-todo-button"
|
||||||
|
@ -264,6 +268,7 @@ function NoteListControls(props: Props) {
|
||||||
level={ButtonLevel.Secondary}
|
level={ButtonLevel.Secondary}
|
||||||
size={ButtonSize.Small}
|
size={ButtonSize.Small}
|
||||||
onClick={onNewTodoButtonClick}
|
onClick={onNewTodoButtonClick}
|
||||||
|
disabled={!props.newTodoButtonEnabled}
|
||||||
/>
|
/>
|
||||||
</TopRow>
|
</TopRow>
|
||||||
);
|
);
|
||||||
|
@ -300,9 +305,13 @@ function NoteListControls(props: Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapStateToProps = (state: AppState) => {
|
const mapStateToProps = (state: AppState) => {
|
||||||
|
const whenClauseContext = stateToWhenClauseContext(state);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// TODO: showNewNoteButtons and the logic associated is not needed anymore.
|
// TODO: showNewNoteButtons and the logic associated is not needed anymore.
|
||||||
showNewNoteButtons: true,
|
showNewNoteButtons: true,
|
||||||
|
newNoteButtonEnabled: CommandService.instance().isEnabled('newNote', whenClauseContext),
|
||||||
|
newTodoButtonEnabled: CommandService.instance().isEnabled('newTodo', whenClauseContext),
|
||||||
sortOrderButtonsVisible: state.settings['notes.sortOrder.buttonsVisible'],
|
sortOrderButtonsVisible: state.settings['notes.sortOrder.buttonsVisible'],
|
||||||
sortOrderField: state.settings['notes.sortOrder.field'],
|
sortOrderField: state.settings['notes.sortOrder.field'],
|
||||||
sortOrderReverse: state.settings['notes.sortOrder.reverse'],
|
sortOrderReverse: state.settings['notes.sortOrder.reverse'],
|
||||||
|
|
|
@ -58,6 +58,7 @@ interface NoteListItemProps {
|
||||||
onNoteDragOver: any;
|
onNoteDragOver: any;
|
||||||
onTitleClick: any;
|
onTitleClick: any;
|
||||||
onContextMenu(event: React.MouseEvent<HTMLAnchorElement, MouseEvent>): void;
|
onContextMenu(event: React.MouseEvent<HTMLAnchorElement, MouseEvent>): void;
|
||||||
|
draggable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function NoteListItem(props: NoteListItemProps, ref: any) {
|
function NoteListItem(props: NoteListItemProps, ref: any) {
|
||||||
|
@ -185,7 +186,7 @@ function NoteListItem(props: NoteListItemProps, ref: any) {
|
||||||
ref={anchorRef}
|
ref={anchorRef}
|
||||||
onContextMenu={props.onContextMenu}
|
onContextMenu={props.onContextMenu}
|
||||||
href="#"
|
href="#"
|
||||||
draggable={true}
|
draggable={props.draggable}
|
||||||
style={listItemTitleStyle}
|
style={listItemTitleStyle}
|
||||||
onClick={onTitleClick}
|
onClick={onTitleClick}
|
||||||
onDragStart={props.onDragStart}
|
onDragStart={props.onDragStart}
|
||||||
|
|
|
@ -11,6 +11,7 @@ interface NoteToolbarProps {
|
||||||
themeId: number;
|
themeId: number;
|
||||||
style: any;
|
style: any;
|
||||||
toolbarButtonInfos: ToolbarButtonInfo[];
|
toolbarButtonInfos: ToolbarButtonInfo[];
|
||||||
|
disabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function styles_(props: NoteToolbarProps) {
|
function styles_(props: NoteToolbarProps) {
|
||||||
|
@ -27,7 +28,7 @@ function styles_(props: NoteToolbarProps) {
|
||||||
|
|
||||||
function NoteToolbar(props: NoteToolbarProps) {
|
function NoteToolbar(props: NoteToolbarProps) {
|
||||||
const styles = styles_(props);
|
const styles = styles_(props);
|
||||||
return <ToolbarBase style={styles.root} items={props.toolbarButtonInfos} />;
|
return <ToolbarBase style={styles.root} items={props.toolbarButtonInfos} disabled={props.disabled}/>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const toolbarButtonUtils = new ToolbarButtonUtils(CommandService.instance());
|
const toolbarButtonUtils = new ToolbarButtonUtils(CommandService.instance());
|
||||||
|
|
|
@ -2,7 +2,7 @@ import Dialog from '../Dialog';
|
||||||
import DialogButtonRow, { ClickEvent, ButtonSpec } from '../DialogButtonRow';
|
import DialogButtonRow, { ClickEvent, ButtonSpec } from '../DialogButtonRow';
|
||||||
import DialogTitle from '../DialogTitle';
|
import DialogTitle from '../DialogTitle';
|
||||||
import { _ } from '@joplin/lib/locale';
|
import { _ } from '@joplin/lib/locale';
|
||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { FolderEntity } from '@joplin/lib/services/database/types';
|
import { FolderEntity } from '@joplin/lib/services/database/types';
|
||||||
import Folder from '@joplin/lib/models/Folder';
|
import Folder from '@joplin/lib/models/Folder';
|
||||||
import ShareService, { ApiShare } from '@joplin/lib/services/share/ShareService';
|
import ShareService, { ApiShare } from '@joplin/lib/services/share/ShareService';
|
||||||
|
@ -12,11 +12,12 @@ import StyledInput from '../style/StyledInput';
|
||||||
import Button, { ButtonSize } from '../Button/Button';
|
import Button, { ButtonSize } from '../Button/Button';
|
||||||
import Logger from '@joplin/lib/Logger';
|
import Logger from '@joplin/lib/Logger';
|
||||||
import StyledMessage from '../style/StyledMessage';
|
import StyledMessage from '../style/StyledMessage';
|
||||||
import { ShareUserStatus, StateShare, StateShareUser } from '@joplin/lib/services/share/reducer';
|
import { SharePermissions, ShareUserStatus, StateShare, StateShareUser } from '@joplin/lib/services/share/reducer';
|
||||||
import { State } from '@joplin/lib/reducer';
|
import { State } from '@joplin/lib/reducer';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { reg } from '@joplin/lib/registry';
|
import { reg } from '@joplin/lib/registry';
|
||||||
import useAsyncEffect, { AsyncEffectEvent } from '@joplin/lib/hooks/useAsyncEffect';
|
import useAsyncEffect, { AsyncEffectEvent } from '@joplin/lib/hooks/useAsyncEffect';
|
||||||
|
import { ChangeEvent, Dropdown, DropdownOptions, DropdownVariant } from '../Dropdown/Dropdown';
|
||||||
|
|
||||||
const logger = Logger.create('ShareFolderDialog');
|
const logger = Logger.create('ShareFolderDialog');
|
||||||
|
|
||||||
|
@ -108,13 +109,20 @@ enum ShareState {
|
||||||
}
|
}
|
||||||
|
|
||||||
function ShareFolderDialog(props: Props) {
|
function ShareFolderDialog(props: Props) {
|
||||||
|
const permissionOptions: DropdownOptions = {
|
||||||
|
'can_read': _('Can view'),
|
||||||
|
'can_read_and_write': _('Can edit'),
|
||||||
|
};
|
||||||
|
|
||||||
const [folder, setFolder] = useState<FolderEntity>(null);
|
const [folder, setFolder] = useState<FolderEntity>(null);
|
||||||
const [recipientEmail, setRecipientEmail] = useState<string>('');
|
const [recipientEmail, setRecipientEmail] = useState<string>('');
|
||||||
|
const [recipientPermissions, setRecipientPermissions] = useState<string>('can_read');
|
||||||
const [latestError, setLatestError] = useState<Error>(null);
|
const [latestError, setLatestError] = useState<Error>(null);
|
||||||
const [share, setShare] = useState<StateShare>(null);
|
const [share, setShare] = useState<StateShare>(null);
|
||||||
const [shareUsers, setShareUsers] = useState<StateShareUser[]>([]);
|
const [shareUsers, setShareUsers] = useState<StateShareUser[]>([]);
|
||||||
const [shareState, setShareState] = useState<ShareState>(ShareState.Idle);
|
const [shareState, setShareState] = useState<ShareState>(ShareState.Idle);
|
||||||
const [customButtons, setCustomButtons] = useState<ButtonSpec[]>([]);
|
const [customButtons, setCustomButtons] = useState<ButtonSpec[]>([]);
|
||||||
|
const [recipientsBeingUpdated, setRecipientsBeingUpdated] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
async function synchronize(event: AsyncEffectEvent = null) {
|
async function synchronize(event: AsyncEffectEvent = null) {
|
||||||
setShareState(ShareState.Synchronizing);
|
setShareState(ShareState.Synchronizing);
|
||||||
|
@ -166,7 +174,14 @@ function ShareFolderDialog(props: Props) {
|
||||||
void ShareService.instance().refreshShares();
|
void ShareService.instance().refreshShares();
|
||||||
}, [props.folderId]);
|
}, [props.folderId]);
|
||||||
|
|
||||||
async function shareRecipient_click() {
|
const permissionsFromString = (p: string): SharePermissions => {
|
||||||
|
return {
|
||||||
|
can_read: 1,
|
||||||
|
can_write: p === 'can_read_and_write' ? 1 : 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const shareRecipient_click = useCallback(async () => {
|
||||||
setShareState(ShareState.Creating);
|
setShareState(ShareState.Creating);
|
||||||
setLatestError(null);
|
setLatestError(null);
|
||||||
|
|
||||||
|
@ -192,7 +207,7 @@ function ShareFolderDialog(props: Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ShareService.instance().addShareRecipient(share.id, share.master_key_id, recipientEmail);
|
await ShareService.instance().addShareRecipient(share.id, share.master_key_id, recipientEmail, permissionsFromString(recipientPermissions));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Handle the error but continue the process because we need to at
|
// Handle the error but continue the process because we need to at
|
||||||
// least refresh the shares since one has been created above.
|
// least refresh the shares since one has been created above.
|
||||||
|
@ -212,7 +227,7 @@ function ShareFolderDialog(props: Props) {
|
||||||
} finally {
|
} finally {
|
||||||
defer(null);
|
defer(null);
|
||||||
}
|
}
|
||||||
}
|
}, [recipientPermissions, props.folderId, recipientEmail]);
|
||||||
|
|
||||||
function recipientEmail_change(event: any) {
|
function recipientEmail_change(event: any) {
|
||||||
setRecipientEmail(event.target.value);
|
setRecipientEmail(event.target.value);
|
||||||
|
@ -239,19 +254,41 @@ function ShareFolderDialog(props: Props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const recipientPermissions_change = useCallback((event: ChangeEvent) => {
|
||||||
|
setRecipientPermissions(event.value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
function renderAddRecipient() {
|
function renderAddRecipient() {
|
||||||
const disabled = shareState !== ShareState.Idle;
|
const disabled = shareState !== ShareState.Idle;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledAddRecipient>
|
<StyledAddRecipient>
|
||||||
<StyledFormLabel>{_('Add recipient:')}</StyledFormLabel>
|
<StyledFormLabel>{_('Add recipient:')}</StyledFormLabel>
|
||||||
<StyledRecipientControls>
|
<StyledRecipientControls>
|
||||||
<StyledRecipientInput disabled={disabled} type="email" placeholder="example@domain.com" value={recipientEmail} onChange={recipientEmail_change} />
|
<StyledRecipientInput disabled={disabled} type="email" placeholder="example@domain.com" value={recipientEmail} onChange={recipientEmail_change} />
|
||||||
|
<Dropdown className="permission-dropdown" options={permissionOptions} value={recipientPermissions} onChange={recipientPermissions_change}/>
|
||||||
<Button size={ButtonSize.Small} disabled={disabled} title={_('Share')} onClick={shareRecipient_click}></Button>
|
<Button size={ButtonSize.Small} disabled={disabled} title={_('Share')} onClick={shareRecipient_click}></Button>
|
||||||
</StyledRecipientControls>
|
</StyledRecipientControls>
|
||||||
</StyledAddRecipient>
|
</StyledAddRecipient>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const recipient_permissionChange = useCallback(async (shareUserId: string, value: string) => {
|
||||||
|
try {
|
||||||
|
setRecipientsBeingUpdated(prev => {
|
||||||
|
return { ...prev, [shareUserId]: true };
|
||||||
|
});
|
||||||
|
await ShareService.instance().setPermissions(share.id, shareUserId, permissionsFromString(value));
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Could not set permissions: ${error.message}`);
|
||||||
|
logger.error(error);
|
||||||
|
} finally {
|
||||||
|
setRecipientsBeingUpdated(prev => {
|
||||||
|
return { ...prev, [shareUserId]: false };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [share]);
|
||||||
|
|
||||||
function renderRecipient(index: number, shareUser: StateShareUser) {
|
function renderRecipient(index: number, shareUser: StateShareUser) {
|
||||||
const statusToIcon = {
|
const statusToIcon = {
|
||||||
[ShareUserStatus.Waiting]: 'fas fa-question',
|
[ShareUserStatus.Waiting]: 'fas fa-question',
|
||||||
|
@ -265,11 +302,15 @@ function ShareFolderDialog(props: Props) {
|
||||||
[ShareUserStatus.Accepted]: _('Recipient has accepted the invitation'),
|
[ShareUserStatus.Accepted]: _('Recipient has accepted the invitation'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const permission = shareUser.can_write ? 'can_read_and_write' : 'can_read';
|
||||||
|
const enabled = !recipientsBeingUpdated[shareUser.id];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledRecipient key={shareUser.user.email} index={index}>
|
<StyledRecipient key={shareUser.user.email} index={index}>
|
||||||
<StyledRecipientName>{shareUser.user.email}</StyledRecipientName>
|
<StyledRecipientName>{shareUser.user.email}</StyledRecipientName>
|
||||||
|
<Dropdown disabled={!enabled} className="permission-dropdown" value={permission} options={permissionOptions} variant={DropdownVariant.NoBorder} onChange={event => recipient_permissionChange(shareUser.id, event.value)}/>
|
||||||
<StyledRecipientStatusIcon title={statusToMessage[shareUser.status]} className={statusToIcon[shareUser.status]}></StyledRecipientStatusIcon>
|
<StyledRecipientStatusIcon title={statusToMessage[shareUser.status]} className={statusToIcon[shareUser.status]}></StyledRecipientStatusIcon>
|
||||||
<Button size={ButtonSize.Small} iconName="far fa-times-circle" onClick={() => recipient_delete({ shareUserId: shareUser.id })}/>
|
<Button disabled={!enabled} size={ButtonSize.Small} iconName="far fa-times-circle" onClick={() => recipient_delete({ shareUserId: shareUser.id })}/>
|
||||||
</StyledRecipient>
|
</StyledRecipient>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -335,7 +376,7 @@ function ShareFolderDialog(props: Props) {
|
||||||
|
|
||||||
function renderContent() {
|
function renderContent() {
|
||||||
return (
|
return (
|
||||||
<StyledRoot>
|
<StyledRoot className="share-folder-dialog">
|
||||||
<DialogTitle title={_('Share Notebook')}/>
|
<DialogTitle title={_('Share Notebook')}/>
|
||||||
{renderFolder()}
|
{renderFolder()}
|
||||||
{renderAddRecipient()}
|
{renderAddRecipient()}
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
.share-folder-dialog {
|
||||||
|
.permission-dropdown {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
}
|
|
@ -296,12 +296,9 @@ const SidebarComponent = (props: Props) => {
|
||||||
const state: AppState = store().getState();
|
const state: AppState = store().getState();
|
||||||
|
|
||||||
let deleteMessage = '';
|
let deleteMessage = '';
|
||||||
let deleteButtonLabel = _('Remove');
|
const deleteButtonLabel = _('Remove');
|
||||||
if (itemType === BaseModel.TYPE_FOLDER) {
|
|
||||||
const folder = await Folder.load(itemId);
|
if (itemType === BaseModel.TYPE_TAG) {
|
||||||
deleteMessage = _('Delete notebook "%s"?\n\nAll notes and sub-notebooks within this notebook will also be deleted.', substrWithEllipsis(folder.title, 0, 32));
|
|
||||||
deleteButtonLabel = _('Delete');
|
|
||||||
} else if (itemType === BaseModel.TYPE_TAG) {
|
|
||||||
const tag = await Tag.load(itemId);
|
const tag = await Tag.load(itemId);
|
||||||
deleteMessage = _('Remove tag "%s" from all notes?', substrWithEllipsis(tag.title, 0, 32));
|
deleteMessage = _('Remove tag "%s" from all notes?', substrWithEllipsis(tag.title, 0, 32));
|
||||||
} else if (itemType === BaseModel.TYPE_SEARCH) {
|
} else if (itemType === BaseModel.TYPE_SEARCH) {
|
||||||
|
@ -321,29 +318,33 @@ const SidebarComponent = (props: Props) => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
menu.append(
|
if (itemType === BaseModel.TYPE_FOLDER) {
|
||||||
new MenuItem({
|
menu.append(
|
||||||
label: deleteButtonLabel,
|
new MenuItem(menuUtils.commandToStatefulMenuItem('deleteFolder', itemId))
|
||||||
click: async () => {
|
);
|
||||||
const ok = bridge().showConfirmMessageBox(deleteMessage, {
|
} else {
|
||||||
buttons: [deleteButtonLabel, _('Cancel')],
|
menu.append(
|
||||||
defaultId: 1,
|
new MenuItem({
|
||||||
});
|
label: deleteButtonLabel,
|
||||||
if (!ok) return;
|
click: async () => {
|
||||||
|
const ok = bridge().showConfirmMessageBox(deleteMessage, {
|
||||||
if (itemType === BaseModel.TYPE_FOLDER) {
|
buttons: [deleteButtonLabel, _('Cancel')],
|
||||||
await Folder.delete(itemId);
|
defaultId: 1,
|
||||||
} else if (itemType === BaseModel.TYPE_TAG) {
|
|
||||||
await Tag.untagAll(itemId);
|
|
||||||
} else if (itemType === BaseModel.TYPE_SEARCH) {
|
|
||||||
props.dispatch({
|
|
||||||
type: 'SEARCH_DELETE',
|
|
||||||
id: itemId,
|
|
||||||
});
|
});
|
||||||
}
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied) {
|
if (itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied) {
|
||||||
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('openFolderDialog', { folderId: itemId })));
|
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('openFolderDialog', { folderId: itemId })));
|
||||||
|
|
|
@ -11,7 +11,7 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import useAsyncEffect, { AsyncEffectEvent } from '@joplin/lib/hooks/useAsyncEffect';
|
import useAsyncEffect, { AsyncEffectEvent } from '@joplin/lib/hooks/useAsyncEffect';
|
||||||
import themeToCss from '@joplin/lib/services/style/themeToCss';
|
import themeToCss from '@joplin/lib/services/style/themeToCss';
|
||||||
import { themeById } from '@joplin/lib/theme';
|
import { addExtraStyles, themeById } from '@joplin/lib/theme';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
themeId: any;
|
themeId: any;
|
||||||
|
@ -21,7 +21,7 @@ export default function(props: Props): any {
|
||||||
const [styleSheetContent, setStyleSheetContent] = useState('');
|
const [styleSheetContent, setStyleSheetContent] = useState('');
|
||||||
|
|
||||||
useAsyncEffect(async (event: AsyncEffectEvent) => {
|
useAsyncEffect(async (event: AsyncEffectEvent) => {
|
||||||
const theme = themeById(props.themeId);
|
const theme = addExtraStyles(themeById(props.themeId));
|
||||||
const themeCss = themeToCss(theme);
|
const themeCss = themeToCss(theme);
|
||||||
if (event.cancelled) return;
|
if (event.cancelled) return;
|
||||||
setStyleSheetContent(themeCss);
|
setStyleSheetContent(themeCss);
|
||||||
|
|
|
@ -9,6 +9,7 @@ interface Props {
|
||||||
themeId: number;
|
themeId: number;
|
||||||
style: any;
|
style: any;
|
||||||
items: any[];
|
items: any[];
|
||||||
|
disabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
class ToolbarBaseComponent extends React.Component<Props, any> {
|
class ToolbarBaseComponent extends React.Component<Props, any> {
|
||||||
|
@ -46,6 +47,7 @@ class ToolbarBaseComponent extends React.Component<Props, any> {
|
||||||
const props = {
|
const props = {
|
||||||
key: key,
|
key: key,
|
||||||
themeId: this.props.themeId,
|
themeId: this.props.themeId,
|
||||||
|
disabled: this.props.disabled,
|
||||||
...o,
|
...o,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -53,17 +53,7 @@ export default class NoteListUtils {
|
||||||
);
|
);
|
||||||
|
|
||||||
menu.append(
|
menu.append(
|
||||||
new MenuItem({
|
new MenuItem(menuUtils.commandToStatefulMenuItem('duplicateNote', noteIds))
|
||||||
label: _('Duplicate'),
|
|
||||||
click: async () => {
|
|
||||||
for (let i = 0; i < noteIds.length; i++) {
|
|
||||||
const note = await Note.load(noteIds[i]);
|
|
||||||
await Note.duplicate(noteIds[i], {
|
|
||||||
uniqueTitle: _('%s - Copy', note.title),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (singleNoteId) {
|
if (singleNoteId) {
|
||||||
|
@ -73,22 +63,9 @@ export default class NoteListUtils {
|
||||||
|
|
||||||
if (noteIds.length <= 1) {
|
if (noteIds.length <= 1) {
|
||||||
menu.append(
|
menu.append(
|
||||||
new MenuItem({
|
new MenuItem(
|
||||||
label: _('Switch between note and to-do type'),
|
menuUtils.commandToStatefulMenuItem('toggleNoteType', noteIds)
|
||||||
click: async () => {
|
)
|
||||||
for (let i = 0; i < noteIds.length; i++) {
|
|
||||||
const note = await Note.load(noteIds[i]);
|
|
||||||
const newNote = await Note.save(Note.toggleIsTodo(note), { userSideValidation: true });
|
|
||||||
const eventNote = {
|
|
||||||
id: newNote.id,
|
|
||||||
is_todo: newNote.is_todo,
|
|
||||||
todo_due: newNote.todo_due,
|
|
||||||
todo_completed: newNote.todo_completed,
|
|
||||||
};
|
|
||||||
eventManager.emit('noteTypeToggle', { noteId: note.id, note: eventNote });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const switchNoteType = async (noteIds: string[], type: string) => {
|
const switchNoteType = async (noteIds: string[], type: string) => {
|
||||||
|
@ -189,12 +166,9 @@ export default class NoteListUtils {
|
||||||
}
|
}
|
||||||
|
|
||||||
menu.append(
|
menu.append(
|
||||||
new MenuItem({
|
new MenuItem(
|
||||||
label: _('Delete'),
|
menuUtils.commandToStatefulMenuItem('deleteNote', noteIds)
|
||||||
click: async () => {
|
)
|
||||||
await this.confirmDeleteNotes(noteIds);
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const pluginViewInfos = pluginUtils.viewInfosByType(props.plugins, 'menuItem');
|
const pluginViewInfos = pluginUtils.viewInfosByType(props.plugins, 'menuItem');
|
||||||
|
@ -213,19 +187,4 @@ export default class NoteListUtils {
|
||||||
return menu;
|
return menu;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async confirmDeleteNotes(noteIds: string[]) {
|
|
||||||
if (!noteIds.length) return;
|
|
||||||
|
|
||||||
const msg = await Note.deleteMessage(noteIds);
|
|
||||||
if (!msg) return;
|
|
||||||
|
|
||||||
const ok = bridge().showConfirmMessageBox(msg, {
|
|
||||||
buttons: [_('Delete'), _('Cancel')],
|
|
||||||
defaultId: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!ok) return;
|
|
||||||
await Note.batchDelete(noteIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,4 +2,6 @@
|
||||||
@use 'gui/EditFolderDialog/style.scss' as edit-folder-dialog;
|
@use 'gui/EditFolderDialog/style.scss' as edit-folder-dialog;
|
||||||
@use 'gui/EncryptionConfigScreen/style.scss' as encryption-config-screen;
|
@use 'gui/EncryptionConfigScreen/style.scss' as encryption-config-screen;
|
||||||
@use 'gui/PasswordInput/style.scss' as password-input;
|
@use 'gui/PasswordInput/style.scss' as password-input;
|
||||||
|
@use 'gui/Dropdown/style.scss' as dropdown-control;
|
||||||
|
@use 'gui/ShareFolderDialog/style.scss' as share-folder-dialog;
|
||||||
@use 'main.scss' as main;
|
@use 'main.scss' as main;
|
|
@ -342,6 +342,8 @@ export function initCodeMirror(
|
||||||
|
|
||||||
...defaultKeymap, ...historyKeymap, indentWithTab, ...searchKeymap,
|
...defaultKeymap, ...historyKeymap, indentWithTab, ...searchKeymap,
|
||||||
]),
|
]),
|
||||||
|
|
||||||
|
EditorState.readOnly.of(settings.readOnly),
|
||||||
],
|
],
|
||||||
doc: initialText,
|
doc: initialText,
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -31,6 +31,7 @@ interface MarkdownToolbarProps {
|
||||||
editorSettings: EditorSettings;
|
editorSettings: EditorSettings;
|
||||||
onAttach: OnAttachCallback;
|
onAttach: OnAttachCallback;
|
||||||
style?: ViewStyle;
|
style?: ViewStyle;
|
||||||
|
readOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
||||||
|
@ -38,6 +39,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
||||||
const styles = useStyles(props.style, themeData);
|
const styles = useStyles(props.style, themeData);
|
||||||
const selState = props.selectionState;
|
const selState = props.selectionState;
|
||||||
const editorControl = props.editorControl;
|
const editorControl = props.editorControl;
|
||||||
|
const readOnly = props.readOnly;
|
||||||
|
|
||||||
const headerButtons: ButtonSpec[] = [];
|
const headerButtons: ButtonSpec[] = [];
|
||||||
for (let level = 1; level <= 5; level++) {
|
for (let level = 1; level <= 5; level++) {
|
||||||
|
@ -58,6 +60,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
||||||
// Make it likely for the first three header buttons to show, less likely for
|
// Make it likely for the first three header buttons to show, less likely for
|
||||||
// the others.
|
// the others.
|
||||||
priority: level < 3 ? 2 : 0,
|
priority: level < 3 ? 2 : 0,
|
||||||
|
disabled: readOnly,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -73,6 +76,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
||||||
}, [editorControl]),
|
}, [editorControl]),
|
||||||
|
|
||||||
priority: -2,
|
priority: -2,
|
||||||
|
disabled: readOnly,
|
||||||
});
|
});
|
||||||
|
|
||||||
listButtons.push({
|
listButtons.push({
|
||||||
|
@ -86,6 +90,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
||||||
}, [editorControl]),
|
}, [editorControl]),
|
||||||
|
|
||||||
priority: -2,
|
priority: -2,
|
||||||
|
disabled: readOnly,
|
||||||
});
|
});
|
||||||
|
|
||||||
listButtons.push({
|
listButtons.push({
|
||||||
|
@ -99,6 +104,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
||||||
}, [editorControl]),
|
}, [editorControl]),
|
||||||
|
|
||||||
priority: -2,
|
priority: -2,
|
||||||
|
disabled: readOnly,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
@ -110,6 +116,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
||||||
onPress: editorControl.decreaseIndent,
|
onPress: editorControl.decreaseIndent,
|
||||||
|
|
||||||
priority: -1,
|
priority: -1,
|
||||||
|
disabled: readOnly,
|
||||||
});
|
});
|
||||||
|
|
||||||
listButtons.push({
|
listButtons.push({
|
||||||
|
@ -120,6 +127,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
||||||
onPress: editorControl.increaseIndent,
|
onPress: editorControl.increaseIndent,
|
||||||
|
|
||||||
priority: -1,
|
priority: -1,
|
||||||
|
disabled: readOnly,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
@ -134,6 +142,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
||||||
onPress: editorControl.toggleBolded,
|
onPress: editorControl.toggleBolded,
|
||||||
|
|
||||||
priority: 3,
|
priority: 3,
|
||||||
|
disabled: readOnly,
|
||||||
});
|
});
|
||||||
|
|
||||||
inlineFormattingBtns.push({
|
inlineFormattingBtns.push({
|
||||||
|
@ -145,6 +154,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
||||||
onPress: editorControl.toggleItalicized,
|
onPress: editorControl.toggleItalicized,
|
||||||
|
|
||||||
priority: 2,
|
priority: 2,
|
||||||
|
disabled: readOnly,
|
||||||
});
|
});
|
||||||
|
|
||||||
inlineFormattingBtns.push({
|
inlineFormattingBtns.push({
|
||||||
|
@ -154,6 +164,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
||||||
onPress: editorControl.toggleCode,
|
onPress: editorControl.toggleCode,
|
||||||
|
|
||||||
priority: 2,
|
priority: 2,
|
||||||
|
disabled: readOnly,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (props.editorSettings.katexEnabled) {
|
if (props.editorSettings.katexEnabled) {
|
||||||
|
@ -164,6 +175,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
||||||
onPress: editorControl.toggleMath,
|
onPress: editorControl.toggleMath,
|
||||||
|
|
||||||
priority: 1,
|
priority: 1,
|
||||||
|
disabled: readOnly,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -176,6 +188,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
||||||
onPress: editorControl.showLinkDialog,
|
onPress: editorControl.showLinkDialog,
|
||||||
|
|
||||||
priority: -3,
|
priority: -3,
|
||||||
|
disabled: readOnly,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
@ -189,6 +202,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
||||||
onPress: useCallback(() => {
|
onPress: useCallback(() => {
|
||||||
editorControl.insertText(time.formatDateToLocal(new Date()));
|
editorControl.insertText(time.formatDateToLocal(new Date()));
|
||||||
}, [editorControl]),
|
}, [editorControl]),
|
||||||
|
disabled: readOnly,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onDismissKeyboard = useCallback(() => {
|
const onDismissKeyboard = useCallback(() => {
|
||||||
|
@ -208,6 +222,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
||||||
onDismissKeyboard();
|
onDismissKeyboard();
|
||||||
props.onAttach();
|
props.onAttach();
|
||||||
}, [props.onAttach, onDismissKeyboard]),
|
}, [props.onAttach, onDismissKeyboard]),
|
||||||
|
disabled: readOnly,
|
||||||
});
|
});
|
||||||
|
|
||||||
actionButtons.push({
|
actionButtons.push({
|
||||||
|
@ -227,6 +242,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
||||||
}, [editorControl, props.searchState.dialogVisible]),
|
}, [editorControl, props.searchState.dialogVisible]),
|
||||||
|
|
||||||
priority: -3,
|
priority: -3,
|
||||||
|
disabled: readOnly,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [keyboardVisible, setKeyboardVisible] = useState(false);
|
const [keyboardVisible, setKeyboardVisible] = useState(false);
|
||||||
|
|
|
@ -31,6 +31,7 @@ interface Props {
|
||||||
style: ViewStyle;
|
style: ViewStyle;
|
||||||
contentStyle?: ViewStyle;
|
contentStyle?: ViewStyle;
|
||||||
toolbarEnabled: boolean;
|
toolbarEnabled: boolean;
|
||||||
|
readOnly: boolean;
|
||||||
|
|
||||||
onChange: ChangeEventHandler;
|
onChange: ChangeEventHandler;
|
||||||
onSelectionChange: SelectionChangeEventHandler;
|
onSelectionChange: SelectionChangeEventHandler;
|
||||||
|
@ -226,6 +227,7 @@ function NoteEditor(props: Props, ref: any) {
|
||||||
themeData: editorTheme(props.themeId),
|
themeData: editorTheme(props.themeId),
|
||||||
katexEnabled: Setting.value('markdown.plugin.katex'),
|
katexEnabled: Setting.value('markdown.plugin.katex'),
|
||||||
spellcheckEnabled: Setting.value('editor.mobile.spellcheckEnabled'),
|
spellcheckEnabled: Setting.value('editor.mobile.spellcheckEnabled'),
|
||||||
|
readOnly: props.readOnly,
|
||||||
};
|
};
|
||||||
|
|
||||||
const injectedJavaScript = `
|
const injectedJavaScript = `
|
||||||
|
@ -377,6 +379,7 @@ function NoteEditor(props: Props, ref: any) {
|
||||||
selectionState={selectionState}
|
selectionState={selectionState}
|
||||||
searchState={searchState}
|
searchState={searchState}
|
||||||
onAttach={props.onAttach}
|
onAttach={props.onAttach}
|
||||||
|
readOnly={props.readOnly}
|
||||||
/>;
|
/>;
|
||||||
|
|
||||||
// - `scrollEnabled` prevents iOS from scrolling the document (has no effect on Android)
|
// - `scrollEnabled` prevents iOS from scrolling the document (has no effect on Android)
|
||||||
|
|
|
@ -19,6 +19,7 @@ export interface EditorSettings {
|
||||||
|
|
||||||
katexEnabled: boolean;
|
katexEnabled: boolean;
|
||||||
spellcheckEnabled: boolean;
|
spellcheckEnabled: boolean;
|
||||||
|
readOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChangeEvent {
|
export interface ChangeEvent {
|
||||||
|
|
|
@ -7,7 +7,7 @@ const Icon = require('react-native-vector-icons/Ionicons').default;
|
||||||
const { BackButtonService } = require('../services/back-button.js');
|
const { BackButtonService } = require('../services/back-button.js');
|
||||||
import NavService from '@joplin/lib/services/NavService';
|
import NavService from '@joplin/lib/services/NavService';
|
||||||
import { Menu, MenuOptions, MenuOption, MenuTrigger } from 'react-native-popup-menu';
|
import { Menu, MenuOptions, MenuOption, MenuTrigger } from 'react-native-popup-menu';
|
||||||
import { _ } from '@joplin/lib/locale';
|
import { _, _n } from '@joplin/lib/locale';
|
||||||
import Setting from '@joplin/lib/models/Setting';
|
import Setting from '@joplin/lib/models/Setting';
|
||||||
import Note from '@joplin/lib/models/Note';
|
import Note from '@joplin/lib/models/Note';
|
||||||
import Folder from '@joplin/lib/models/Folder';
|
import Folder from '@joplin/lib/models/Folder';
|
||||||
|
@ -40,10 +40,11 @@ interface NavButtonPressEvent {
|
||||||
screen: string;
|
screen: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MenuOptionType {
|
export interface MenuOptionType {
|
||||||
onPress: OnPressCallback;
|
onPress: OnPressCallback;
|
||||||
isDivider?: boolean;
|
isDivider?: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type DispatchCommandType=(event: { type: string })=> void;
|
type DispatchCommandType=(event: { type: string })=> void;
|
||||||
|
@ -201,6 +202,11 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
styleObject.contextMenuItemTextDisabled = {
|
||||||
|
...styleObject.contextMenuItemText,
|
||||||
|
opacity: 0.5,
|
||||||
|
};
|
||||||
|
|
||||||
styleObject.topIcon = { ...theme.icon };
|
styleObject.topIcon = { ...theme.icon };
|
||||||
styleObject.topIcon.flex = 1;
|
styleObject.topIcon.flex = 1;
|
||||||
styleObject.topIcon.textAlignVertical = 'center';
|
styleObject.topIcon.textAlignVertical = 'center';
|
||||||
|
@ -240,11 +246,15 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
|
||||||
private async duplicateButton_press() {
|
private async duplicateButton_press() {
|
||||||
const noteIds = this.props.selectedNoteIds;
|
const noteIds = this.props.selectedNoteIds;
|
||||||
|
|
||||||
// Duplicate all selected notes. ensureUniqueTitle is set to true to use the
|
|
||||||
// original note's name as a root for the new unique identifier.
|
|
||||||
await Note.duplicateMultipleNotes(noteIds, { ensureUniqueTitle: true });
|
|
||||||
|
|
||||||
this.props.dispatch({ type: 'NOTE_SELECTION_END' });
|
this.props.dispatch({ type: 'NOTE_SELECTION_END' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Duplicate all selected notes. ensureUniqueTitle is set to true to use the
|
||||||
|
// original note's name as a root for the new unique identifier.
|
||||||
|
await Note.duplicateMultipleNotes(noteIds, { ensureUniqueTitle: true });
|
||||||
|
} catch (error) {
|
||||||
|
alert(_n('This note could not be duplicated: %s', 'These notes could not be duplicated: %s', noteIds.length, error.message));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async deleteButton_press() {
|
private async deleteButton_press() {
|
||||||
|
@ -259,7 +269,12 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
|
||||||
this.props.dispatch({ type: 'NOTE_SELECTION_END' });
|
this.props.dispatch({ type: 'NOTE_SELECTION_END' });
|
||||||
await Note.batchDelete(noteIds);
|
|
||||||
|
try {
|
||||||
|
await Note.batchDelete(noteIds);
|
||||||
|
} catch (error) {
|
||||||
|
alert(_n('This note could not be deleted: %s', 'These notes could not be deleted: %s', noteIds.length, error.message));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private menu_select(value: OnSelectCallbackType) {
|
private menu_select(value: OnSelectCallbackType) {
|
||||||
|
@ -470,8 +485,8 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
|
||||||
menuOptionComponents.push(<View key={`menuOption_${key++}`} style={this.styles().divider} />);
|
menuOptionComponents.push(<View key={`menuOption_${key++}`} style={this.styles().divider} />);
|
||||||
} else {
|
} else {
|
||||||
menuOptionComponents.push(
|
menuOptionComponents.push(
|
||||||
<MenuOption value={o.onPress} key={`menuOption_${key++}`} style={this.styles().contextMenuItem}>
|
<MenuOption value={o.onPress} key={`menuOption_${key++}`} style={this.styles().contextMenuItem} disabled={!!o.disabled}>
|
||||||
<Text style={this.styles().contextMenuItemText}>{o.title}</Text>
|
<Text style={o.disabled ? this.styles().contextMenuItemTextDisabled : this.styles().contextMenuItemText}>{o.title}</Text>
|
||||||
</MenuOption>
|
</MenuOption>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -523,8 +538,13 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
|
||||||
this.props.dispatch({ type: 'NOTE_SELECTION_END' });
|
this.props.dispatch({ type: 'NOTE_SELECTION_END' });
|
||||||
for (let i = 0; i < noteIds.length; i++) {
|
|
||||||
await Note.moveToFolder(noteIds[i], folderId);
|
try {
|
||||||
|
for (let i = 0; i < noteIds.length; i++) {
|
||||||
|
await Note.moveToFolder(noteIds[i], folderId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert(_n('This note could not be moved: %s', 'These notes could not be moved: %s', noteIds.length, error.message));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
mustSelect={!!folderPickerOptions.mustSelect}
|
mustSelect={!!folderPickerOptions.mustSelect}
|
||||||
|
|
|
@ -25,7 +25,7 @@ import BaseModel from '@joplin/lib/BaseModel';
|
||||||
import ActionButton from '../ActionButton';
|
import ActionButton from '../ActionButton';
|
||||||
const { fileExtension, safeFileExtension } = require('@joplin/lib/path-utils');
|
const { fileExtension, safeFileExtension } = require('@joplin/lib/path-utils');
|
||||||
const mimeUtils = require('@joplin/lib/mime-utils.js').mime;
|
const mimeUtils = require('@joplin/lib/mime-utils.js').mime;
|
||||||
import ScreenHeader from '../ScreenHeader';
|
import ScreenHeader, { MenuOptionType } from '../ScreenHeader';
|
||||||
const NoteTagsDialog = require('./NoteTagsDialog');
|
const NoteTagsDialog = require('./NoteTagsDialog');
|
||||||
import time from '@joplin/lib/time';
|
import time from '@joplin/lib/time';
|
||||||
const { Checkbox } = require('../checkbox.js');
|
const { Checkbox } = require('../checkbox.js');
|
||||||
|
@ -960,13 +960,14 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||||
const note = this.state.note;
|
const note = this.state.note;
|
||||||
const isTodo = note && !!note.is_todo;
|
const isTodo = note && !!note.is_todo;
|
||||||
const isSaved = note && note.id;
|
const isSaved = note && note.id;
|
||||||
|
const readOnly = this.state.readOnly;
|
||||||
|
|
||||||
const cacheKey = md5([isTodo, isSaved].join('_'));
|
const cacheKey = md5([isTodo, isSaved].join('_'));
|
||||||
if (!this.menuOptionsCache_) this.menuOptionsCache_ = {};
|
if (!this.menuOptionsCache_) this.menuOptionsCache_ = {};
|
||||||
|
|
||||||
if (this.menuOptionsCache_[cacheKey]) return this.menuOptionsCache_[cacheKey];
|
if (this.menuOptionsCache_[cacheKey]) return this.menuOptionsCache_[cacheKey];
|
||||||
|
|
||||||
const output = [];
|
const output: MenuOptionType[] = [];
|
||||||
|
|
||||||
// The file attachement modules only work in Android >= 5 (Version 21)
|
// The file attachement modules only work in Android >= 5 (Version 21)
|
||||||
// https://github.com/react-community/react-native-image-picker/issues/606
|
// https://github.com/react-community/react-native-image-picker/issues/606
|
||||||
|
@ -981,6 +982,7 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||||
output.push({
|
output.push({
|
||||||
title: _('Attach...'),
|
title: _('Attach...'),
|
||||||
onPress: () => this.showAttachMenu(),
|
onPress: () => this.showAttachMenu(),
|
||||||
|
disabled: readOnly,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -990,6 +992,7 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
this.setState({ alarmDialogShown: true });
|
this.setState({ alarmDialogShown: true });
|
||||||
},
|
},
|
||||||
|
disabled: readOnly,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -998,6 +1001,7 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
void this.share_onPress();
|
void this.share_onPress();
|
||||||
},
|
},
|
||||||
|
disabled: readOnly,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Voice typing is enabled only for French language and on Android for now
|
// Voice typing is enabled only for French language and on Android for now
|
||||||
|
@ -1008,6 +1012,7 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||||
// this.voiceRecording_onPress();
|
// this.voiceRecording_onPress();
|
||||||
this.setState({ voiceTypingDialogShown: true });
|
this.setState({ voiceTypingDialogShown: true });
|
||||||
},
|
},
|
||||||
|
disabled: readOnly,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1024,6 +1029,7 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
this.toggleIsTodo_onPress();
|
this.toggleIsTodo_onPress();
|
||||||
},
|
},
|
||||||
|
disabled: readOnly,
|
||||||
});
|
});
|
||||||
if (isSaved) {
|
if (isSaved) {
|
||||||
output.push({
|
output.push({
|
||||||
|
@ -1044,6 +1050,7 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
void this.deleteNote_onPress();
|
void this.deleteNote_onPress();
|
||||||
},
|
},
|
||||||
|
disabled: readOnly,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.menuOptionsCache_ = {};
|
this.menuOptionsCache_ = {};
|
||||||
|
@ -1106,7 +1113,7 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||||
|
|
||||||
public folderPickerOptions() {
|
public folderPickerOptions() {
|
||||||
const options = {
|
const options = {
|
||||||
enabled: true,
|
enabled: !this.state.readOnly,
|
||||||
selectedFolderId: this.state.folder ? this.state.folder.id : null,
|
selectedFolderId: this.state.folder ? this.state.folder.id : null,
|
||||||
onValueChange: this.folderPickerOptions_valueChanged,
|
onValueChange: this.folderPickerOptions_valueChanged,
|
||||||
};
|
};
|
||||||
|
@ -1244,6 +1251,7 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||||
onSelectionChange={this.body_selectionChange}
|
onSelectionChange={this.body_selectionChange}
|
||||||
onUndoRedoDepthChange={this.onUndoRedoDepthChange}
|
onUndoRedoDepthChange={this.onUndoRedoDepthChange}
|
||||||
onAttach={() => this.showAttachMenu()}
|
onAttach={() => this.showAttachMenu()}
|
||||||
|
readOnly={this.state.readOnly}
|
||||||
style={{
|
style={{
|
||||||
...editorStyle,
|
...editorStyle,
|
||||||
paddingLeft: 0,
|
paddingLeft: 0,
|
||||||
|
@ -1300,6 +1308,7 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||||
keyboardAppearance={theme.keyboardAppearance}
|
keyboardAppearance={theme.keyboardAppearance}
|
||||||
placeholder={_('Add title')}
|
placeholder={_('Add title')}
|
||||||
placeholderTextColor={theme.colorFaded}
|
placeholderTextColor={theme.colorFaded}
|
||||||
|
editable={!this.state.readOnly}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
@ -1326,6 +1335,7 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||||
undoButtonDisabled={!this.state.undoRedoButtonState.canUndo && this.state.undoRedoButtonState.canRedo}
|
undoButtonDisabled={!this.state.undoRedoButtonState.canUndo && this.state.undoRedoButtonState.canRedo}
|
||||||
onUndoButtonPress={this.screenHeader_undoButtonPress}
|
onUndoButtonPress={this.screenHeader_undoButtonPress}
|
||||||
onRedoButtonPress={this.screenHeader_redoButtonPress}
|
onRedoButtonPress={this.screenHeader_redoButtonPress}
|
||||||
|
title={this.state.folder ? this.state.folder.title : ''}
|
||||||
/>
|
/>
|
||||||
{titleComp}
|
{titleComp}
|
||||||
{bodyComponent}
|
{bodyComponent}
|
||||||
|
|
|
@ -150,16 +150,20 @@ class NotesScreenComponent extends BaseScreenComponent<any> {
|
||||||
}
|
}
|
||||||
|
|
||||||
public newNoteNavigate = async (folderId: string, isTodo: boolean) => {
|
public newNoteNavigate = async (folderId: string, isTodo: boolean) => {
|
||||||
const newNote = await Note.save({
|
try {
|
||||||
parent_id: folderId,
|
const newNote = await Note.save({
|
||||||
is_todo: isTodo ? 1 : 0,
|
parent_id: folderId,
|
||||||
}, { provisional: true });
|
is_todo: isTodo ? 1 : 0,
|
||||||
|
}, { provisional: true });
|
||||||
|
|
||||||
this.props.dispatch({
|
this.props.dispatch({
|
||||||
type: 'NAV_GO',
|
type: 'NAV_GO',
|
||||||
routeName: 'Note',
|
routeName: 'Note',
|
||||||
noteId: newNote.id,
|
noteId: newNote.id,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
alert(_('Cannot create a new note: %s', error.message));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
public parentItem(props: any = null) {
|
public parentItem(props: any = null) {
|
||||||
|
|
|
@ -477,7 +477,7 @@ PODS:
|
||||||
- React-Core
|
- React-Core
|
||||||
- RNGestureHandler (2.10.2):
|
- RNGestureHandler (2.10.2):
|
||||||
- React-Core
|
- React-Core
|
||||||
- RNLocalize (3.0.0):
|
- RNLocalize (3.0.1):
|
||||||
- React-Core
|
- React-Core
|
||||||
- RNQuickAction (0.3.13):
|
- RNQuickAction (0.3.13):
|
||||||
- React
|
- React
|
||||||
|
@ -855,7 +855,7 @@ SPEC CHECKSUMS:
|
||||||
RNFileViewer: ce7ca3ac370e18554d35d6355cffd7c30437c592
|
RNFileViewer: ce7ca3ac370e18554d35d6355cffd7c30437c592
|
||||||
RNFS: 4ac0f0ea233904cb798630b3c077808c06931688
|
RNFS: 4ac0f0ea233904cb798630b3c077808c06931688
|
||||||
RNGestureHandler: f75d81410b40aaa99e71ae8f8bb7a88620c95042
|
RNGestureHandler: f75d81410b40aaa99e71ae8f8bb7a88620c95042
|
||||||
RNLocalize: 5944c97d2fe8150913a51ddd5eab4e23a82bd80d
|
RNLocalize: 6dd9226886fa61bf0cefc7644e3f9620770b1a31
|
||||||
RNQuickAction: 6d404a869dc872cde841ad3147416a670d13fa93
|
RNQuickAction: 6d404a869dc872cde841ad3147416a670d13fa93
|
||||||
RNReanimated: 9976fbaaeb8a188c36026154c844bf374b3b7eeb
|
RNReanimated: 9976fbaaeb8a188c36026154c844bf374b3b7eeb
|
||||||
RNSecureRandom: 07efbdf2cd99efe13497433668e54acd7df49fef
|
RNSecureRandom: 07efbdf2cd99efe13497433668e54acd7df49fef
|
||||||
|
|
|
@ -117,6 +117,7 @@ import sensorInfo, { SensorInfo } from './components/biometrics/sensorInfo';
|
||||||
import { getCurrentProfile } from '@joplin/lib/services/profileConfig';
|
import { getCurrentProfile } from '@joplin/lib/services/profileConfig';
|
||||||
import { getDatabaseName, getProfilesRootDir, getResourceDir, setDispatch } from './services/profiles';
|
import { getDatabaseName, getProfilesRootDir, getResourceDir, setDispatch } from './services/profiles';
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
|
import { parseShareCache } from '@joplin/lib/services/share/reducer';
|
||||||
|
|
||||||
type SideMenuPosition = 'left' | 'right';
|
type SideMenuPosition = 'left' | 'right';
|
||||||
|
|
||||||
|
@ -516,6 +517,7 @@ async function initialize(dispatch: Function) {
|
||||||
if (!Setting.value('clientId')) Setting.setValue('clientId', uuid.create());
|
if (!Setting.value('clientId')) Setting.setValue('clientId', uuid.create());
|
||||||
reg.logger().info(`Client ID: ${Setting.value('clientId')}`);
|
reg.logger().info(`Client ID: ${Setting.value('clientId')}`);
|
||||||
|
|
||||||
|
BaseItem.syncShareCache = parseShareCache(Setting.value('sync.shareCache'));
|
||||||
|
|
||||||
if (Setting.value('firstStart')) {
|
if (Setting.value('firstStart')) {
|
||||||
const detectedLocale = shim.detectAndSetLocale(Setting);
|
const detectedLocale = shim.detectAndSetLocale(Setting);
|
||||||
|
|
|
@ -58,6 +58,7 @@ import RSA from './services/e2ee/RSA.node';
|
||||||
import Resource from './models/Resource';
|
import Resource from './models/Resource';
|
||||||
import { ProfileConfig } from './services/profileConfig/types';
|
import { ProfileConfig } from './services/profileConfig/types';
|
||||||
import initProfile from './services/profileConfig/initProfile';
|
import initProfile from './services/profileConfig/initProfile';
|
||||||
|
import { parseShareCache } from './services/share/reducer';
|
||||||
|
|
||||||
import RotatingLogs from './RotatingLogs';
|
import RotatingLogs from './RotatingLogs';
|
||||||
|
|
||||||
|
@ -840,6 +841,8 @@ export default class BaseApplication {
|
||||||
|
|
||||||
appLogger.info(`Client ID: ${Setting.value('clientId')}`);
|
appLogger.info(`Client ID: ${Setting.value('clientId')}`);
|
||||||
|
|
||||||
|
BaseItem.syncShareCache = parseShareCache(Setting.value('sync.shareCache'));
|
||||||
|
|
||||||
if (initArgs?.isSafeMode) {
|
if (initArgs?.isSafeMode) {
|
||||||
Setting.setValue('isSafeMode', true);
|
Setting.setValue('isSafeMode', true);
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@ import Database from './database';
|
||||||
import uuid from './uuid';
|
import uuid from './uuid';
|
||||||
import time from './time';
|
import time from './time';
|
||||||
import JoplinDatabase, { TableField } from './JoplinDatabase';
|
import JoplinDatabase, { TableField } from './JoplinDatabase';
|
||||||
import { LoadOptions } from './models/utils/types';
|
import { LoadOptions, SaveOptions } from './models/utils/types';
|
||||||
const Mutex = require('async-mutex').Mutex;
|
const Mutex = require('async-mutex').Mutex;
|
||||||
|
|
||||||
// New code should make use of this enum
|
// New code should make use of this enum
|
||||||
|
@ -535,7 +535,7 @@ class BaseModel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async save(o: any, options: any = null) {
|
public static async save(o: any, options: SaveOptions = null) {
|
||||||
// When saving, there's a mutex per model ID. This is because the model returned from this function
|
// When saving, there's a mutex per model ID. This is because the model returned from this function
|
||||||
// is basically its input `o` (instead of being read from the database, for performance reasons).
|
// is basically its input `o` (instead of being read from the database, for performance reasons).
|
||||||
// This works well in general except if that model is saved simultaneously in two places. In that
|
// This works well in general except if that model is saved simultaneously in two places. In that
|
||||||
|
@ -546,7 +546,8 @@ class BaseModel {
|
||||||
const mutexRelease = await this.saveMutex(o).acquire();
|
const mutexRelease = await this.saveMutex(o).acquire();
|
||||||
|
|
||||||
options = this.modOptions(options);
|
options = this.modOptions(options);
|
||||||
options.isNew = this.isNew(o, options);
|
const isNew = this.isNew(o, options);
|
||||||
|
options.isNew = isNew;
|
||||||
|
|
||||||
// Diff saving is an optimisation which takes a new version of the item and an old one,
|
// Diff saving is an optimisation which takes a new version of the item and an old one,
|
||||||
// do a diff and save only this diff. IMPORTANT: When using this make sure that both
|
// do a diff and save only this diff. IMPORTANT: When using this make sure that both
|
||||||
|
|
|
@ -26,6 +26,10 @@ import { fetchSyncInfo, getActiveMasterKey, localSyncInfo, mergeSyncInfos, saveL
|
||||||
import { getMasterPassword, setupAndDisableEncryption, setupAndEnableEncryption } from './services/e2ee/utils';
|
import { getMasterPassword, setupAndDisableEncryption, setupAndEnableEncryption } from './services/e2ee/utils';
|
||||||
import { generateKeyPair } from './services/e2ee/ppk';
|
import { generateKeyPair } from './services/e2ee/ppk';
|
||||||
import syncDebugLog from './services/synchronizer/syncDebugLog';
|
import syncDebugLog from './services/synchronizer/syncDebugLog';
|
||||||
|
import handleConflictAction, { ConflictAction } from './services/synchronizer/utils/handleConflictAction';
|
||||||
|
import resourceRemotePath from './services/synchronizer/utils/resourceRemotePath';
|
||||||
|
import syncDeleteStep from './services/synchronizer/utils/syncDeleteStep';
|
||||||
|
import { ErrorCode } from './errors';
|
||||||
const { sprintf } = require('sprintf-js');
|
const { sprintf } = require('sprintf-js');
|
||||||
const { Dirnames } = require('./services/synchronizer/utils/types');
|
const { Dirnames } = require('./services/synchronizer/utils/types');
|
||||||
|
|
||||||
|
@ -404,10 +408,6 @@ export default class Synchronizer {
|
||||||
this.dispatch({ type: 'SYNC_HAS_DISABLED_SYNC_ITEMS' });
|
this.dispatch({ type: 'SYNC_HAS_DISABLED_SYNC_ITEMS' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const resourceRemotePath = (resourceId: string) => {
|
|
||||||
return `${Dirnames.Resources}/${resourceId}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// We index resources before sync mostly to flag any potential orphan
|
// We index resources before sync mostly to flag any potential orphan
|
||||||
// resource before it is being synced. That way, it can potentially be
|
// resource before it is being synced. That way, it can potentially be
|
||||||
// auto-deleted at a later time. Indexing resources is fast so it's fine
|
// auto-deleted at a later time. Indexing resources is fast so it's fine
|
||||||
|
@ -425,8 +425,20 @@ export default class Synchronizer {
|
||||||
|
|
||||||
// Before synchronising make sure all share_id properties are set
|
// Before synchronising make sure all share_id properties are set
|
||||||
// correctly so as to share/unshare the right items.
|
// correctly so as to share/unshare the right items.
|
||||||
await Folder.updateAllShareIds(this.resourceService());
|
try {
|
||||||
if (this.shareService_) await this.shareService_.checkShareConsistency();
|
await Folder.updateAllShareIds(this.resourceService());
|
||||||
|
if (this.shareService_) await this.shareService_.checkShareConsistency();
|
||||||
|
} catch (error) {
|
||||||
|
if (error && error.code === ErrorCode.IsReadOnly) {
|
||||||
|
// We ignore it because the functions above tried to modify a
|
||||||
|
// read-only item and failed. Normally it shouldn't happen since
|
||||||
|
// the UI should prevent, but if there's a bug in the UI or some
|
||||||
|
// other issue we don't want sync to fail because of this.
|
||||||
|
logger.error('Could not update share because an item is readonly:', error);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const itemUploader = new ItemUploader(this.api(), this.apiCall);
|
const itemUploader = new ItemUploader(this.api(), this.apiCall);
|
||||||
|
|
||||||
|
@ -515,22 +527,17 @@ export default class Synchronizer {
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
if (syncSteps.indexOf('delete_remote') >= 0) {
|
if (syncSteps.indexOf('delete_remote') >= 0) {
|
||||||
const deletedItems = await BaseItem.deletedItems(syncTargetId);
|
await syncDeleteStep(
|
||||||
for (let i = 0; i < deletedItems.length; i++) {
|
syncTargetId,
|
||||||
if (this.cancelling()) break;
|
this.cancelling(),
|
||||||
|
(action, local, logSyncOperation, message, actionCount) => {
|
||||||
const item = deletedItems[i];
|
this.logSyncOperation(action, local, logSyncOperation, message, actionCount);
|
||||||
const path = BaseItem.systemPath(item.item_id);
|
},
|
||||||
this.logSyncOperation('deleteRemote', null, { id: item.item_id }, 'local has been deleted');
|
(fnName, ...args) => {
|
||||||
await this.apiCall('delete', path);
|
return this.apiCall(fnName, ...args);
|
||||||
|
},
|
||||||
if (item.item_type === BaseModel.TYPE_RESOURCE) {
|
action => { return this.dispatch(action); }
|
||||||
const remoteContentPath = resourceRemotePath(item.item_id);
|
);
|
||||||
await this.apiCall('delete', remoteContentPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
await BaseItem.remoteDeletedItem(syncTargetId, item.item_id);
|
|
||||||
}
|
|
||||||
} // DELETE_REMOTE STEP
|
} // DELETE_REMOTE STEP
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
@ -573,7 +580,7 @@ export default class Synchronizer {
|
||||||
|
|
||||||
const remote: RemoteItem = result.neverSyncedItemIds.includes(local.id) ? null : await this.apiCall('stat', path);
|
const remote: RemoteItem = result.neverSyncedItemIds.includes(local.id) ? null : await this.apiCall('stat', path);
|
||||||
let action = null;
|
let action = null;
|
||||||
|
let itemIsReadOnly = false;
|
||||||
let reason = '';
|
let reason = '';
|
||||||
let remoteContent = null;
|
let remoteContent = null;
|
||||||
|
|
||||||
|
@ -698,6 +705,10 @@ export default class Synchronizer {
|
||||||
if (isCannotSyncError(error)) {
|
if (isCannotSyncError(error)) {
|
||||||
await handleCannotSyncItem(ItemClass, syncTargetId, local, error.message);
|
await handleCannotSyncItem(ItemClass, syncTargetId, local, error.message);
|
||||||
action = null;
|
action = null;
|
||||||
|
} else if (error && error.code === ErrorCode.IsReadOnly) {
|
||||||
|
action = getConflictType(local);
|
||||||
|
itemIsReadOnly = true;
|
||||||
|
logger.info('Resource is readonly and cannot be modified - handling it as a conflict:', local);
|
||||||
} else {
|
} else {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
@ -709,11 +720,16 @@ export default class Synchronizer {
|
||||||
let canSync = true;
|
let canSync = true;
|
||||||
try {
|
try {
|
||||||
if (this.testingHooks_.indexOf('notesRejectedByTarget') >= 0 && local.type_ === BaseModel.TYPE_NOTE) throw new JoplinError('Testing rejectedByTarget', 'rejectedByTarget');
|
if (this.testingHooks_.indexOf('notesRejectedByTarget') >= 0 && local.type_ === BaseModel.TYPE_NOTE) throw new JoplinError('Testing rejectedByTarget', 'rejectedByTarget');
|
||||||
|
if (this.testingHooks_.indexOf('itemIsReadOnly') >= 0) throw new JoplinError('Testing isReadOnly', ErrorCode.IsReadOnly);
|
||||||
await itemUploader.serializeAndUploadItem(ItemClass, path, local);
|
await itemUploader.serializeAndUploadItem(ItemClass, path, local);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error && error.code === 'rejectedByTarget') {
|
if (error && error.code === 'rejectedByTarget') {
|
||||||
await handleCannotSyncItem(ItemClass, syncTargetId, local, error.message);
|
await handleCannotSyncItem(ItemClass, syncTargetId, local, error.message);
|
||||||
canSync = false;
|
canSync = false;
|
||||||
|
} else if (error && error.code === ErrorCode.IsReadOnly) {
|
||||||
|
action = getConflictType(local);
|
||||||
|
itemIsReadOnly = true;
|
||||||
|
canSync = false;
|
||||||
} else {
|
} else {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
@ -741,77 +757,18 @@ export default class Synchronizer {
|
||||||
|
|
||||||
await ItemClass.saveSyncTime(syncTargetId, local, local.updated_time);
|
await ItemClass.saveSyncTime(syncTargetId, local, local.updated_time);
|
||||||
}
|
}
|
||||||
} else if (action === 'itemConflict') {
|
|
||||||
// ------------------------------------------------------------------------------
|
|
||||||
// For non-note conflicts, we take the remote version (i.e. the version that was
|
|
||||||
// synced first) and overwrite the local content.
|
|
||||||
// ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
if (remote) {
|
|
||||||
local = remoteContent;
|
|
||||||
|
|
||||||
const syncTimeQueries = BaseItem.updateSyncTimeQueries(syncTargetId, local, time.unixMs());
|
|
||||||
await ItemClass.save(local, { autoTimestamp: false, changeSource: ItemChange.SOURCE_SYNC, nextQueries: syncTimeQueries });
|
|
||||||
} else {
|
|
||||||
await ItemClass.delete(local.id, {
|
|
||||||
changeSource: ItemChange.SOURCE_SYNC,
|
|
||||||
trackDeleted: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (action === 'noteConflict') {
|
|
||||||
// ------------------------------------------------------------------------------
|
|
||||||
// First find out if the conflict matters. For example, if the conflict is on the title or body
|
|
||||||
// we want to preserve all the changes. If it's on todo_completed it doesn't really matter
|
|
||||||
// so in this case we just take the remote content.
|
|
||||||
// ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
let mustHandleConflict = true;
|
|
||||||
if (remoteContent) {
|
|
||||||
mustHandleConflict = Note.mustHandleConflict(local, remoteContent);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------------------
|
|
||||||
// Create a duplicate of local note into Conflicts folder
|
|
||||||
// (to preserve the user's changes)
|
|
||||||
// ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
if (mustHandleConflict) {
|
|
||||||
await Note.createConflictNote(local, ItemChange.SOURCE_SYNC);
|
|
||||||
}
|
|
||||||
} else if (action === 'resourceConflict') {
|
|
||||||
// ------------------------------------------------------------------------------
|
|
||||||
// Unlike notes we always handle the conflict for resources
|
|
||||||
// ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
await Resource.createConflictResourceNote(local);
|
|
||||||
|
|
||||||
if (remote) {
|
|
||||||
// The local content we have is no longer valid and should be re-downloaded
|
|
||||||
await Resource.setLocalState(local.id, {
|
|
||||||
fetch_status: Resource.FETCH_STATUS_IDLE,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (['noteConflict', 'resourceConflict'].includes(action)) {
|
await handleConflictAction(
|
||||||
// ------------------------------------------------------------------------------
|
action as ConflictAction,
|
||||||
// For note and resource conflicts, the creation of the conflict item is done
|
ItemClass,
|
||||||
// differently. However the way the local content is handled is the same.
|
!!remote,
|
||||||
// Either copy the remote content to local or, if the remote content has
|
remoteContent,
|
||||||
// been deleted, delete the local content.
|
local,
|
||||||
// ------------------------------------------------------------------------------
|
syncTargetId,
|
||||||
|
itemIsReadOnly,
|
||||||
if (remote) {
|
(action: any) => this.dispatch(action)
|
||||||
local = remoteContent;
|
);
|
||||||
const syncTimeQueries = BaseItem.updateSyncTimeQueries(syncTargetId, local, time.unixMs());
|
|
||||||
await ItemClass.save(local, { autoTimestamp: false, changeSource: ItemChange.SOURCE_SYNC, nextQueries: syncTimeQueries });
|
|
||||||
|
|
||||||
if (local.encryption_applied) this.dispatch({ type: 'SYNC_GOT_ENCRYPTED_ITEM' });
|
|
||||||
} else {
|
|
||||||
// Remote no longer exists (note deleted) so delete local one too
|
|
||||||
await ItemClass.delete(local.id, { changeSource: ItemChange.SOURCE_SYNC, trackDeleted: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
completeItemProcessing(path);
|
completeItemProcessing(path);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,16 @@
|
||||||
import { NoteEntity } from '../../services/database/types';
|
import { NoteEntity } from '../../services/database/types';
|
||||||
import { reg } from '../../registry';
|
import { reg } from '../../registry';
|
||||||
import Folder from '../../models/Folder';
|
import Folder from '../../models/Folder';
|
||||||
import BaseModel from '../../BaseModel';
|
import BaseModel, { ModelType } from '../../BaseModel';
|
||||||
import Note from '../../models/Note';
|
import Note from '../../models/Note';
|
||||||
import Resource from '../../models/Resource';
|
import Resource from '../../models/Resource';
|
||||||
import ResourceFetcher from '../../services/ResourceFetcher';
|
import ResourceFetcher from '../../services/ResourceFetcher';
|
||||||
import DecryptionWorker from '../../services/DecryptionWorker';
|
import DecryptionWorker from '../../services/DecryptionWorker';
|
||||||
import Setting from '../../models/Setting';
|
import Setting from '../../models/Setting';
|
||||||
import { Mutex } from 'async-mutex';
|
import { Mutex } from 'async-mutex';
|
||||||
|
import { itemIsReadOnlySync, ItemSlice } from '../../models/utils/readOnly';
|
||||||
|
import ItemChange from '../../models/ItemChange';
|
||||||
|
import BaseItem from '../../models/BaseItem';
|
||||||
|
|
||||||
interface Shared {
|
interface Shared {
|
||||||
noteExists?: (noteId: string)=> Promise<boolean>;
|
noteExists?: (noteId: string)=> Promise<boolean>;
|
||||||
|
@ -219,6 +222,7 @@ shared.initState = async function(comp: any) {
|
||||||
const isProvisionalNote = comp.props.provisionalNoteIds.includes(comp.props.noteId);
|
const isProvisionalNote = comp.props.provisionalNoteIds.includes(comp.props.noteId);
|
||||||
|
|
||||||
const note = await Note.load(comp.props.noteId);
|
const note = await Note.load(comp.props.noteId);
|
||||||
|
|
||||||
let mode = 'view';
|
let mode = 'view';
|
||||||
|
|
||||||
if (isProvisionalNote && !comp.props.sharedData) {
|
if (isProvisionalNote && !comp.props.sharedData) {
|
||||||
|
@ -236,6 +240,7 @@ shared.initState = async function(comp: any) {
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
fromShare: !!comp.props.sharedData,
|
fromShare: !!comp.props.sharedData,
|
||||||
noteResources: await shared.attachedResources(note ? note.body : ''),
|
noteResources: await shared.attachedResources(note ? note.body : ''),
|
||||||
|
readOnly: itemIsReadOnlySync(ModelType.Note, ItemChange.SOURCE_UNSPECIFIED, note as ItemSlice, Setting.value('sync.userId'), BaseItem.syncShareCache),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,13 +1,14 @@
|
||||||
const Setting = require('../../models/Setting').default;
|
import Setting from '../../models/Setting';
|
||||||
const Tag = require('../../models/Tag').default;
|
import Tag from '../../models/Tag';
|
||||||
const BaseModel = require('../../BaseModel').default;
|
import BaseModel from '../../BaseModel';
|
||||||
const Note = require('../../models/Note').default;
|
import Note from '../../models/Note';
|
||||||
const { reg } = require('../../registry.js');
|
import { reg } from '../../registry.js';
|
||||||
const ResourceFetcher = require('../../services/ResourceFetcher').default;
|
import ResourceFetcher from '../../services/ResourceFetcher';
|
||||||
const DecryptionWorker = require('../../services/DecryptionWorker').default;
|
import DecryptionWorker from '../../services/DecryptionWorker';
|
||||||
const eventManager = require('../../eventManager').default;
|
import eventManager from '../../eventManager';
|
||||||
|
import BaseItem from '../../models/BaseItem';
|
||||||
|
|
||||||
const reduxSharedMiddleware = async function(store, next, action) {
|
const reduxSharedMiddleware = async function(store: any, _next: any, action: any) {
|
||||||
const newState = store.getState();
|
const newState = store.getState();
|
||||||
|
|
||||||
eventManager.appStateEmit(newState);
|
eventManager.appStateEmit(newState);
|
||||||
|
@ -40,7 +41,7 @@ const reduxSharedMiddleware = async function(store, next, action) {
|
||||||
// automatically after each full sync (which is triggered when the user presses the sync
|
// automatically after each full sync (which is triggered when the user presses the sync
|
||||||
// button, but not when a note is saved).
|
// button, but not when a note is saved).
|
||||||
if (action.type === 'SYNC_COMPLETED' && action.isFullSync) {
|
if (action.type === 'SYNC_COMPLETED' && action.isFullSync) {
|
||||||
DecryptionWorker.instance().scheduleStart();
|
void DecryptionWorker.instance().scheduleStart();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action.type === 'NOTE_DELETE' ||
|
if (action.type === 'NOTE_DELETE' ||
|
||||||
|
@ -87,7 +88,7 @@ const reduxSharedMiddleware = async function(store, next, action) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mustAutoAddResources) {
|
if (mustAutoAddResources) {
|
||||||
ResourceFetcher.instance().autoAddResources();
|
void ResourceFetcher.instance().autoAddResources();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (refreshTags) {
|
if (refreshTags) {
|
||||||
|
@ -97,6 +98,12 @@ const reduxSharedMiddleware = async function(store, next, action) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action.type.startsWith('SHARE_')) {
|
||||||
|
const serialized = JSON.stringify(newState.shareService);
|
||||||
|
Setting.setValue('sync.shareCache', serialized);
|
||||||
|
BaseItem.syncShareCache = JSON.parse(serialized);
|
||||||
|
}
|
||||||
|
|
||||||
// For debugging purposes: it seems in some case an empty note is saved to
|
// For debugging purposes: it seems in some case an empty note is saved to
|
||||||
// the note array, so in that case display a log statements so that it can
|
// the note array, so in that case display a log statements so that it can
|
||||||
// be debugged.
|
// be debugged.
|
|
@ -0,0 +1,6 @@
|
||||||
|
/* eslint-disable import/prefer-default-export */
|
||||||
|
|
||||||
|
export enum ErrorCode {
|
||||||
|
IsReadOnly = 'isReadOnly',
|
||||||
|
NotFound = 'notFound',
|
||||||
|
}
|
|
@ -179,6 +179,10 @@ export default class FileApiDriverJoplinServer {
|
||||||
return error.code === 413 || error.code === 409 || error.httpCode === 413 || error.httpCode === 409;
|
return error.code === 413 || error.code === 409 || error.httpCode === 413 || error.httpCode === 409;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isReadyOnlyError(error: any) {
|
||||||
|
return error && error.code === 'isReadOnly';
|
||||||
|
}
|
||||||
|
|
||||||
public async put(path: string, content: any, options: any = null) {
|
public async put(path: string, content: any, options: any = null) {
|
||||||
try {
|
try {
|
||||||
const output = await this.api().exec('PUT', `${this.apiFilePath_(path)}/content`, options && options.shareId ? { share_id: options.shareId } : null, content, {
|
const output = await this.api().exec('PUT', `${this.apiFilePath_(path)}/content`, options && options.shareId ? { share_id: options.shareId } : null, content, {
|
||||||
|
@ -189,6 +193,11 @@ export default class FileApiDriverJoplinServer {
|
||||||
if (this.isRejectedBySyncTargetError(error)) {
|
if (this.isRejectedBySyncTargetError(error)) {
|
||||||
throw new JoplinError(error.message, 'rejectedByTarget');
|
throw new JoplinError(error.message, 'rejectedByTarget');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.isReadyOnlyError(error)) {
|
||||||
|
throw new JoplinError(error.message, 'isReadOnly');
|
||||||
|
}
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -199,6 +208,8 @@ export default class FileApiDriverJoplinServer {
|
||||||
for (const [, response] of Object.entries<any>(output.items)) {
|
for (const [, response] of Object.entries<any>(output.items)) {
|
||||||
if (response.error && this.isRejectedBySyncTargetError(response.error)) {
|
if (response.error && this.isRejectedBySyncTargetError(response.error)) {
|
||||||
response.error.code = 'rejectedByTarget';
|
response.error.code = 'rejectedByTarget';
|
||||||
|
} else if (response.error && this.isReadyOnlyError(response.error)) {
|
||||||
|
response.error.code = 'isReadOnly';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,7 @@ const { basicDelta } = require('./file-api');
|
||||||
const { dirname, basename } = require('./path-utils');
|
const { dirname, basename } = require('./path-utils');
|
||||||
const shim = require('./shim').default;
|
const shim = require('./shim').default;
|
||||||
const Buffer = require('buffer').Buffer;
|
const Buffer = require('buffer').Buffer;
|
||||||
|
const { ltrimSlashes } = require('./path-utils');
|
||||||
|
|
||||||
class FileApiDriverOneDrive {
|
class FileApiDriverOneDrive {
|
||||||
constructor(api) {
|
constructor(api) {
|
||||||
|
@ -196,11 +197,12 @@ class FileApiDriverOneDrive {
|
||||||
|
|
||||||
async clearRoot() {
|
async clearRoot() {
|
||||||
const recurseItems = async (path) => {
|
const recurseItems = async (path) => {
|
||||||
|
path = ltrimSlashes(path);
|
||||||
const result = await this.list(this.fileApi_.fullPath(path));
|
const result = await this.list(this.fileApi_.fullPath(path));
|
||||||
const output = [];
|
const output = [];
|
||||||
|
|
||||||
for (const item of result.items) {
|
for (const item of result.items) {
|
||||||
const fullPath = `${path}/${item.path}`;
|
const fullPath = ltrimSlashes(`${path}/${item.path}`);
|
||||||
if (item.isDir) {
|
if (item.isDir) {
|
||||||
await recurseItems(fullPath);
|
await recurseItems(fullPath);
|
||||||
}
|
}
|
||||||
|
|
|
@ -49,7 +49,7 @@ function requestCanBeRepeated(error: any) {
|
||||||
if (errorCode === 403) return false;
|
if (errorCode === 403) return false;
|
||||||
|
|
||||||
// The target is explicitely rejecting the item so repeating wouldn't make a difference.
|
// The target is explicitely rejecting the item so repeating wouldn't make a difference.
|
||||||
if (errorCode === 'rejectedByTarget') return false;
|
if (errorCode === 'rejectedByTarget' || errorCode === 'isReadOnly') return false;
|
||||||
|
|
||||||
// We don't repeat failSafe errors because it's an indication of an issue at the
|
// We don't repeat failSafe errors because it's an indication of an issue at the
|
||||||
// server-level issue which usually cannot be fixed by repeating the request.
|
// server-level issue which usually cannot be fixed by repeating the request.
|
||||||
|
|
|
@ -29,6 +29,10 @@ export default class FsDriverBase {
|
||||||
throw new Error('Not implemented');
|
throw new Error('Not implemented');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async chmod(_source: string, _mode: string | number) {
|
||||||
|
throw new Error('Not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
public async mkdir(_path: string) {
|
public async mkdir(_path: string) {
|
||||||
throw new Error('Not implemented');
|
throw new Error('Not implemented');
|
||||||
}
|
}
|
||||||
|
|
|
@ -162,6 +162,10 @@ export default class FsDriverNode extends FsDriverBase {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async chmod(source: string, mode: string | number) {
|
||||||
|
return fs.chmod(source, mode);
|
||||||
|
}
|
||||||
|
|
||||||
public async unlink(path: string) {
|
public async unlink(path: string) {
|
||||||
try {
|
try {
|
||||||
await fs.unlink(path);
|
await fs.unlink(path);
|
||||||
|
|
|
@ -11,7 +11,10 @@ import ShareService from '../services/share/ShareService';
|
||||||
import itemCanBeEncrypted from './utils/itemCanBeEncrypted';
|
import itemCanBeEncrypted from './utils/itemCanBeEncrypted';
|
||||||
import { getEncryptionEnabled } from '../services/synchronizer/syncInfoUtils';
|
import { getEncryptionEnabled } from '../services/synchronizer/syncInfoUtils';
|
||||||
import JoplinError from '../JoplinError';
|
import JoplinError from '../JoplinError';
|
||||||
import { LoadOptions } from './utils/types';
|
import { LoadOptions, SaveOptions } from './utils/types';
|
||||||
|
import { State as ShareState } from '../services/share/reducer';
|
||||||
|
import { checkIfItemCanBeAddedToFolder, checkIfItemCanBeChanged, checkIfItemsCanBeChanged, needsReadOnlyChecks } from './utils/readOnly';
|
||||||
|
|
||||||
const { sprintf } = require('sprintf-js');
|
const { sprintf } = require('sprintf-js');
|
||||||
const moment = require('moment');
|
const moment = require('moment');
|
||||||
|
|
||||||
|
@ -45,6 +48,7 @@ export default class BaseItem extends BaseModel {
|
||||||
public static encryptionService_: any = null;
|
public static encryptionService_: any = null;
|
||||||
public static revisionService_: any = null;
|
public static revisionService_: any = null;
|
||||||
public static shareService_: ShareService = null;
|
public static shareService_: ShareService = null;
|
||||||
|
private static syncShareCache_: ShareState | null = null;
|
||||||
|
|
||||||
// Also update:
|
// Also update:
|
||||||
// - itemsThatNeedSync()
|
// - itemsThatNeedSync()
|
||||||
|
@ -83,6 +87,14 @@ export default class BaseItem extends BaseModel {
|
||||||
throw new Error(`Invalid class name: ${className}`);
|
throw new Error(`Invalid class name: ${className}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static get syncShareCache(): ShareState {
|
||||||
|
return this.syncShareCache_;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static set syncShareCache(v: ShareState) {
|
||||||
|
this.syncShareCache_ = v;
|
||||||
|
}
|
||||||
|
|
||||||
public static async findUniqueItemTitle(title: string, parentId: string = null) {
|
public static async findUniqueItemTitle(title: string, parentId: string = null) {
|
||||||
let counter = 1;
|
let counter = 1;
|
||||||
let titleToTry = title;
|
let titleToTry = title;
|
||||||
|
@ -200,10 +212,10 @@ export default class BaseItem extends BaseModel {
|
||||||
return this.loadItemById(this.pathToId(path));
|
return this.loadItemById(this.pathToId(path));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async loadItemById(id: string) {
|
public static async loadItemById(id: string, options: LoadOptions = null) {
|
||||||
const classes = this.syncItemClassNames();
|
const classes = this.syncItemClassNames();
|
||||||
for (let i = 0; i < classes.length; i++) {
|
for (let i = 0; i < classes.length; i++) {
|
||||||
const item = await this.getClass(classes[i]).load(id);
|
const item = await this.getClass(classes[i]).load(id, options);
|
||||||
if (item) return item;
|
if (item) return item;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
@ -223,6 +235,21 @@ export default class BaseItem extends BaseModel {
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async loadItemsByTypeAndIds(itemType: ModelType, ids: string[], options: LoadOptions = null): Promise<any[]> {
|
||||||
|
if (!ids.length) return [];
|
||||||
|
|
||||||
|
const fields = options && options.fields ? options.fields : [];
|
||||||
|
const ItemClass = this.getClassByItemType(itemType);
|
||||||
|
const fieldsSql = fields.length ? this.db().escapeFields(fields) : '*';
|
||||||
|
const sql = `SELECT ${fieldsSql} FROM ${ItemClass.tableName()} WHERE id IN ("${ids.join('","')}")`;
|
||||||
|
return ItemClass.modelSelectAll(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async loadItemByTypeAndId(itemType: ModelType, id: string, options: LoadOptions = null) {
|
||||||
|
const result = await this.loadItemsByTypeAndIds(itemType, [id], options);
|
||||||
|
return result.length ? result[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
public static loadItemByField(itemType: number, field: string, value: any) {
|
public static loadItemByField(itemType: number, field: string, value: any) {
|
||||||
const ItemClass = this.itemClass(itemType);
|
const ItemClass = this.itemClass(itemType);
|
||||||
return ItemClass.loadByField(field, value);
|
return ItemClass.loadByField(field, value);
|
||||||
|
@ -258,6 +285,11 @@ export default class BaseItem extends BaseModel {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (needsReadOnlyChecks(this.modelType(), options.changeSource, this.syncShareCache)) {
|
||||||
|
const previousItems = await this.loadItemsByTypeAndIds(this.modelType(), ids, { fields: ['share_id', 'id'] });
|
||||||
|
checkIfItemsCanBeChanged(this.modelType(), options.changeSource, previousItems, this.syncShareCache);
|
||||||
|
}
|
||||||
|
|
||||||
await super.batchDelete(ids, options);
|
await super.batchDelete(ids, options);
|
||||||
|
|
||||||
if (trackDeleted) {
|
if (trackDeleted) {
|
||||||
|
@ -863,13 +895,34 @@ export default class BaseItem extends BaseModel {
|
||||||
await this.db().exec('UPDATE sync_items SET force_sync = 1');
|
await this.db().exec('UPDATE sync_items SET force_sync = 1');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async save(o: any, options: any = null) {
|
public static async save(o: any, options: SaveOptions = null) {
|
||||||
if (!options) options = {};
|
if (!options) options = {};
|
||||||
|
|
||||||
if (options.userSideValidation === true) {
|
if (options.userSideValidation === true) {
|
||||||
if (o.encryption_applied) throw new Error(_('Encrypted items cannot be modified'));
|
if (o.encryption_applied) throw new Error(_('Encrypted items cannot be modified'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isNew = this.isNew(o, options);
|
||||||
|
|
||||||
|
if (needsReadOnlyChecks(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the item has a parent folder (a note or a sub-folder), check
|
||||||
|
// that we're not adding the item to a read-only folder.
|
||||||
|
if (o.parent_id) {
|
||||||
|
await checkIfItemCanBeAddedToFolder(
|
||||||
|
this.modelType(),
|
||||||
|
this.getClass('Folder'),
|
||||||
|
options.changeSource,
|
||||||
|
BaseItem.syncShareCache,
|
||||||
|
o.parent_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return super.save(o, options);
|
return super.save(o, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
|
import { ErrorCode } from '../errors';
|
||||||
import { FolderEntity } from '../services/database/types';
|
import { FolderEntity } from '../services/database/types';
|
||||||
import { createNTestNotes, setupDatabaseAndSynchronizer, sleep, switchClient, checkThrowAsync, createFolderTree } from '../testing/test-utils';
|
import { createNTestNotes, setupDatabaseAndSynchronizer, sleep, switchClient, checkThrowAsync, createFolderTree, simulateReadOnlyShareEnv, expectThrow } from '../testing/test-utils';
|
||||||
import Folder from './Folder';
|
import Folder from './Folder';
|
||||||
import Note from './Note';
|
import Note from './Note';
|
||||||
|
|
||||||
|
@ -16,7 +17,7 @@ describe('models/Folder', () => {
|
||||||
await switchClient(1);
|
await switchClient(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should tell if a notebook can be nested under another one', (async () => {
|
it('should tell if a folder can be nested under another one', (async () => {
|
||||||
const f1 = await Folder.save({ title: 'folder1' });
|
const f1 = await Folder.save({ title: 'folder1' });
|
||||||
const f2 = await Folder.save({ title: 'folder2', parent_id: f1.id });
|
const f2 = await Folder.save({ title: 'folder2', parent_id: f1.id });
|
||||||
const f3 = await Folder.save({ title: 'folder3', parent_id: f2.id });
|
const f3 = await Folder.save({ title: 'folder3', parent_id: f2.id });
|
||||||
|
@ -32,7 +33,7 @@ describe('models/Folder', () => {
|
||||||
expect(await Folder.canNestUnder(f2.id, '')).toBe(true);
|
expect(await Folder.canNestUnder(f2.id, '')).toBe(true);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
it('should recursively delete notes and sub-notebooks', (async () => {
|
it('should recursively delete notes and sub-folders', (async () => {
|
||||||
const f1 = await Folder.save({ title: 'folder1' });
|
const f1 = await Folder.save({ title: 'folder1' });
|
||||||
const f2 = await Folder.save({ title: 'folder2', parent_id: f1.id });
|
const f2 = await Folder.save({ title: 'folder2', parent_id: f1.id });
|
||||||
const f3 = await Folder.save({ title: 'folder3', parent_id: f2.id });
|
const f3 = await Folder.save({ title: 'folder3', parent_id: f2.id });
|
||||||
|
@ -222,7 +223,7 @@ describe('models/Folder', () => {
|
||||||
expect(sortedFolderTree[2].id).toBe(f6.id);
|
expect(sortedFolderTree[2].id).toBe(f6.id);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
it('should not allow setting a notebook parent as itself', (async () => {
|
it('should not allow setting a folder parent as itself', (async () => {
|
||||||
const f1 = await Folder.save({ title: 'folder1' });
|
const f1 = await Folder.save({ title: 'folder1' });
|
||||||
const hasThrown = await checkThrowAsync(() => Folder.save({ id: f1.id, parent_id: f1.id }, { userSideValidation: true }));
|
const hasThrown = await checkThrowAsync(() => Folder.save({ id: f1.id, parent_id: f1.id }, { userSideValidation: true }));
|
||||||
expect(hasThrown).toBe(true);
|
expect(hasThrown).toBe(true);
|
||||||
|
@ -284,4 +285,42 @@ describe('models/Folder', () => {
|
||||||
expect(children.map(c => c.id).sort()).toEqual([].sort());
|
expect(children.map(c => c.id).sort()).toEqual([].sort());
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
it('should not allow creating a new folder as a child of a read-only folder', async () => {
|
||||||
|
const cleanup = simulateReadOnlyShareEnv('123456789');
|
||||||
|
|
||||||
|
const readonlyFolder = await Folder.save({ share_id: '123456789' });
|
||||||
|
await expectThrow(async () => Folder.save({ parent_id: readonlyFolder.id }), ErrorCode.IsReadOnly);
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not allow moving a folder as a child of a read-only folder', async () => {
|
||||||
|
const cleanup = simulateReadOnlyShareEnv('123456789');
|
||||||
|
|
||||||
|
const readonlyFolder = await Folder.save({ share_id: '123456789' });
|
||||||
|
const folder = await Folder.save({});
|
||||||
|
await expectThrow(async () => Folder.save({ id: folder.id, parent_id: readonlyFolder.id }), ErrorCode.IsReadOnly);
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not allow modifying a read-only folder', async () => {
|
||||||
|
const cleanup = simulateReadOnlyShareEnv('123456789');
|
||||||
|
|
||||||
|
const readonlyFolder = await Folder.save({ share_id: '123456789' });
|
||||||
|
await expectThrow(async () => Folder.save({ id: readonlyFolder.id, title: 'cannot do that' }), ErrorCode.IsReadOnly);
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not allow deleting a read-only folder', async () => {
|
||||||
|
const cleanup = simulateReadOnlyShareEnv('123456789');
|
||||||
|
|
||||||
|
const readonlyFolder = await Folder.save({ share_id: '123456789' });
|
||||||
|
await expectThrow(async () => Folder.delete(readonlyFolder.id), ErrorCode.IsReadOnly);
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { isRootSharedFolder } from '../services/share/reducer';
|
||||||
import Logger from '../Logger';
|
import Logger from '../Logger';
|
||||||
import syncDebugLog from '../services/synchronizer/syncDebugLog';
|
import syncDebugLog from '../services/synchronizer/syncDebugLog';
|
||||||
import ResourceService from '../services/ResourceService';
|
import ResourceService from '../services/ResourceService';
|
||||||
|
import { LoadOptions } from './utils/types';
|
||||||
const { substrWithEllipsis } = require('../string-utils.js');
|
const { substrWithEllipsis } = require('../string-utils.js');
|
||||||
|
|
||||||
const logger = Logger.create('models/Folder');
|
const logger = Logger.create('models/Folder');
|
||||||
|
@ -122,6 +123,8 @@ export default class Folder extends BaseItem {
|
||||||
title: this.conflictFolderTitle(),
|
title: this.conflictFolderTitle(),
|
||||||
updated_time: time.unixMs(),
|
updated_time: time.unixMs(),
|
||||||
user_updated_time: time.unixMs(),
|
user_updated_time: time.unixMs(),
|
||||||
|
share_id: '',
|
||||||
|
is_shared: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -671,9 +674,9 @@ export default class Folder extends BaseItem {
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static load(id: string, _options: any = null): Promise<FolderEntity> {
|
public static load(id: string, options: LoadOptions = null): Promise<FolderEntity> {
|
||||||
if (id === this.conflictFolderId()) return Promise.resolve(this.conflictFolder());
|
if (id === this.conflictFolderId()) return Promise.resolve(this.conflictFolder());
|
||||||
return super.load(id);
|
return super.load(id, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static defaultFolder() {
|
public static defaultFolder() {
|
||||||
|
@ -759,14 +762,14 @@ export default class Folder extends BaseItem {
|
||||||
|
|
||||||
syncDebugLog.info('Folder Save:', o);
|
syncDebugLog.info('Folder Save:', o);
|
||||||
|
|
||||||
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
|
const savedFolder: FolderEntity = await super.save(o, options);
|
||||||
return super.save(o, options).then((folder: FolderEntity) => {
|
|
||||||
this.dispatch({
|
this.dispatch({
|
||||||
type: 'FOLDER_UPDATE_ONE',
|
type: 'FOLDER_UPDATE_ONE',
|
||||||
item: folder,
|
item: savedFolder,
|
||||||
});
|
|
||||||
return folder;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return savedFolder;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static serializeIcon(icon: FolderIcon): string {
|
public static serializeIcon(icon: FolderIcon): string {
|
||||||
|
|
|
@ -2,7 +2,7 @@ import Setting from './Setting';
|
||||||
import BaseModel from '../BaseModel';
|
import BaseModel from '../BaseModel';
|
||||||
import shim from '../shim';
|
import shim from '../shim';
|
||||||
import markdownUtils from '../markdownUtils';
|
import markdownUtils from '../markdownUtils';
|
||||||
import { sortedIds, createNTestNotes, setupDatabaseAndSynchronizer, switchClient, checkThrowAsync, supportDir } from '../testing/test-utils';
|
import { sortedIds, createNTestNotes, expectThrow, setupDatabaseAndSynchronizer, switchClient, checkThrowAsync, supportDir, expectNotThrow, simulateReadOnlyShareEnv } from '../testing/test-utils';
|
||||||
import Folder from './Folder';
|
import Folder from './Folder';
|
||||||
import Note from './Note';
|
import Note from './Note';
|
||||||
import Tag from './Tag';
|
import Tag from './Tag';
|
||||||
|
@ -11,6 +11,7 @@ import Resource from './Resource';
|
||||||
import { ResourceEntity } from '../services/database/types';
|
import { ResourceEntity } from '../services/database/types';
|
||||||
import { toForwardSlashes } from '../path-utils';
|
import { toForwardSlashes } from '../path-utils';
|
||||||
import * as ArrayUtils from '../ArrayUtils';
|
import * as ArrayUtils from '../ArrayUtils';
|
||||||
|
import { ErrorCode } from '../errors';
|
||||||
|
|
||||||
async function allItems() {
|
async function allItems() {
|
||||||
const folders = await Folder.all();
|
const folders = await Folder.all();
|
||||||
|
@ -164,6 +165,8 @@ describe('models/Note', () => {
|
||||||
|
|
||||||
expect(note.body).toContain(resource.id);
|
expect(note.body).toContain(resource.id);
|
||||||
expect(duplicatedNote.body).toContain(duplicatedResource.id);
|
expect(duplicatedNote.body).toContain(duplicatedResource.id);
|
||||||
|
expect(duplicatedResource.share_id).toBe('');
|
||||||
|
expect(duplicatedResource.is_shared).toBe(0);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
it('should delete a set of notes', (async () => {
|
it('should delete a set of notes', (async () => {
|
||||||
|
@ -371,12 +374,14 @@ describe('models/Note', () => {
|
||||||
|
|
||||||
it('should create a conflict note', async () => {
|
it('should create a conflict note', async () => {
|
||||||
const folder = await Folder.save({ title: 'Source Folder' });
|
const folder = await Folder.save({ title: 'Source Folder' });
|
||||||
const origNote = await Note.save({ title: 'note', parent_id: folder.id });
|
const origNote = await Note.save({ title: 'note', parent_id: folder.id, share_id: 'SHARE', is_shared: 1 });
|
||||||
const conflictedNote = await Note.createConflictNote(origNote, ItemChange.SOURCE_SYNC);
|
const conflictedNote = await Note.createConflictNote(origNote, ItemChange.SOURCE_SYNC);
|
||||||
|
|
||||||
expect(conflictedNote.is_conflict).toBe(1);
|
expect(conflictedNote.is_conflict).toBe(1);
|
||||||
expect(conflictedNote.conflict_original_id).toBe(origNote.id);
|
expect(conflictedNote.conflict_original_id).toBe(origNote.id);
|
||||||
expect(conflictedNote.parent_id).toBe(folder.id);
|
expect(conflictedNote.parent_id).toBe(folder.id);
|
||||||
|
expect(conflictedNote.is_shared).toBeUndefined();
|
||||||
|
expect(conflictedNote.share_id).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should copy conflicted note to target folder and cancel conflict', (async () => {
|
it('should copy conflicted note to target folder and cancel conflict', (async () => {
|
||||||
|
@ -441,4 +446,55 @@ describe('models/Note', () => {
|
||||||
testResourceReplacment(body, pathsToTry, expected);
|
testResourceReplacment(body, pathsToTry, expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not allow modifying a read-only note', async () => {
|
||||||
|
const folder = await Folder.save({ });
|
||||||
|
const note = await Note.save({ parent_id: folder.id });
|
||||||
|
|
||||||
|
const cleanup = simulateReadOnlyShareEnv('123456789');
|
||||||
|
|
||||||
|
await expectNotThrow(async () => await Note.save({ id: note.id, title: 'can do this' }));
|
||||||
|
|
||||||
|
await Folder.save({ id: folder.id, share_id: '123456789' });
|
||||||
|
await Note.save({ id: note.id, share_id: '123456789' });
|
||||||
|
|
||||||
|
await expectThrow(async () => await Note.save({ id: note.id, title: 'cannot do this!' }), ErrorCode.IsReadOnly);
|
||||||
|
|
||||||
|
await expectNotThrow(async () => await Note.save({ id: note.id, title: 'But it can be updated via sync' }, { changeSource: ItemChange.SOURCE_SYNC }));
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not allow deleting a read-only note', async () => {
|
||||||
|
const folder = await Folder.save({ });
|
||||||
|
const note1 = await Note.save({ parent_id: folder.id });
|
||||||
|
|
||||||
|
const cleanup = simulateReadOnlyShareEnv('123456789');
|
||||||
|
|
||||||
|
await Folder.save({ id: folder.id, share_id: '123456789' });
|
||||||
|
await Note.save({ id: note1.id, share_id: '123456789' });
|
||||||
|
|
||||||
|
await expectThrow(async () => await Note.delete(note1.id), ErrorCode.IsReadOnly);
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not allow creating a new note as a child of a read-only folder', (async () => {
|
||||||
|
const cleanup = simulateReadOnlyShareEnv('123456789');
|
||||||
|
|
||||||
|
const readonlyFolder = await Folder.save({ share_id: '123456789' });
|
||||||
|
await expectThrow(async () => Note.save({ parent_id: readonlyFolder.id }), ErrorCode.IsReadOnly);
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should not allow adding an existing note as a child of a read-only folder', (async () => {
|
||||||
|
const cleanup = simulateReadOnlyShareEnv('123456789');
|
||||||
|
|
||||||
|
const readonlyFolder = await Folder.save({ share_id: '123456789' });
|
||||||
|
const note = await Note.save({});
|
||||||
|
await expectThrow(async () => Note.save({ id: note.id, parent_id: readonlyFolder.id }), ErrorCode.IsReadOnly);
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
}));
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -14,7 +14,7 @@ import { toFileProtocolPath, toForwardSlashes } from '../path-utils';
|
||||||
const { pregQuote, substrWithEllipsis } = require('../string-utils.js');
|
const { pregQuote, substrWithEllipsis } = require('../string-utils.js');
|
||||||
const { _ } = require('../locale');
|
const { _ } = require('../locale');
|
||||||
import { pull, unique } from '../ArrayUtils';
|
import { pull, unique } from '../ArrayUtils';
|
||||||
import { LoadOptions } from './utils/types';
|
import { LoadOptions, SaveOptions } from './utils/types';
|
||||||
const urlUtils = require('../urlUtils.js');
|
const urlUtils = require('../urlUtils.js');
|
||||||
const { isImageMimeType } = require('../resourceUtils');
|
const { isImageMimeType } = require('../resourceUtils');
|
||||||
const { MarkupToHtml } = require('@joplin/renderer');
|
const { MarkupToHtml } = require('@joplin/renderer');
|
||||||
|
@ -312,7 +312,7 @@ export default class Note extends BaseItem {
|
||||||
public static previewFields(options: any = null) {
|
public static previewFields(options: any = null) {
|
||||||
options = { includeTimestamps: true, ...options };
|
options = { includeTimestamps: true, ...options };
|
||||||
|
|
||||||
const output = ['id', 'title', 'is_todo', 'todo_completed', 'todo_due', 'parent_id', 'encryption_applied', 'order', 'markup_language', 'is_conflict', 'is_shared'];
|
const output = ['id', 'title', 'is_todo', 'todo_completed', 'todo_due', 'parent_id', 'encryption_applied', 'order', 'markup_language', 'is_conflict', 'is_shared', 'share_id'];
|
||||||
|
|
||||||
if (options.includeTimestamps) {
|
if (options.includeTimestamps) {
|
||||||
output.push('updated_time');
|
output.push('updated_time');
|
||||||
|
@ -667,7 +667,7 @@ export default class Note extends BaseItem {
|
||||||
return super.load(id, options);
|
return super.load(id, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async save(o: NoteEntity, options: any = null): Promise<NoteEntity> {
|
public static async save(o: NoteEntity, options: SaveOptions = null): Promise<NoteEntity> {
|
||||||
const isNew = this.isNew(o, options);
|
const isNew = this.isNew(o, options);
|
||||||
|
|
||||||
// If true, this is a provisional note - it will be saved permanently
|
// If true, this is a provisional note - it will be saved permanently
|
||||||
|
@ -684,6 +684,8 @@ export default class Note extends BaseItem {
|
||||||
if (isNew && !o.source_application) o.source_application = Setting.value('appId');
|
if (isNew && !o.source_application) o.source_application = Setting.value('appId');
|
||||||
if (isNew && !('order' in o)) o.order = Date.now();
|
if (isNew && !('order' in o)) o.order = Date.now();
|
||||||
|
|
||||||
|
const changeSource = options && options.changeSource ? options.changeSource : null;
|
||||||
|
|
||||||
// We only keep the previous note content for "old notes" (see Revision Service for more info)
|
// We only keep the previous note content for "old notes" (see Revision Service for more info)
|
||||||
// In theory, we could simply save all the previous note contents, and let the revision service
|
// In theory, we could simply save all the previous note contents, and let the revision service
|
||||||
// decide what to keep and what to ignore, but in practice keeping the previous content is a bit
|
// decide what to keep and what to ignore, but in practice keeping the previous content is a bit
|
||||||
|
@ -720,7 +722,6 @@ export default class Note extends BaseItem {
|
||||||
|
|
||||||
const note = await super.save(o, options);
|
const note = await super.save(o, options);
|
||||||
|
|
||||||
const changeSource = options && options.changeSource ? options.changeSource : null;
|
|
||||||
void ItemChange.add(BaseModel.TYPE_NOTE, note.id, isNew ? ItemChange.TYPE_CREATE : ItemChange.TYPE_UPDATE, changeSource, beforeNoteJson);
|
void ItemChange.add(BaseModel.TYPE_NOTE, note.id, isNew ? ItemChange.TYPE_CREATE : ItemChange.TYPE_UPDATE, changeSource, beforeNoteJson);
|
||||||
|
|
||||||
if (dispatchUpdateAction) {
|
if (dispatchUpdateAction) {
|
||||||
|
@ -1005,10 +1006,11 @@ export default class Note extends BaseItem {
|
||||||
return new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
|
return new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public static async createConflictNote(sourceNote: NoteEntity, changeSource: number): Promise<NoteEntity> {
|
public static async createConflictNote(sourceNote: NoteEntity, changeSource: number): Promise<NoteEntity> {
|
||||||
const conflictNote = { ...sourceNote };
|
const conflictNote = { ...sourceNote };
|
||||||
delete conflictNote.id;
|
delete conflictNote.id;
|
||||||
|
delete conflictNote.is_shared;
|
||||||
|
delete conflictNote.share_id;
|
||||||
conflictNote.is_conflict = 1;
|
conflictNote.is_conflict = 1;
|
||||||
conflictNote.conflict_original_id = sourceNote.id;
|
conflictNote.conflict_original_id = sourceNote.id;
|
||||||
return await Note.save(conflictNote, { autoTimestamp: false, changeSource: changeSource });
|
return await Note.save(conflictNote, { autoTimestamp: false, changeSource: changeSource });
|
||||||
|
|
|
@ -1,11 +1,30 @@
|
||||||
import { supportDir, setupDatabaseAndSynchronizer, switchClient } from '../testing/test-utils';
|
import { supportDir, setupDatabaseAndSynchronizer, switchClient, simulateReadOnlyShareEnv, expectThrow, createTempFile } from '../testing/test-utils';
|
||||||
import Folder from '../models/Folder';
|
import Folder from '../models/Folder';
|
||||||
import Note from '../models/Note';
|
import Note from '../models/Note';
|
||||||
import Resource from '../models/Resource';
|
import Resource from '../models/Resource';
|
||||||
import shim from '../shim';
|
import shim from '../shim';
|
||||||
|
import { ErrorCode } from '../errors';
|
||||||
|
import { remove, pathExists } from 'fs-extra';
|
||||||
|
|
||||||
const testImagePath = `${supportDir}/photo.jpg`;
|
const testImagePath = `${supportDir}/photo.jpg`;
|
||||||
|
|
||||||
|
const setupFolderNoteResourceReadOnly = async (shareId: string) => {
|
||||||
|
const cleanup = simulateReadOnlyShareEnv(shareId);
|
||||||
|
|
||||||
|
let folder = await Folder.save({ });
|
||||||
|
let note = await Note.save({ parent_id: folder.id });
|
||||||
|
await shim.attachFileToNote(note, testImagePath);
|
||||||
|
let resource = (await Resource.all())[0];
|
||||||
|
|
||||||
|
folder = await Folder.save({ id: folder.id, share_id: shareId });
|
||||||
|
note = await Note.save({ id: note.id, share_id: shareId });
|
||||||
|
resource = await Resource.save({ id: resource.id, share_id: shareId });
|
||||||
|
|
||||||
|
resource = await Resource.load(resource.id); // reload to get all properties
|
||||||
|
|
||||||
|
return { cleanup, folder, note, resource };
|
||||||
|
};
|
||||||
|
|
||||||
describe('models/Resource', () => {
|
describe('models/Resource', () => {
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
@ -76,24 +95,27 @@ describe('models/Resource', () => {
|
||||||
expect(originalStat.size).toBe(newStat.size);
|
expect(originalStat.size).toBe(newStat.size);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// it('should encrypt a shared resource using the correct encryption key', (async () => {
|
it('should not allow modifying a read-only resource', async () => {
|
||||||
// const folder1 = await Folder.save({ title: 'folder1' });
|
const { cleanup, resource } = await setupFolderNoteResourceReadOnly('123456789');
|
||||||
// const note1 = await Note.save({ title: 'ma note', parent_id: folder1.id });
|
await expectThrow(async () => Resource.save({ id: resource.id, share_id: '123456789', title: 'cannot do this!' }), ErrorCode.IsReadOnly);
|
||||||
// await shim.attachFileToNote(note1, testImagePath);
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
// Resource.shareService_ = {
|
it('should not allow modifying a read-only resource content', async () => {
|
||||||
// shareById: () => {
|
const { cleanup, resource } = await setupFolderNoteResourceReadOnly('123456789');
|
||||||
// return {
|
const tempFilePath = await createTempFile('something');
|
||||||
// master_key_id: '',
|
await expectThrow(async () => Resource.updateResourceBlobContent(resource.id, tempFilePath), ErrorCode.IsReadOnly);
|
||||||
// };
|
await remove(tempFilePath);
|
||||||
// },
|
cleanup();
|
||||||
// } as any;
|
});
|
||||||
|
|
||||||
// try {
|
it('should not allow deleting a read-only resource', async () => {
|
||||||
|
const { cleanup, resource } = await setupFolderNoteResourceReadOnly('123456789');
|
||||||
// } finally {
|
expect(await pathExists(Resource.fullPath(resource))).toBe(true);
|
||||||
// Resource.shareService_ = null;
|
await expectThrow(async () => Resource.delete(resource.id), ErrorCode.IsReadOnly);
|
||||||
// }
|
// Also check that the resource blob has not been deleted
|
||||||
// }));
|
expect(await pathExists(Resource.fullPath(resource))).toBe(true);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -271,6 +271,11 @@ export default class Resource extends BaseItem {
|
||||||
return ResourceLocalState.byResourceId(typeof resourceOrId === 'object' ? resourceOrId.id : resourceOrId);
|
return ResourceLocalState.byResourceId(typeof resourceOrId === 'object' ? resourceOrId.id : resourceOrId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static setLocalStateQueries(resourceOrId: any, state: ResourceLocalStateEntity) {
|
||||||
|
const id = typeof resourceOrId === 'object' ? resourceOrId.id : resourceOrId;
|
||||||
|
return ResourceLocalState.saveQueries({ ...state, resource_id: id });
|
||||||
|
}
|
||||||
|
|
||||||
public static async setLocalState(resourceOrId: any, state: ResourceLocalStateEntity) {
|
public static async setLocalState(resourceOrId: any, state: ResourceLocalStateEntity) {
|
||||||
const id = typeof resourceOrId === 'object' ? resourceOrId.id : resourceOrId;
|
const id = typeof resourceOrId === 'object' ? resourceOrId.id : resourceOrId;
|
||||||
await ResourceLocalState.save({ ...state, resource_id: id });
|
await ResourceLocalState.save({ ...state, resource_id: id });
|
||||||
|
@ -288,17 +293,18 @@ export default class Resource extends BaseItem {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async batchDelete(ids: string[], options: any = null) {
|
public static async batchDelete(ids: string[], options: any = null) {
|
||||||
// For resources, there's not really batch deleting since there's the file data to delete
|
// For resources, there's not really batch deletion since there's the
|
||||||
// too, so each is processed one by one with the item being deleted last (since the db
|
// file data to delete too, so each is processed one by one with the
|
||||||
// call is the less likely to fail).
|
// file data being deleted last since the metadata deletion call may
|
||||||
|
// throw (for example if trying to delete a read-only item).
|
||||||
for (let i = 0; i < ids.length; i++) {
|
for (let i = 0; i < ids.length; i++) {
|
||||||
const id = ids[i];
|
const id = ids[i];
|
||||||
const resource = await Resource.load(id);
|
const resource = await Resource.load(id);
|
||||||
if (!resource) continue;
|
if (!resource) continue;
|
||||||
|
|
||||||
const path = Resource.fullPath(resource);
|
const path = Resource.fullPath(resource);
|
||||||
await this.fsDriver().remove(path);
|
|
||||||
await super.batchDelete([id], options);
|
await super.batchDelete([id], options);
|
||||||
|
await this.fsDriver().remove(path);
|
||||||
await NoteResource.deleteByResource(id); // Clean up note/resource relationships
|
await NoteResource.deleteByResource(id); // Clean up note/resource relationships
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -362,12 +368,20 @@ export default class Resource extends BaseItem {
|
||||||
await this.requireIsReady(resource);
|
await this.requireIsReady(resource);
|
||||||
|
|
||||||
const fileStat = await this.fsDriver().stat(newBlobFilePath);
|
const fileStat = await this.fsDriver().stat(newBlobFilePath);
|
||||||
await this.fsDriver().copy(newBlobFilePath, Resource.fullPath(resource));
|
|
||||||
|
|
||||||
return await Resource.save({
|
// We first save the resource metadata because this can throw, for
|
||||||
|
// example if modifying a resource that is read-only
|
||||||
|
|
||||||
|
const result = await Resource.save({
|
||||||
id: resource.id,
|
id: resource.id,
|
||||||
size: fileStat.size,
|
size: fileStat.size,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// If the above call has succeeded, we save the data blob
|
||||||
|
|
||||||
|
await this.fsDriver().copy(newBlobFilePath, Resource.fullPath(resource));
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async resourceBlobContent(resourceId: string, encoding = 'Buffer') {
|
public static async resourceBlobContent(resourceId: string, encoding = 'Buffer') {
|
||||||
|
@ -383,6 +397,7 @@ export default class Resource extends BaseItem {
|
||||||
let newResource: ResourceEntity = { ...resource };
|
let newResource: ResourceEntity = { ...resource };
|
||||||
delete newResource.id;
|
delete newResource.id;
|
||||||
delete newResource.is_shared;
|
delete newResource.is_shared;
|
||||||
|
delete newResource.share_id;
|
||||||
newResource = await Resource.save(newResource);
|
newResource = await Resource.save(newResource);
|
||||||
|
|
||||||
const newLocalState = { ...localState };
|
const newLocalState = { ...localState };
|
||||||
|
|
|
@ -26,10 +26,12 @@ export default class ResourceLocalState extends BaseModel {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async save(o: ResourceLocalStateEntity) {
|
public static saveQueries(o: ResourceLocalStateEntity) {
|
||||||
const queries = [{ sql: 'DELETE FROM resource_local_states WHERE resource_id = ?', params: [o.resource_id] }, Database.insertQuery(this.tableName(), o)];
|
return [{ sql: 'DELETE FROM resource_local_states WHERE resource_id = ?', params: [o.resource_id] }, Database.insertQuery(this.tableName(), o)];
|
||||||
|
}
|
||||||
|
|
||||||
return this.db().transactionExecBatch(queries);
|
public static async save(o: ResourceLocalStateEntity) {
|
||||||
|
return this.db().transactionExecBatch(this.saveQueries(o));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static batchDelete(ids: string[], options: any = null) {
|
public static batchDelete(ids: string[], options: any = null) {
|
||||||
|
|
|
@ -1699,6 +1699,12 @@ class Setting extends BaseModel {
|
||||||
public: false,
|
public: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'sync.shareCache': {
|
||||||
|
value: null,
|
||||||
|
type: SettingItemType.String,
|
||||||
|
public: false,
|
||||||
|
},
|
||||||
|
|
||||||
'voiceTypingBaseUrl': {
|
'voiceTypingBaseUrl': {
|
||||||
value: '',
|
value: '',
|
||||||
type: SettingItemType.String,
|
type: SettingItemType.String,
|
||||||
|
@ -1708,7 +1714,6 @@ class Setting extends BaseModel {
|
||||||
label: () => _('Voice typing language files (URL)'),
|
label: () => _('Voice typing language files (URL)'),
|
||||||
section: 'note',
|
section: 'note',
|
||||||
},
|
},
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
this.metadata_ = { ...this.buildInMetadata_ };
|
this.metadata_ = { ...this.buildInMetadata_ };
|
||||||
|
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { ModelType } from '../../BaseModel';
|
||||||
|
import { ErrorCode } from '../../errors';
|
||||||
|
import JoplinError from '../../JoplinError';
|
||||||
|
import { State as ShareState } from '../../services/share/reducer';
|
||||||
|
import ItemChange from '../ItemChange';
|
||||||
|
import Setting from '../Setting';
|
||||||
|
|
||||||
|
export interface ItemSlice {
|
||||||
|
id?: string;
|
||||||
|
share_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This function can be called to wrap any read-only-related code. It should be
|
||||||
|
// fast and allows an early exit for cases that don't apply, for example if not
|
||||||
|
// synchronising with Joplin Cloud or if not sharing any notebook.
|
||||||
|
export const needsReadOnlyChecks = (itemType: ModelType, changeSource: number, shareState: ShareState) => {
|
||||||
|
if (Setting.value('sync.target') !== 10) return false;
|
||||||
|
if (changeSource === ItemChange.SOURCE_SYNC) return false;
|
||||||
|
if (!Setting.value('sync.userId')) return false;
|
||||||
|
if (![ModelType.Note, ModelType.Folder, ModelType.Resource].includes(itemType)) return false;
|
||||||
|
|
||||||
|
if (!shareState) throw new Error('Share state must be provided');
|
||||||
|
if (!shareState.shareInvitations.length) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const checkIfItemsCanBeChanged = (itemType: ModelType, changeSource: number, items: ItemSlice[], shareState: ShareState) => {
|
||||||
|
for (const item of items) {
|
||||||
|
checkIfItemCanBeChanged(itemType, changeSource, item, shareState);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const checkIfItemCanBeChanged = (itemType: ModelType, changeSource: number, item: ItemSlice, shareState: ShareState) => {
|
||||||
|
if (!needsReadOnlyChecks(itemType, changeSource, shareState)) return;
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
if (itemIsReadOnlySync(itemType, changeSource, item, Setting.value('sync.userId'), shareState)) {
|
||||||
|
throw new JoplinError(`Cannot change or delete a read-only item: ${item.id}`, ErrorCode.IsReadOnly);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const checkIfItemCanBeAddedToFolder = async (itemType: ModelType, Folder: any, changeSource: number, shareState: ShareState, parentId: string) => {
|
||||||
|
if (needsReadOnlyChecks(itemType, changeSource, shareState) && parentId) {
|
||||||
|
const parentFolder = await Folder.load(parentId, { fields: ['id', 'share_id'] });
|
||||||
|
if (itemIsReadOnlySync(itemType, changeSource, parentFolder, Setting.value('sync.userId'), shareState)) {
|
||||||
|
throw new JoplinError('Cannot add an item as a child of a read-only item', ErrorCode.IsReadOnly);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const itemIsReadOnlySync = (itemType: ModelType, changeSource: number, item: ItemSlice, userId: string, shareState: ShareState): boolean => {
|
||||||
|
if (!needsReadOnlyChecks(itemType, changeSource, shareState)) return false;
|
||||||
|
|
||||||
|
if (!('share_id' in item)) throw new Error('share_id property is missing');
|
||||||
|
|
||||||
|
// Item is not shared
|
||||||
|
if (!item.share_id) return false;
|
||||||
|
|
||||||
|
// Item belongs to the user
|
||||||
|
if (shareState.shares.find(s => s.user.id === userId)) return false;
|
||||||
|
|
||||||
|
const shareUser = shareState.shareInvitations.find(si => si.share.id === item.share_id);
|
||||||
|
|
||||||
|
// Shouldn't happen
|
||||||
|
if (!shareUser) return false;
|
||||||
|
|
||||||
|
return !shareUser.can_write;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const itemIsReadOnly = async (BaseItem: any, itemType: ModelType, changeSource: number, itemId: string, userId: string, shareState: ShareState): Promise<boolean> => {
|
||||||
|
if (!needsReadOnlyChecks(itemType, changeSource, shareState)) return false;
|
||||||
|
const item: ItemSlice = await BaseItem.loadItem(itemType, itemId, { fields: ['id', 'share_id'] });
|
||||||
|
if (!item) throw new JoplinError(`No such item: ${itemType}: ${itemId}`, ErrorCode.NotFound);
|
||||||
|
return itemIsReadOnlySync(itemType, changeSource, item, userId, shareState);
|
||||||
|
};
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { SqlQuery } from '../../database';
|
||||||
|
|
||||||
export enum PaginationOrderDir {
|
export enum PaginationOrderDir {
|
||||||
ASC = 'ASC',
|
ASC = 'ASC',
|
||||||
DESC = 'DESC',
|
DESC = 'DESC',
|
||||||
|
@ -20,3 +22,15 @@ export interface LoadOptions {
|
||||||
caseInsensitive?: boolean;
|
caseInsensitive?: boolean;
|
||||||
fields?: string | string[];
|
fields?: string | string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SaveOptions {
|
||||||
|
isNew?: boolean;
|
||||||
|
oldItem?: any;
|
||||||
|
userSideValidation?: boolean;
|
||||||
|
nextQueries?: SqlQuery[];
|
||||||
|
autoTimestamp?: boolean;
|
||||||
|
provisional?: boolean;
|
||||||
|
ignoreProvisionalFlag?: boolean;
|
||||||
|
dispatchUpdateAction?: boolean;
|
||||||
|
changeSource?: number;
|
||||||
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { toSystemSlashes } from '../../path-utils';
|
||||||
import Logger from '../../Logger';
|
import Logger from '../../Logger';
|
||||||
import Setting from '../../models/Setting';
|
import Setting from '../../models/Setting';
|
||||||
import Resource from '../../models/Resource';
|
import Resource from '../../models/Resource';
|
||||||
|
import { ResourceEntity } from '../database/types';
|
||||||
const EventEmitter = require('events');
|
const EventEmitter = require('events');
|
||||||
const chokidar = require('chokidar');
|
const chokidar = require('chokidar');
|
||||||
|
|
||||||
|
@ -213,6 +214,20 @@ export default class ResourceEditWatcher {
|
||||||
return this.watcher_;
|
return this.watcher_;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async makeEditPath(resource: ResourceEntity) {
|
||||||
|
const tempDir = await this.tempDir();
|
||||||
|
return toSystemSlashes(await shim.fsDriver().findUniqueFilename(`${tempDir}/${Resource.friendlySafeFilename(resource)}`), 'linux');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async copyResourceToEditablePath(resourceId: string) {
|
||||||
|
const resource = await Resource.load(resourceId);
|
||||||
|
if (!(await Resource.isReady(resource))) throw new Error(_('This attachment is not downloaded or not decrypted yet'));
|
||||||
|
const sourceFilePath = Resource.fullPath(resource);
|
||||||
|
const editFilePath = await this.makeEditPath(resource);
|
||||||
|
await shim.fsDriver().copy(sourceFilePath, editFilePath);
|
||||||
|
return { resource, editFilePath };
|
||||||
|
}
|
||||||
|
|
||||||
private async watch(resourceId: string): Promise<WatchedItem> {
|
private async watch(resourceId: string): Promise<WatchedItem> {
|
||||||
let watchedItem = this.watchedItemByResourceId(resourceId);
|
let watchedItem = this.watchedItemByResourceId(resourceId);
|
||||||
|
|
||||||
|
@ -229,13 +244,7 @@ export default class ResourceEditWatcher {
|
||||||
};
|
};
|
||||||
|
|
||||||
this.watchedItems_[resourceId] = watchedItem;
|
this.watchedItems_[resourceId] = watchedItem;
|
||||||
|
const { resource, editFilePath } = await this.copyResourceToEditablePath(resourceId);
|
||||||
const resource = await Resource.load(resourceId);
|
|
||||||
if (!(await Resource.isReady(resource))) throw new Error(_('This attachment is not downloaded or not decrypted yet'));
|
|
||||||
const sourceFilePath = Resource.fullPath(resource);
|
|
||||||
const tempDir = await this.tempDir();
|
|
||||||
const editFilePath = toSystemSlashes(await shim.fsDriver().findUniqueFilename(`${tempDir}/${Resource.friendlySafeFilename(resource)}`), 'linux');
|
|
||||||
await shim.fsDriver().copy(sourceFilePath, editFilePath);
|
|
||||||
const stat = await shim.fsDriver().stat(editFilePath);
|
const stat = await shim.fsDriver().stat(editFilePath);
|
||||||
|
|
||||||
watchedItem.path = editFilePath;
|
watchedItem.path = editFilePath;
|
||||||
|
@ -259,10 +268,18 @@ export default class ResourceEditWatcher {
|
||||||
|
|
||||||
public async openAndWatch(resourceId: string) {
|
public async openAndWatch(resourceId: string) {
|
||||||
const watchedItem = await this.watch(resourceId);
|
const watchedItem = await this.watch(resourceId);
|
||||||
// bridge().openItem(watchedItem.path);
|
|
||||||
this.openItem_(watchedItem.path);
|
this.openItem_(watchedItem.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This call simply copies the resource file to a separate path and opens it.
|
||||||
|
// That way, even if it is changed, the real resource file on drive won't be
|
||||||
|
// affected.
|
||||||
|
public async openAsReadOnly(resourceId: string) {
|
||||||
|
const { editFilePath } = await this.copyResourceToEditablePath(resourceId);
|
||||||
|
await shim.fsDriver().chmod(editFilePath, 0o0666);
|
||||||
|
this.openItem_(editFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
public async stopWatching(resourceId: string) {
|
public async stopWatching(resourceId: string) {
|
||||||
if (!resourceId) return;
|
if (!resourceId) return;
|
||||||
|
|
||||||
|
|
|
@ -96,9 +96,13 @@ export default class MenuUtils {
|
||||||
}
|
}
|
||||||
|
|
||||||
public commandToStatefulMenuItem(commandName: string, ...args: any[]): MenuItem {
|
public commandToStatefulMenuItem(commandName: string, ...args: any[]): MenuItem {
|
||||||
return this.commandToMenuItem(commandName, () => {
|
const whenClauseContext = this.service.currentWhenClauseContext();
|
||||||
|
|
||||||
|
const menuItem = this.commandToMenuItem(commandName, () => {
|
||||||
return this.service.execute(commandName, ...args);
|
return this.service.execute(commandName, ...args);
|
||||||
});
|
});
|
||||||
|
menuItem.enabled = this.service.isEnabled(commandName, whenClauseContext);
|
||||||
|
return menuItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
import { State, stateUtils } from '../../reducer';
|
import { State, stateUtils } from '../../reducer';
|
||||||
import BaseModel from '../../BaseModel';
|
import BaseModel, { ModelType } from '../../BaseModel';
|
||||||
import Folder from '../../models/Folder';
|
import Folder from '../../models/Folder';
|
||||||
import MarkupToHtml from '@joplin/renderer/MarkupToHtml';
|
import MarkupToHtml from '@joplin/renderer/MarkupToHtml';
|
||||||
import { isRootSharedFolder, isSharedFolderOwner } from '../share/reducer';
|
import { isRootSharedFolder, isSharedFolderOwner } from '../share/reducer';
|
||||||
import { FolderEntity, NoteEntity } from '../database/types';
|
import { FolderEntity, NoteEntity } from '../database/types';
|
||||||
|
import { itemIsReadOnlySync, ItemSlice } from '../../models/utils/readOnly';
|
||||||
|
import ItemChange from '../../models/ItemChange';
|
||||||
|
|
||||||
export interface WhenClauseContextOptions {
|
export interface WhenClauseContextOptions {
|
||||||
commandFolderId?: string;
|
commandFolderId?: string;
|
||||||
|
@ -31,6 +33,8 @@ export interface WhenClauseContext {
|
||||||
folderIsShareRoot: boolean;
|
folderIsShareRoot: boolean;
|
||||||
joplinServerConnected: boolean;
|
joplinServerConnected: boolean;
|
||||||
hasMultiProfiles: boolean;
|
hasMultiProfiles: boolean;
|
||||||
|
noteIsReadOnly: boolean;
|
||||||
|
folderIsReadOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function stateToWhenClauseContext(state: State, options: WhenClauseContextOptions = null): WhenClauseContext {
|
export default function stateToWhenClauseContext(state: State, options: WhenClauseContextOptions = null): WhenClauseContext {
|
||||||
|
@ -40,15 +44,15 @@ export default function stateToWhenClauseContext(state: State, options: WhenClau
|
||||||
...options,
|
...options,
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedNoteId = state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null;
|
const selectedNoteIds = state.selectedNoteIds || [];
|
||||||
|
const selectedNoteId = selectedNoteIds.length === 1 ? selectedNoteIds[0] : null;
|
||||||
const selectedNote: NoteEntity = selectedNoteId ? BaseModel.byId(state.notes, selectedNoteId) : null;
|
const selectedNote: NoteEntity = selectedNoteId ? BaseModel.byId(state.notes, selectedNoteId) : null;
|
||||||
|
|
||||||
// const commandNoteId = options.commandNoteId || selectedNoteId;
|
|
||||||
// const commandNote:NoteEntity = commandNoteId ? BaseModel.byId(state.notes, commandNoteId) : null;
|
|
||||||
|
|
||||||
const commandFolderId = options.commandFolderId || state.selectedFolderId;
|
const commandFolderId = options.commandFolderId || state.selectedFolderId;
|
||||||
const commandFolder: FolderEntity = commandFolderId ? BaseModel.byId(state.folders, commandFolderId) : null;
|
const commandFolder: FolderEntity = commandFolderId ? BaseModel.byId(state.folders, commandFolderId) : null;
|
||||||
|
|
||||||
|
const settings = state.settings || {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Application state
|
// Application state
|
||||||
notesAreBeingSaved: stateUtils.hasNotesBeingSaved(state),
|
notesAreBeingSaved: stateUtils.hasNotesBeingSaved(state),
|
||||||
|
@ -59,13 +63,13 @@ export default function stateToWhenClauseContext(state: State, options: WhenClau
|
||||||
|
|
||||||
// Note selection
|
// Note selection
|
||||||
oneNoteSelected: !!selectedNote,
|
oneNoteSelected: !!selectedNote,
|
||||||
someNotesSelected: state.selectedNoteIds.length > 0,
|
someNotesSelected: selectedNoteIds.length > 0,
|
||||||
multipleNotesSelected: state.selectedNoteIds.length > 1,
|
multipleNotesSelected: selectedNoteIds.length > 1,
|
||||||
noNotesSelected: !state.selectedNoteIds.length,
|
noNotesSelected: !selectedNoteIds.length,
|
||||||
|
|
||||||
// Note history
|
// Note history
|
||||||
historyhasBackwardNotes: state.backwardHistoryNotes.length > 0,
|
historyhasBackwardNotes: state.backwardHistoryNotes && state.backwardHistoryNotes.length > 0,
|
||||||
historyhasForwardNotes: state.forwardHistoryNotes.length > 0,
|
historyhasForwardNotes: state.forwardHistoryNotes && state.forwardHistoryNotes.length > 0,
|
||||||
|
|
||||||
// Folder selection
|
// Folder selection
|
||||||
oneFolderSelected: !!state.selectedFolderId,
|
oneFolderSelected: !!state.selectedFolderId,
|
||||||
|
@ -82,8 +86,11 @@ export default function stateToWhenClauseContext(state: State, options: WhenClau
|
||||||
folderIsShareRootAndOwnedByUser: commandFolder ? isRootSharedFolder(commandFolder) && isSharedFolderOwner(state, commandFolder.id) : false,
|
folderIsShareRootAndOwnedByUser: commandFolder ? isRootSharedFolder(commandFolder) && isSharedFolderOwner(state, commandFolder.id) : false,
|
||||||
folderIsShared: commandFolder ? !!commandFolder.share_id : false,
|
folderIsShared: commandFolder ? !!commandFolder.share_id : false,
|
||||||
|
|
||||||
joplinServerConnected: [9, 10].includes(state.settings['sync.target']),
|
joplinServerConnected: [9, 10].includes(settings['sync.target']),
|
||||||
|
|
||||||
hasMultiProfiles: state.profileConfig && state.profileConfig.profiles.length > 1,
|
hasMultiProfiles: state.profileConfig && state.profileConfig.profiles.length > 1,
|
||||||
|
|
||||||
|
noteIsReadOnly: selectedNote ? itemIsReadOnlySync(ModelType.Note, ItemChange.SOURCE_UNSPECIFIED, selectedNote as ItemSlice, settings['sync.userId'], state.shareService) : false,
|
||||||
|
folderIsReadOnly: commandFolder ? itemIsReadOnlySync(ModelType.Note, ItemChange.SOURCE_UNSPECIFIED, commandFolder as ItemSlice, settings['sync.userId'], state.shareService) : false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -210,7 +210,7 @@ describe('ShareService', () => {
|
||||||
|
|
||||||
const { share } = await testShareFolder(service);
|
const { share } = await testShareFolder(service);
|
||||||
|
|
||||||
await service.addShareRecipient(share.id, share.master_key_id, 'toto@example.com');
|
await service.addShareRecipient(share.id, share.master_key_id, 'toto@example.com', { can_read: 1, can_write: 1 });
|
||||||
|
|
||||||
expect(uploadedEmail).toBe('toto@example.com');
|
expect(uploadedEmail).toBe('toto@example.com');
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,7 @@ import { MasterKeyEntity } from '../e2ee/types';
|
||||||
import { getMasterPassword } from '../e2ee/utils';
|
import { getMasterPassword } from '../e2ee/utils';
|
||||||
import ResourceService from '../ResourceService';
|
import ResourceService from '../ResourceService';
|
||||||
import { addMasterKey, getEncryptionEnabled, localSyncInfo } from '../synchronizer/syncInfoUtils';
|
import { addMasterKey, getEncryptionEnabled, localSyncInfo } from '../synchronizer/syncInfoUtils';
|
||||||
import { ShareInvitation, State, stateRootKey, StateShare } from './reducer';
|
import { ShareInvitation, SharePermissions, State, stateRootKey, StateShare } from './reducer';
|
||||||
|
|
||||||
const logger = Logger.create('ShareService');
|
const logger = Logger.create('ShareService');
|
||||||
|
|
||||||
|
@ -306,7 +306,7 @@ export default class ShareService {
|
||||||
return this.api().exec('GET', `api/users/${encodeURIComponent(userEmail)}/public_key`);
|
return this.api().exec('GET', `api/users/${encodeURIComponent(userEmail)}/public_key`);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async addShareRecipient(shareId: string, masterKeyId: string, recipientEmail: string) {
|
public async addShareRecipient(shareId: string, masterKeyId: string, recipientEmail: string, permissions: SharePermissions) {
|
||||||
let recipientMasterKey: MasterKeyEntity = null;
|
let recipientMasterKey: MasterKeyEntity = null;
|
||||||
|
|
||||||
if (getEncryptionEnabled()) {
|
if (getEncryptionEnabled()) {
|
||||||
|
@ -330,6 +330,7 @@ export default class ShareService {
|
||||||
return this.api().exec('POST', `api/shares/${shareId}/users`, {}, {
|
return this.api().exec('POST', `api/shares/${shareId}/users`, {}, {
|
||||||
email: recipientEmail,
|
email: recipientEmail,
|
||||||
master_key: JSON.stringify(recipientMasterKey),
|
master_key: JSON.stringify(recipientMasterKey),
|
||||||
|
...permissions,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -360,6 +361,25 @@ export default class ShareService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async setPermissions(shareId: string, shareUserId: string, permissions: SharePermissions) {
|
||||||
|
logger.info('setPermissions: ', shareUserId, permissions);
|
||||||
|
|
||||||
|
await this.api().exec('PATCH', `api/share_users/${shareUserId}`, null, {
|
||||||
|
can_read: 1,
|
||||||
|
can_write: permissions.can_write,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.store.dispatch({
|
||||||
|
type: 'SHARE_USER_UPDATE_ONE',
|
||||||
|
shareId: shareId,
|
||||||
|
shareUser: {
|
||||||
|
id: shareUserId,
|
||||||
|
...permissions,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public async respondInvitation(shareUserId: string, masterKey: MasterKeyEntity, accept: boolean) {
|
public async respondInvitation(shareUserId: string, masterKey: MasterKeyEntity, accept: boolean) {
|
||||||
logger.info('respondInvitation: ', shareUserId, accept);
|
logger.info('respondInvitation: ', shareUserId, accept);
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,9 @@ import { State as RootState } from '../../reducer';
|
||||||
import { Draft } from 'immer';
|
import { Draft } from 'immer';
|
||||||
import { FolderEntity } from '../database/types';
|
import { FolderEntity } from '../database/types';
|
||||||
import { MasterKeyEntity } from '../e2ee/types';
|
import { MasterKeyEntity } from '../e2ee/types';
|
||||||
|
import Logger from '../../Logger';
|
||||||
|
|
||||||
|
const logger = Logger.create('share/reducer');
|
||||||
|
|
||||||
interface StateShareUserUser {
|
interface StateShareUserUser {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -15,10 +18,17 @@ export enum ShareUserStatus {
|
||||||
Rejected = 2,
|
Rejected = 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SharePermissions {
|
||||||
|
can_read: number;
|
||||||
|
can_write: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StateShareUser {
|
export interface StateShareUser {
|
||||||
id: string;
|
id: string;
|
||||||
status: ShareUserStatus;
|
status: ShareUserStatus;
|
||||||
user: StateShareUserUser;
|
user: StateShareUserUser;
|
||||||
|
can_read: number;
|
||||||
|
can_write: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StateShare {
|
export interface StateShare {
|
||||||
|
@ -35,6 +45,8 @@ export interface ShareInvitation {
|
||||||
master_key: MasterKeyEntity;
|
master_key: MasterKeyEntity;
|
||||||
share: StateShare;
|
share: StateShare;
|
||||||
status: ShareUserStatus;
|
status: ShareUserStatus;
|
||||||
|
can_read: number;
|
||||||
|
can_write: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface State {
|
export interface State {
|
||||||
|
@ -53,6 +65,27 @@ export const defaultState: State = {
|
||||||
processingShareInvitationResponse: false,
|
processingShareInvitationResponse: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const parseShareCache = (serialized: string): State => {
|
||||||
|
let raw: any = {};
|
||||||
|
try {
|
||||||
|
raw = JSON.parse(serialized);
|
||||||
|
if (!raw) raw = {};
|
||||||
|
} catch (error) {
|
||||||
|
logger.info('Could not load share cache from settings - will return a default value. Error was:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
shares: raw.shares || [],
|
||||||
|
shareUsers: raw.shareUsers || {},
|
||||||
|
shareInvitations: raw.shareInvitations || [],
|
||||||
|
processingShareInvitationResponse: false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const readFromSettings = (state: RootState): State => {
|
||||||
|
return parseShareCache(state.settings['sync.shareCache']);
|
||||||
|
};
|
||||||
|
|
||||||
export function isSharedFolderOwner(state: RootState, folderId: string): boolean {
|
export function isSharedFolderOwner(state: RootState, folderId: string): boolean {
|
||||||
const userId = state.settings['sync.userId'];
|
const userId = state.settings['sync.userId'];
|
||||||
const share = state[stateRootKey].shares.find(s => s.folder_id === folderId);
|
const share = state[stateRootKey].shares.find(s => s.folder_id === folderId);
|
||||||
|
@ -82,6 +115,18 @@ const reducer = (draftRoot: Draft<RootState>, action: any) => {
|
||||||
draft.shareUsers[action.shareId] = action.shareUsers;
|
draft.shareUsers[action.shareId] = action.shareUsers;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'SHARE_USER_UPDATE_ONE':
|
||||||
|
|
||||||
|
{
|
||||||
|
const shareUser = (draft.shareUsers as any)[action.shareId].find((su: StateShareUser) => su.id === action.shareUser.id);
|
||||||
|
if (!shareUser) throw new Error(`No such user: ${JSON.stringify(action)}`);
|
||||||
|
|
||||||
|
for (const [name, value] of Object.entries(action.shareUser)) {
|
||||||
|
shareUser[name] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
case 'SHARE_INVITATION_SET':
|
case 'SHARE_INVITATION_SET':
|
||||||
|
|
||||||
draft.shareInvitations = action.shareInvitations;
|
draft.shareInvitations = action.shareInvitations;
|
||||||
|
|
|
@ -61,40 +61,40 @@ const expected = `
|
||||||
:root {
|
:root {
|
||||||
--joplin-appearance: light;
|
--joplin-appearance: light;
|
||||||
--joplin-background-color: #ffffff;
|
--joplin-background-color: #ffffff;
|
||||||
--joplin-background-color-transparent: rgba(255,255,255,0.9);
|
|
||||||
--joplin-odd-background-color: #eeeeee;
|
|
||||||
--joplin-color: #32373F;
|
|
||||||
--joplin-color-error: red;
|
|
||||||
--joplin-color-correct: green;
|
|
||||||
--joplin-color-warn: rgb(228,86,0);
|
|
||||||
--joplin-color-warn-url: #155BDA;
|
|
||||||
--joplin-color-faded: #7C8B9E;
|
|
||||||
--joplin-divider-color: #dddddd;
|
|
||||||
--joplin-selected-color: #e5e5e5;
|
|
||||||
--joplin-url-color: #155BDA;
|
|
||||||
--joplin-background-color2: #313640;
|
--joplin-background-color2: #313640;
|
||||||
|
--joplin-background-color3: #F4F5F6;
|
||||||
|
--joplin-background-color4: #ffffff;
|
||||||
|
--joplin-background-color-hover3: #CBDAF1;
|
||||||
|
--joplin-background-color-transparent: rgba(255,255,255,0.9);
|
||||||
|
--joplin-block-quote-opacity: 0.7;
|
||||||
|
--joplin-code-background-color: rgb(243, 243, 243);
|
||||||
|
--joplin-code-border-color: rgb(220, 220, 220);
|
||||||
|
--joplin-code-color: rgb(0,0,0);
|
||||||
|
--joplin-code-mirror-theme: default;
|
||||||
|
--joplin-code-theme-css: atom-one-light.css;
|
||||||
|
--joplin-color: #32373F;
|
||||||
--joplin-color2: #ffffff;
|
--joplin-color2: #ffffff;
|
||||||
--joplin-selected-color2: #131313;
|
--joplin-color3: #738598;
|
||||||
|
--joplin-color4: #2D6BDC;
|
||||||
|
--joplin-color-correct: green;
|
||||||
|
--joplin-color-error: red;
|
||||||
--joplin-color-error2: #ff6c6c;
|
--joplin-color-error2: #ff6c6c;
|
||||||
|
--joplin-color-faded: #7C8B9E;
|
||||||
|
--joplin-color-warn: rgb(228,86,0);
|
||||||
--joplin-color-warn2: #ffcb81;
|
--joplin-color-warn2: #ffcb81;
|
||||||
--joplin-color-warn3: #ff7626;
|
--joplin-color-warn3: #ff7626;
|
||||||
--joplin-background-color3: #F4F5F6;
|
--joplin-color-warn-url: #155BDA;
|
||||||
--joplin-background-color-hover3: #CBDAF1;
|
--joplin-divider-color: #dddddd;
|
||||||
--joplin-color3: #738598;
|
--joplin-odd-background-color: #eeeeee;
|
||||||
--joplin-background-color4: #ffffff;
|
|
||||||
--joplin-color4: #2D6BDC;
|
|
||||||
--joplin-raised-background-color: #e5e5e5;
|
--joplin-raised-background-color: #e5e5e5;
|
||||||
--joplin-raised-color: #222222;
|
--joplin-raised-color: #222222;
|
||||||
--joplin-search-marker-background-color: #F7D26E;
|
--joplin-search-marker-background-color: #F7D26E;
|
||||||
--joplin-search-marker-color: black;
|
--joplin-search-marker-color: black;
|
||||||
--joplin-warning-background-color: #FFD08D;
|
--joplin-selected-color: #e5e5e5;
|
||||||
|
--joplin-selected-color2: #131313;
|
||||||
--joplin-table-background-color: rgb(247, 247, 247);
|
--joplin-table-background-color: rgb(247, 247, 247);
|
||||||
--joplin-code-background-color: rgb(243, 243, 243);
|
--joplin-url-color: #155BDA;
|
||||||
--joplin-code-border-color: rgb(220, 220, 220);
|
--joplin-warning-background-color: #FFD08D;
|
||||||
--joplin-code-color: rgb(0,0,0);
|
|
||||||
--joplin-block-quote-opacity: 0.7;
|
|
||||||
--joplin-code-mirror-theme: default;
|
|
||||||
--joplin-code-theme-css: atom-one-light.css;
|
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
describe('themeToCss', () => {
|
describe('themeToCss', () => {
|
||||||
|
|
|
@ -1,18 +1,23 @@
|
||||||
import { Theme } from '../../themes/type';
|
import { Theme } from '../../themes/type';
|
||||||
const { camelCaseToDash, formatCssSize } = require('../../string-utils');
|
const { camelCaseToDash, formatCssSize } = require('../../string-utils');
|
||||||
|
|
||||||
// function quoteCssValue(name: string, value: string): string {
|
const isColor = (v: any) => {
|
||||||
// const needsQuote = ['appearance', 'codeMirrorTheme', 'codeThemeCss'].includes(name);
|
return v && typeof v === 'object' && ('color' in v) && ('model' in v) && ('valpha' in v);
|
||||||
// if (needsQuote) return `'${value}'`;
|
};
|
||||||
// return value;
|
|
||||||
// }
|
|
||||||
|
|
||||||
export default function(theme: Theme) {
|
export default function(theme: Theme) {
|
||||||
const lines = [];
|
const lines = [];
|
||||||
lines.push(':root {');
|
lines.push(':root {');
|
||||||
|
|
||||||
for (const name in theme) {
|
const names = Object.keys(theme).sort();
|
||||||
|
|
||||||
|
for (const name of names) {
|
||||||
const value = (theme as any)[name];
|
const value = (theme as any)[name];
|
||||||
|
|
||||||
|
if (typeof value === 'object' && !isColor(value)) continue;
|
||||||
|
if (value === undefined || value === null) continue;
|
||||||
|
if (typeof value === 'number' && isNaN(value)) continue;
|
||||||
|
|
||||||
const newName = `--joplin-${camelCaseToDash(name)}`;
|
const newName = `--joplin-${camelCaseToDash(name)}`;
|
||||||
const formattedValue = typeof value === 'number' && newName.indexOf('opacity') < 0 ? formatCssSize(value) : value;
|
const formattedValue = typeof value === 'number' && newName.indexOf('opacity') < 0 ? formatCssSize(value) : value;
|
||||||
lines.push(`\t${newName}: ${formattedValue};`);
|
lines.push(`\t${newName}: ${formattedValue};`);
|
||||||
|
|
|
@ -3,7 +3,8 @@ import BaseItem from '../../models/BaseItem';
|
||||||
import Note from '../../models/Note';
|
import Note from '../../models/Note';
|
||||||
import { expectNotThrow, expectThrow, setupDatabaseAndSynchronizer, switchClient } from '../../testing/test-utils';
|
import { expectNotThrow, expectThrow, setupDatabaseAndSynchronizer, switchClient } from '../../testing/test-utils';
|
||||||
import time from '../../time';
|
import time from '../../time';
|
||||||
import ItemUploader, { ApiCallFunction } from './ItemUploader';
|
import ItemUploader from './ItemUploader';
|
||||||
|
import { ApiCallFunction } from './utils/types';
|
||||||
|
|
||||||
interface ApiCall {
|
interface ApiCall {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
|
@ -4,11 +4,10 @@ import JoplinError from '../../JoplinError';
|
||||||
import Logger from '../../Logger';
|
import Logger from '../../Logger';
|
||||||
import BaseItem from '../../models/BaseItem';
|
import BaseItem from '../../models/BaseItem';
|
||||||
import { BaseItemEntity } from '../database/types';
|
import { BaseItemEntity } from '../database/types';
|
||||||
|
import { ApiCallFunction } from './utils/types';
|
||||||
|
|
||||||
const logger = Logger.create('ItemUploader');
|
const logger = Logger.create('ItemUploader');
|
||||||
|
|
||||||
export type ApiCallFunction = (fnName: string, ...args: any[])=> Promise<any>;
|
|
||||||
|
|
||||||
interface BatchItem extends MultiPutItem {
|
interface BatchItem extends MultiPutItem {
|
||||||
localItemUpdatedTime: number;
|
localItemUpdatedTime: number;
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ import Folder from '../../models/Folder';
|
||||||
import Note from '../../models/Note';
|
import Note from '../../models/Note';
|
||||||
import BaseItem from '../../models/BaseItem';
|
import BaseItem from '../../models/BaseItem';
|
||||||
import WelcomeUtils from '../../WelcomeUtils';
|
import WelcomeUtils from '../../WelcomeUtils';
|
||||||
|
import { NoteEntity } from '../database/types';
|
||||||
|
|
||||||
describe('Synchronizer.basics', () => {
|
describe('Synchronizer.basics', () => {
|
||||||
|
|
||||||
|
@ -12,6 +13,7 @@ describe('Synchronizer.basics', () => {
|
||||||
await setupDatabaseAndSynchronizer(1);
|
await setupDatabaseAndSynchronizer(1);
|
||||||
await setupDatabaseAndSynchronizer(2);
|
await setupDatabaseAndSynchronizer(2);
|
||||||
await switchClient(1);
|
await switchClient(1);
|
||||||
|
synchronizer().testingHooks_ = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
@ -260,6 +262,25 @@ describe('Synchronizer.basics', () => {
|
||||||
expect(disabledItems.length).toBe(1);
|
expect(disabledItems.length).toBe(1);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
it('should handle items that are read-only on the sync target', (async () => {
|
||||||
|
const folder = await Folder.save({ title: 'folder' });
|
||||||
|
const note = await Note.save({ title: 'un', is_todo: 1, parent_id: folder.id });
|
||||||
|
const noteId = note.id;
|
||||||
|
await synchronizerStart();
|
||||||
|
await Note.save({ id: noteId, title: 'un mod' });
|
||||||
|
synchronizer().testingHooks_ = ['itemIsReadOnly'];
|
||||||
|
await synchronizerStart();
|
||||||
|
synchronizer().testingHooks_ = [];
|
||||||
|
|
||||||
|
const noteReload = await Note.load(note.id);
|
||||||
|
expect(noteReload.title).toBe(note.title);
|
||||||
|
|
||||||
|
const conflictNote: NoteEntity = (await Note.all()).find((n: NoteEntity) => !!n.is_conflict);
|
||||||
|
expect(conflictNote).toBeTruthy();
|
||||||
|
expect(conflictNote.title).toBe('un mod');
|
||||||
|
expect(conflictNote.id).not.toBe(note.id);
|
||||||
|
}));
|
||||||
|
|
||||||
it('should allow duplicate folder titles', (async () => {
|
it('should allow duplicate folder titles', (async () => {
|
||||||
await Folder.save({ title: 'folder' });
|
await Folder.save({ title: 'folder' });
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { Dispatch } from 'redux';
|
||||||
|
import Logger from '../../../Logger';
|
||||||
|
import BaseItem from '../../../models/BaseItem';
|
||||||
|
import ItemChange from '../../../models/ItemChange';
|
||||||
|
import Note from '../../../models/Note';
|
||||||
|
import Resource from '../../../models/Resource';
|
||||||
|
import time from '../../../time';
|
||||||
|
|
||||||
|
const logger = Logger.create('handleConflictAction');
|
||||||
|
|
||||||
|
export type ConflictAction = 'itemConflict' | 'noteConflict' | 'resourceConflict';
|
||||||
|
|
||||||
|
export default async (action: ConflictAction, ItemClass: any, remoteExists: boolean, remoteContent: any, local: any, syncTargetId: number, itemIsReadOnly: boolean, dispatch: Dispatch) => {
|
||||||
|
logger.debug(`Handling conflict: ${action}`);
|
||||||
|
logger.debug('remoteExists:', remoteExists);
|
||||||
|
|
||||||
|
if (action === 'itemConflict') {
|
||||||
|
// ------------------------------------------------------------------------------
|
||||||
|
// For non-note conflicts, we take the remote version (i.e. the version that was
|
||||||
|
// synced first) and overwrite the local content.
|
||||||
|
// ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if (remoteExists) {
|
||||||
|
local = remoteContent;
|
||||||
|
|
||||||
|
const syncTimeQueries = BaseItem.updateSyncTimeQueries(syncTargetId, local, time.unixMs());
|
||||||
|
await ItemClass.save(local, { autoTimestamp: false, changeSource: ItemChange.SOURCE_SYNC, nextQueries: syncTimeQueries });
|
||||||
|
} else {
|
||||||
|
await ItemClass.delete(local.id, {
|
||||||
|
changeSource: ItemChange.SOURCE_SYNC,
|
||||||
|
trackDeleted: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (action === 'noteConflict') {
|
||||||
|
// ------------------------------------------------------------------------------
|
||||||
|
// First find out if the conflict matters. For example, if the conflict is on the title or body
|
||||||
|
// we want to preserve all the changes. If it's on todo_completed it doesn't really matter
|
||||||
|
// so in this case we just take the remote content.
|
||||||
|
// ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let mustHandleConflict = true;
|
||||||
|
if (!itemIsReadOnly && remoteContent) {
|
||||||
|
mustHandleConflict = Note.mustHandleConflict(local, remoteContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------------
|
||||||
|
// Create a duplicate of local note into Conflicts folder
|
||||||
|
// (to preserve the user's changes)
|
||||||
|
// ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if (mustHandleConflict) {
|
||||||
|
await Note.createConflictNote(local, ItemChange.SOURCE_SYNC);
|
||||||
|
}
|
||||||
|
} else if (action === 'resourceConflict') {
|
||||||
|
// ------------------------------------------------------------------------------
|
||||||
|
// Unlike notes we always handle the conflict for resources
|
||||||
|
// ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
await Resource.createConflictResourceNote(local);
|
||||||
|
|
||||||
|
if (remoteExists) {
|
||||||
|
// The local content we have is no longer valid and should be re-downloaded
|
||||||
|
await Resource.setLocalState(local.id, {
|
||||||
|
fetch_status: Resource.FETCH_STATUS_IDLE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch({ type: 'SYNC_CREATED_OR_UPDATED_RESOURCE', id: local.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['noteConflict', 'resourceConflict'].includes(action)) {
|
||||||
|
// ------------------------------------------------------------------------------
|
||||||
|
// For note and resource conflicts, the creation of the conflict item is done
|
||||||
|
// differently. However the way the local content is handled is the same.
|
||||||
|
// Either copy the remote content to local or, if the remote content has
|
||||||
|
// been deleted, delete the local content.
|
||||||
|
// ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if (remoteExists) {
|
||||||
|
local = remoteContent;
|
||||||
|
const syncTimeQueries = BaseItem.updateSyncTimeQueries(syncTargetId, local, time.unixMs());
|
||||||
|
await ItemClass.save(local, { autoTimestamp: false, changeSource: ItemChange.SOURCE_SYNC, nextQueries: syncTimeQueries });
|
||||||
|
|
||||||
|
if (local.encryption_applied) dispatch({ type: 'SYNC_GOT_ENCRYPTED_ITEM' });
|
||||||
|
} else {
|
||||||
|
// Remote no longer exists (note deleted) so delete local one too
|
||||||
|
await ItemClass.delete(local.id, { changeSource: ItemChange.SOURCE_SYNC, trackDeleted: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
|
@ -0,0 +1,5 @@
|
||||||
|
const { Dirnames } = require('./types');
|
||||||
|
|
||||||
|
export default (resourceId: string) => {
|
||||||
|
return `${Dirnames.Resources}/${resourceId}`;
|
||||||
|
};
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { Dispatch } from 'redux';
|
||||||
|
import BaseModel from '../../../BaseModel';
|
||||||
|
import BaseItem from '../../../models/BaseItem';
|
||||||
|
import ItemChange from '../../../models/ItemChange';
|
||||||
|
import Resource from '../../../models/Resource';
|
||||||
|
import time from '../../../time';
|
||||||
|
import resourceRemotePath from './resourceRemotePath';
|
||||||
|
import { ApiCallFunction, LogSyncOperationFunction } from './types';
|
||||||
|
|
||||||
|
export default async (syncTargetId: number, cancelling: boolean, logSyncOperation: LogSyncOperationFunction, apiCall: ApiCallFunction, dispatch: Dispatch) => {
|
||||||
|
const deletedItems = await BaseItem.deletedItems(syncTargetId);
|
||||||
|
for (let i = 0; i < deletedItems.length; i++) {
|
||||||
|
if (cancelling) break;
|
||||||
|
|
||||||
|
const item = deletedItems[i];
|
||||||
|
const path = BaseItem.systemPath(item.item_id);
|
||||||
|
const isResource = item.item_type === BaseModel.TYPE_RESOURCE;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiCall('delete', path);
|
||||||
|
|
||||||
|
if (isResource) {
|
||||||
|
const remoteContentPath = resourceRemotePath(item.item_id);
|
||||||
|
await apiCall('delete', remoteContentPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
logSyncOperation('deleteRemote', null, { id: item.item_id }, 'local has been deleted');
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'isReadOnly') {
|
||||||
|
let remoteContent = await apiCall('get', path);
|
||||||
|
|
||||||
|
if (remoteContent) {
|
||||||
|
remoteContent = await BaseItem.unserialize(remoteContent);
|
||||||
|
const ItemClass = BaseItem.itemClass(item.item_type);
|
||||||
|
let nextQueries = BaseItem.updateSyncTimeQueries(syncTargetId, remoteContent, time.unixMs());
|
||||||
|
|
||||||
|
if (isResource) {
|
||||||
|
nextQueries = nextQueries.concat(Resource.setLocalStateQueries(remoteContent.id, {
|
||||||
|
fetch_status: Resource.FETCH_STATUS_IDLE,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
await ItemClass.save(remoteContent, { isNew: true, autoTimestamp: false, changeSource: ItemChange.SOURCE_SYNC, nextQueries });
|
||||||
|
|
||||||
|
if (isResource) dispatch({ type: 'SYNC_CREATED_OR_UPDATED_RESOURCE', id: remoteContent.id });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await BaseItem.remoteDeletedItem(syncTargetId, item.item_id);
|
||||||
|
}
|
||||||
|
};
|
|
@ -1,6 +1,11 @@
|
||||||
// eslint-disable-next-line import/prefer-default-export
|
import { RemoteItem } from '../../../file-api';
|
||||||
|
|
||||||
export enum Dirnames {
|
export enum Dirnames {
|
||||||
Locks = 'locks',
|
Locks = 'locks',
|
||||||
Resources = '.resource',
|
Resources = '.resource',
|
||||||
Temp = 'temp',
|
Temp = 'temp',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type LogSyncOperationFunction = (action: string, local?: any, remote?: RemoteItem, message?: string, actionCount?: number)=> void;
|
||||||
|
|
||||||
|
export type ApiCallFunction = (fnName: string, ...args: any[])=> Promise<any>;
|
||||||
|
|
|
@ -62,6 +62,7 @@ const md5 = require('md5');
|
||||||
const { S3Client } = require('@aws-sdk/client-s3');
|
const { S3Client } = require('@aws-sdk/client-s3');
|
||||||
const { Dirnames } = require('../services/synchronizer/utils/types');
|
const { Dirnames } = require('../services/synchronizer/utils/types');
|
||||||
import RSA from '../services/e2ee/RSA.node';
|
import RSA from '../services/e2ee/RSA.node';
|
||||||
|
import { State as ShareState } from '../services/share/reducer';
|
||||||
|
|
||||||
// Each suite has its own separate data and temp directory so that multiple
|
// Each suite has its own separate data and temp directory so that multiple
|
||||||
// suites can be run at the same time. suiteName is what is used to
|
// suites can be run at the same time. suiteName is what is used to
|
||||||
|
@ -138,7 +139,7 @@ function setSyncTargetName(name: string) {
|
||||||
syncTargetName_ = name;
|
syncTargetName_ = name;
|
||||||
syncTargetId_ = SyncTargetRegistry.nameToId(syncTargetName_);
|
syncTargetId_ = SyncTargetRegistry.nameToId(syncTargetName_);
|
||||||
sleepTime = syncTargetId_ === SyncTargetRegistry.nameToId('filesystem') ? 1001 : 100;// 400;
|
sleepTime = syncTargetId_ === SyncTargetRegistry.nameToId('filesystem') ? 1001 : 100;// 400;
|
||||||
isNetworkSyncTarget_ = ['nextcloud', 'dropbox', 'onedrive', 'amazon_s3', 'joplinServer'].includes(syncTargetName_);
|
isNetworkSyncTarget_ = ['nextcloud', 'dropbox', 'onedrive', 'amazon_s3', 'joplinServer', 'joplinCloud'].includes(syncTargetName_);
|
||||||
synchronizers_ = [];
|
synchronizers_ = [];
|
||||||
return previousName;
|
return previousName;
|
||||||
}
|
}
|
||||||
|
@ -150,6 +151,7 @@ setSyncTargetName('memory');
|
||||||
// setSyncTargetName('onedrive');
|
// setSyncTargetName('onedrive');
|
||||||
// setSyncTargetName('amazon_s3');
|
// setSyncTargetName('amazon_s3');
|
||||||
// setSyncTargetName('joplinServer');
|
// setSyncTargetName('joplinServer');
|
||||||
|
// setSyncTargetName('joplinCloud');
|
||||||
|
|
||||||
// console.info(`Testing with sync target: ${syncTargetName_}`);
|
// console.info(`Testing with sync target: ${syncTargetName_}`);
|
||||||
|
|
||||||
|
@ -632,7 +634,7 @@ async function initFileApi() {
|
||||||
if (!amazonS3Creds || !amazonS3Creds.credentials) throw new Error(`AWS auth JSON missing in ${amazonS3CredsPath} format should be: { "credentials": { "accessKeyId": "", "secretAccessKey": "", } "bucket": "mybucket", region: "", forcePathStyle: ""}`);
|
if (!amazonS3Creds || !amazonS3Creds.credentials) throw new Error(`AWS auth JSON missing in ${amazonS3CredsPath} format should be: { "credentials": { "accessKeyId": "", "secretAccessKey": "", } "bucket": "mybucket", region: "", forcePathStyle: ""}`);
|
||||||
const api = new S3Client({ region: amazonS3Creds.region, credentials: amazonS3Creds.credentials, s3UseArnRegion: true, forcePathStyle: amazonS3Creds.forcePathStyle, endpoint: amazonS3Creds.endpoint });
|
const api = new S3Client({ region: amazonS3Creds.region, credentials: amazonS3Creds.credentials, s3UseArnRegion: true, forcePathStyle: amazonS3Creds.forcePathStyle, endpoint: amazonS3Creds.endpoint });
|
||||||
fileApi = new FileApi('', new FileApiDriverAmazonS3(api, amazonS3Creds.bucket));
|
fileApi = new FileApi('', new FileApiDriverAmazonS3(api, amazonS3Creds.bucket));
|
||||||
} else if (syncTargetId_ === SyncTargetRegistry.nameToId('joplinServer')) {
|
} else if (syncTargetId_ === SyncTargetRegistry.nameToId('joplinServer') || syncTargetId_ === SyncTargetRegistry.nameToId('joplinCloud')) {
|
||||||
mustRunInBand();
|
mustRunInBand();
|
||||||
|
|
||||||
const joplinServerAuth = JSON.parse(await readCredentialFile('joplin-server-test-units-2.json'));
|
const joplinServerAuth = JSON.parse(await readCredentialFile('joplin-server-test-units-2.json'));
|
||||||
|
@ -838,6 +840,12 @@ function tempFilePath(ext: string) {
|
||||||
return `${Setting.value('tempDir')}/${md5(Date.now() + Math.random())}.${ext}`;
|
return `${Setting.value('tempDir')}/${md5(Date.now() + Math.random())}.${ext}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createTempFile = async (content = '') => {
|
||||||
|
const path = tempFilePath('txt');
|
||||||
|
await fs.writeFile(path, content, 'utf8');
|
||||||
|
return path;
|
||||||
|
};
|
||||||
|
|
||||||
async function createTempDir() {
|
async function createTempDir() {
|
||||||
const tempDirPath = `${baseTempDir}/${uuid.createNano()}`;
|
const tempDirPath = `${baseTempDir}/${uuid.createNano()}`;
|
||||||
await fs.mkdirp(tempDirPath);
|
await fs.mkdirp(tempDirPath);
|
||||||
|
@ -957,4 +965,39 @@ class TestApp extends BaseApplication {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { supportDir, waitForFolderCount, afterAllCleanUp, exportDir, synchronizerStart, afterEachCleanUp, syncTargetName, setSyncTargetName, syncDir, createTempDir, isNetworkSyncTarget, kvStore, expectThrow, logger, expectNotThrow, resourceService, resourceFetcher, tempFilePath, allSyncTargetItemsEncrypted, msleep, setupDatabase, revisionService, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync, checkThrow, encryptionService, loadEncryptionMasterKey, fileContentEqual, decryptionWorker, currentClientId, id, ids, sortedIds, at, createNTestNotes, createNTestFolders, createNTestTags, TestApp };
|
const createTestShareData = (shareId: string): ShareState => {
|
||||||
|
return {
|
||||||
|
processingShareInvitationResponse: false,
|
||||||
|
shares: [],
|
||||||
|
shareInvitations: [
|
||||||
|
{
|
||||||
|
id: '',
|
||||||
|
master_key: {},
|
||||||
|
status: 0,
|
||||||
|
share: {
|
||||||
|
id: shareId,
|
||||||
|
folder_id: '',
|
||||||
|
master_key_id: '',
|
||||||
|
note_id: '',
|
||||||
|
type: 1,
|
||||||
|
},
|
||||||
|
can_read: 1,
|
||||||
|
can_write: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
shareUsers: {},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const simulateReadOnlyShareEnv = (shareId: string) => {
|
||||||
|
Setting.setValue('sync.target', 10);
|
||||||
|
Setting.setValue('sync.userId', 'abcd');
|
||||||
|
BaseItem.syncShareCache = createTestShareData(shareId);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
BaseItem.syncShareCache = null;
|
||||||
|
Setting.setValue('sync.userId', '');
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export { supportDir, createTempFile, createTestShareData, simulateReadOnlyShareEnv, waitForFolderCount, afterAllCleanUp, exportDir, synchronizerStart, afterEachCleanUp, syncTargetName, setSyncTargetName, syncDir, createTempDir, isNetworkSyncTarget, kvStore, expectThrow, logger, expectNotThrow, resourceService, resourceFetcher, tempFilePath, allSyncTargetItemsEncrypted, msleep, setupDatabase, revisionService, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync, checkThrow, encryptionService, loadEncryptionMasterKey, fileContentEqual, decryptionWorker, currentClientId, id, ids, sortedIds, at, createNTestNotes, createNTestFolders, createNTestTags, TestApp };
|
||||||
|
|
|
@ -137,7 +137,7 @@ function addMissingProperties(theme: Theme) {
|
||||||
return theme;
|
return theme;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addExtraStyles(style: any) {
|
export function addExtraStyles(style: any) {
|
||||||
style.selectedDividerColor = Color(style.dividerColor).darken(0.2).hex();
|
style.selectedDividerColor = Color(style.dividerColor).darken(0.2).hex();
|
||||||
style.iconColor = Color(style.color).alpha(0.8);
|
style.iconColor = Color(style.color).alpha(0.8);
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,47 @@
|
||||||
|
# Read-only
|
||||||
|
|
||||||
|
Certain Joplin items can potentially be marked as read-only. The notes, folders and resources support this. Currently, it is used when a Joplin Cloud folder is shared with a disabled "write" permission.
|
||||||
|
|
||||||
|
Support for read-only items is implemented at multiple levels:
|
||||||
|
|
||||||
|
- On Joplin Cloud
|
||||||
|
- On the apps: at the model level
|
||||||
|
- On the apps: at the UI level
|
||||||
|
- On the apps: at the sync level
|
||||||
|
|
||||||
|
## On Joplin Cloud
|
||||||
|
|
||||||
|
Joplin Cloud ensures that it is not possible to write an item to a read-only share, except if you are the share owner. When it is attempted, a 403 Forbidden error is returned along with an error code `{ "code": "isReadOnly" }`.
|
||||||
|
|
||||||
|
## On the applications
|
||||||
|
|
||||||
|
The desktop, mobile and CLI apps support read-only notes, resources or folders.
|
||||||
|
|
||||||
|
### At the sync level
|
||||||
|
|
||||||
|
Because Joplin Cloud can return read-only-specific errors, the synchroniser needs to handle them.
|
||||||
|
|
||||||
|
- If a local read-only item has been modified, and the synchroniser tries to upload it, Joplin Cloud responds with a read-only error. The synchroniser local item is copied to the conflict folder and the remote item overwrites the local one.
|
||||||
|
|
||||||
|
- If a local read-only item has been deleted, and the synchroniser tries to delete the remote one, Joplin Cloud responds with a read-only error. The synchroniser downloads the remote item and restore the local one.
|
||||||
|
|
||||||
|
- If a local item has been added as a child of a read-only folder, and the synchroniser tries to apply the change, Joplin Cloud responds with a read-only error. The local item is copied to the conflict folder and is deleted.
|
||||||
|
|
||||||
|
In theory these errors should never happen since they are prevented at the model and UI level but they are handled anyway because sync would be permanently stuck if it cannot handle a read-only error. Moreover the user local items would be inconsistent with the shared folder, with no way of getting the current data.
|
||||||
|
|
||||||
|
## At the model level
|
||||||
|
|
||||||
|
`lib/models/utils/readOnly.ts` provides a number of utility functions to decide if an item should be considered read-only or not.
|
||||||
|
|
||||||
|
Most of the read-only handling is done in `BaseItem` so the note, folder and resource handling is very similar.
|
||||||
|
|
||||||
|
Four cases are handled:
|
||||||
|
|
||||||
|
- Modifying a read-only item
|
||||||
|
- Deleting a read-only item
|
||||||
|
- Adding an item as a child of a read-only item
|
||||||
|
- Modifying a read-only resource file content
|
||||||
|
|
||||||
|
## At the UI level
|
||||||
|
|
||||||
|
Likewise `readOnly.ts` is used to find out if an item is read-only and to disable menu items, editors, commands, etc.
|
Loading…
Reference in New Issue