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/DialogTitle.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/IconSelector.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/commands/addProfile.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/exportPdf.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/toggleLayoutMoveMode.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/toggleNotesSortOrderReverse.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/components/EncryptionConfigScreen/utils.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.js
|
||||
packages/lib/debug/DebugService.js
|
||||
packages/lib/dom.js
|
||||
packages/lib/dummy.test.js
|
||||
packages/lib/errorUtils.js
|
||||
packages/lib/errors.js
|
||||
packages/lib/eventManager.js
|
||||
packages/lib/file-api-driver-joplinServer.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/paginatedFeed.js
|
||||
packages/lib/models/utils/paginationToSql.js
|
||||
packages/lib/models/utils/readOnly.js
|
||||
packages/lib/models/utils/types.js
|
||||
packages/lib/models/utils/userData.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_MigrationHandler.test.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/resourceRemotePath.js
|
||||
packages/lib/services/synchronizer/utils/syncDeleteStep.js
|
||||
packages/lib/services/synchronizer/utils/types.js
|
||||
packages/lib/shim.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/DialogTitle.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/IconSelector.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/commands/addProfile.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/exportPdf.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/toggleLayoutMoveMode.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/toggleNotesSortOrderReverse.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/components/EncryptionConfigScreen/utils.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.js
|
||||
packages/lib/debug/DebugService.js
|
||||
packages/lib/dom.js
|
||||
packages/lib/dummy.test.js
|
||||
packages/lib/errorUtils.js
|
||||
packages/lib/errors.js
|
||||
packages/lib/eventManager.js
|
||||
packages/lib/file-api-driver-joplinServer.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/paginatedFeed.js
|
||||
packages/lib/models/utils/paginationToSql.js
|
||||
packages/lib/models/utils/readOnly.js
|
||||
packages/lib/models/utils/types.js
|
||||
packages/lib/models/utils/userData.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_MigrationHandler.test.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/resourceRemotePath.js
|
||||
packages/lib/services/synchronizer/utils/syncDeleteStep.js
|
||||
packages/lib/services/synchronizer/utils/types.js
|
||||
packages/lib/shim.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: 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)
|
||||
- [Read-only items](https://github.com/laurent22/joplin/blob/dev/readme/spec/read_only.md)
|
||||
|
||||
- Google Summer of Code 2022
|
||||
|
||||
|
|
|
@ -23,6 +23,6 @@ export const runtime = (): CommandRuntime => {
|
|||
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);
|
||||
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 { stateUtils } from '@joplin/lib/reducer';
|
||||
import { DesktopCommandContext } from '../services/commands/types';
|
||||
import { enabledCondition } from '../gui/NoteEditor/editorCommandDeclarations';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'toggleExternalEditing',
|
||||
|
@ -22,7 +23,7 @@ export const runtime = (): CommandRuntime => {
|
|||
void CommandService.instance().execute('startExternalEditing', noteId);
|
||||
}
|
||||
},
|
||||
enabledCondition: 'oneNoteSelected',
|
||||
enabledCondition: enabledCondition(declaration.name),
|
||||
mapStateToTitle: (state: any) => {
|
||||
const noteId = stateUtils.selectedNoteId(state);
|
||||
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 = {
|
||||
title: folderTitle,
|
||||
icon: Folder.serializeIcon(folderIcon),
|
||||
is_shared: 0,
|
||||
share_id: '',
|
||||
};
|
||||
|
||||
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`
|
||||
import * as addProfile from './addProfile';
|
||||
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 exportPdf from './exportPdf';
|
||||
import * as gotoAnything from './gotoAnything';
|
||||
|
@ -34,6 +37,7 @@ import * as showSpellCheckerMenu from './showSpellCheckerMenu';
|
|||
import * as toggleEditors from './toggleEditors';
|
||||
import * as toggleLayoutMoveMode from './toggleLayoutMoveMode';
|
||||
import * as toggleNoteList from './toggleNoteList';
|
||||
import * as toggleNoteType from './toggleNoteType';
|
||||
import * as toggleNotesSortOrderField from './toggleNotesSortOrderField';
|
||||
import * as toggleNotesSortOrderReverse from './toggleNotesSortOrderReverse';
|
||||
import * as togglePerFolderSortOrder from './togglePerFolderSortOrder';
|
||||
|
@ -43,6 +47,9 @@ import * as toggleVisiblePanes from './toggleVisiblePanes';
|
|||
const index:any[] = [
|
||||
addProfile,
|
||||
commandPalette,
|
||||
deleteFolder,
|
||||
deleteNote,
|
||||
duplicateNote,
|
||||
editAlarm,
|
||||
exportPdf,
|
||||
gotoAnything,
|
||||
|
@ -76,6 +83,7 @@ const index:any[] = [
|
|||
toggleEditors,
|
||||
toggleLayoutMoveMode,
|
||||
toggleNoteList,
|
||||
toggleNoteType,
|
||||
toggleNotesSortOrderField,
|
||||
toggleNotesSortOrderReverse,
|
||||
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);
|
||||
},
|
||||
enabledCondition: '!folderIsReadOnly',
|
||||
};
|
||||
};
|
||||
|
|
|
@ -30,6 +30,6 @@ export const runtime = (): CommandRuntime => {
|
|||
id: newNote.id,
|
||||
});
|
||||
},
|
||||
enabledCondition: 'oneFolderSelected && !inConflictFolder',
|
||||
enabledCondition: 'oneFolderSelected && !inConflictFolder && !folderIsReadOnly',
|
||||
};
|
||||
};
|
||||
|
|
|
@ -13,5 +13,6 @@ export const runtime = (): CommandRuntime => {
|
|||
parentId = parentId || context.state.selectedFolderId;
|
||||
return CommandService.instance().execute('newFolder', parentId);
|
||||
},
|
||||
enabledCondition: '!folderIsReadOnly',
|
||||
};
|
||||
};
|
||||
|
|
|
@ -12,6 +12,6 @@ export const runtime = (): CommandRuntime => {
|
|||
execute: async (_context: CommandContext, body = '') => {
|
||||
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'}
|
||||
codeMirrorTheme={styles.editor.codeMirrorTheme}
|
||||
style={styles.editor}
|
||||
readOnly={props.visiblePanes.indexOf('editor') < 0}
|
||||
readOnly={props.disabled || props.visiblePanes.indexOf('editor') < 0}
|
||||
autoMatchBraces={matchBracesOptions}
|
||||
keyMap={props.keyboardMode}
|
||||
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.">
|
||||
<div style={styles.root} ref={rootRef}>
|
||||
<div style={styles.rowToolbar}>
|
||||
<Toolbar themeId={props.themeId} />
|
||||
<Toolbar themeId={props.themeId}/>
|
||||
{props.noteToolbar}
|
||||
</div>
|
||||
<div style={styles.rowEditorViewer}>
|
||||
|
|
|
@ -167,6 +167,7 @@ function Editor(props: EditorProps, ref: any) {
|
|||
|
||||
const safeOptions: Record<string, any> = {
|
||||
value: props.value,
|
||||
readOnly: props.readOnly,
|
||||
};
|
||||
|
||||
const unsafeOptions: Record<string, any> = {
|
||||
|
|
|
@ -11,6 +11,7 @@ const { buildStyle } = require('@joplin/lib/theme');
|
|||
interface ToolbarProps {
|
||||
themeId: number;
|
||||
toolbarButtonInfos: ToolbarButtonInfo[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function styles_(props: ToolbarProps) {
|
||||
|
@ -28,7 +29,7 @@ const toolbarButtonUtils = new ToolbarButtonUtils(CommandService.instance());
|
|||
|
||||
function Toolbar(props: ToolbarProps) {
|
||||
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) => {
|
||||
|
|
|
@ -31,7 +31,7 @@ import usePrevious from '../hooks/usePrevious';
|
|||
import Setting from '@joplin/lib/models/Setting';
|
||||
import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext';
|
||||
import ExternalEditWatcher from '@joplin/lib/services/ExternalEditWatcher';
|
||||
|
||||
import { itemIsReadOnly } from '@joplin/lib/models/utils/readOnly';
|
||||
const { themeStyle } = require('@joplin/lib/theme');
|
||||
const { substrWithEllipsis } = require('@joplin/lib/string-utils');
|
||||
import NoteSearchBar from '../NoteSearchBar';
|
||||
|
@ -40,6 +40,12 @@ import Note from '@joplin/lib/models/Note';
|
|||
import Folder from '@joplin/lib/models/Folder';
|
||||
const bridge = require('@electron/remote').require('./bridge').default;
|
||||
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 = [
|
||||
require('./commands/showRevisions'),
|
||||
|
@ -51,6 +57,7 @@ function NoteEditor(props: NoteEditorProps) {
|
|||
const [showRevisions, setShowRevisions] = useState(false);
|
||||
const [titleHasBeenManuallyChanged, setTitleHasBeenManuallyChanged] = useState(false);
|
||||
const [scrollWhenReady, setScrollWhenReady] = useState<ScrollOptions>(null);
|
||||
const [isReadOnly, setIsReadOnly] = useState<boolean>(false);
|
||||
|
||||
const editorRef = useRef<any>();
|
||||
const titleInputRef = useRef<any>();
|
||||
|
@ -279,6 +286,23 @@ function NoteEditor(props: NoteEditorProps) {
|
|||
// }
|
||||
// }, [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) => {
|
||||
handleProvisionalFlag();
|
||||
|
||||
|
@ -406,7 +430,7 @@ function NoteEditor(props: NoteEditorProps) {
|
|||
htmlToMarkdown: htmlToMarkdown,
|
||||
markupToHtml: markupToHtml,
|
||||
allAssets: allAssets,
|
||||
disabled: false,
|
||||
disabled: isReadOnly,
|
||||
themeId: props.themeId,
|
||||
dispatch: props.dispatch,
|
||||
noteToolbar: null,
|
||||
|
@ -570,6 +594,7 @@ function NoteEditor(props: NoteEditorProps) {
|
|||
noteTitle={formNote.title}
|
||||
noteUserUpdatedTime={formNote.user_updated_time}
|
||||
onTitleChange={onTitleChange}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
{renderSearchInfo()}
|
||||
<div style={{ display: 'flex', flex: 1, paddingLeft: theme.editorPaddingLeft, maxHeight: '100%' }}>
|
||||
|
@ -629,7 +654,9 @@ const mapStateToProps = (state: AppState) => {
|
|||
], whenClauseContext)[0],
|
||||
contentMaxWidth: state.settings['style.editor.contentMaxWidth'],
|
||||
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;
|
||||
titleInputRef: any;
|
||||
onTitleChange(event: ChangeEvent<HTMLInputElement>): void;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
function styles_(props: Props) {
|
||||
|
@ -98,6 +99,7 @@ export default function NoteTitleBar(props: Props) {
|
|||
return <NoteToolbar
|
||||
themeId={props.themeId}
|
||||
style={styles.toolbarStyle}
|
||||
disabled={props.disabled}
|
||||
/>;
|
||||
}
|
||||
|
||||
|
@ -109,6 +111,7 @@ export default function NoteTitleBar(props: Props) {
|
|||
ref={props.titleInputRef}
|
||||
placeholder={props.isProvisional ? _('Creating new %s...', props.noteIsTodo ? _('to-do') : _('note')) : ''}
|
||||
style={styles.titleInput}
|
||||
readOnly={props.disabled}
|
||||
onChange={props.onTitleChange}
|
||||
onKeyDown={onTitleKeydown}
|
||||
value={props.noteTitle}
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
import { CommandDeclaration } from '@joplin/lib/services/CommandService';
|
||||
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[] = [
|
||||
{
|
||||
|
|
|
@ -7,10 +7,13 @@ const Menu = bridge().Menu;
|
|||
const MenuItem = bridge().MenuItem;
|
||||
import Resource from '@joplin/lib/models/Resource';
|
||||
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 { NoteEntity, ResourceEntity } from '@joplin/lib/services/database/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 { writeFile } = require('fs-extra');
|
||||
const { clipboard } = require('electron');
|
||||
|
@ -50,7 +53,11 @@ export async function openItemById(itemId: string, dispatch: Function, hash = ''
|
|||
}
|
||||
|
||||
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) {
|
||||
console.error(error);
|
||||
bridge().showErrorMessageBox(error.message);
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
import AsyncActionQueue from '@joplin/lib/AsyncActionQueue';
|
||||
import { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import { State as ShareState } from '@joplin/lib/services/share/reducer';
|
||||
import { MarkupLanguage } from '@joplin/renderer';
|
||||
import { RenderResult, RenderResultPluginAsset } from '@joplin/renderer/MarkupToHtml';
|
||||
import { MarkupToHtmlOptions } from './useMarkupToHtml';
|
||||
|
@ -45,6 +46,8 @@ export interface NoteEditorProps {
|
|||
contentMaxWidth: number;
|
||||
isSafeMode: boolean;
|
||||
useCustomPdfViewer: boolean;
|
||||
shareCache: ShareState;
|
||||
syncUserId: string;
|
||||
}
|
||||
|
||||
export interface NoteBodyEditorProps {
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
import { useEffect } from 'react';
|
||||
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 time from '@joplin/lib/time';
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
import { joplinCommandToTinyMceCommands } from '../NoteBody/TinyMCE/utils/joplinCommandToTinyMceCommands';
|
||||
|
||||
const commandsWithDependencies = [
|
||||
require('../commands/showLocalSearch'),
|
||||
|
@ -30,8 +29,6 @@ interface HookDependencies {
|
|||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
function editorCommandRuntime(declaration: CommandDeclaration, editorRef: any, setFormNote: Function): CommandRuntime {
|
||||
const markdownEditorOnly = !Object.keys(joplinCommandToTinyMceCommands).includes(declaration.name);
|
||||
|
||||
return {
|
||||
execute: async (_context: CommandContext, ...args: any[]) => {
|
||||
if (!editorRef.current) {
|
||||
|
@ -73,7 +70,7 @@ function editorCommandRuntime(declaration: CommandDeclaration, editorRef: any, s
|
|||
// currently selected text.
|
||||
//
|
||||
// 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 { _ } from '@joplin/lib/locale';
|
||||
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 Setting from '@joplin/lib/models/Setting';
|
||||
import NoteListItem from '../NoteListItem';
|
||||
|
@ -19,6 +19,9 @@ import Note from '@joplin/lib/models/Note';
|
|||
import Folder from '@joplin/lib/models/Folder';
|
||||
import { Props } from './types';
|
||||
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 = [
|
||||
require('./commands/focusElementNoteList'),
|
||||
|
@ -186,7 +189,7 @@ const NoteListComponent = (props: Props) => {
|
|||
setDragOverTargetNoteIndex(null);
|
||||
|
||||
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);
|
||||
};
|
||||
|
@ -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 = [];
|
||||
|
||||
// Here there is two cases:
|
||||
|
@ -236,13 +241,14 @@ const NoteListComponent = (props: Props) => {
|
|||
if (clickedNoteId) noteIds.push(clickedNoteId);
|
||||
}
|
||||
|
||||
if (!noteIds.length) return;
|
||||
if (!noteIds.length) return false;
|
||||
|
||||
event.dataTransfer.setDragImage(new Image(), 1, 1);
|
||||
event.dataTransfer.clearData();
|
||||
event.dataTransfer.setData('text/x-jop-note-ids', JSON.stringify(noteIds));
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
return true;
|
||||
}, [props.parentFolderIsReadOnly, props.selectedNoteIds]);
|
||||
|
||||
const renderItem = useCallback((item: any, index: number) => {
|
||||
const highlightedWords = () => {
|
||||
|
@ -278,6 +284,7 @@ const NoteListComponent = (props: Props) => {
|
|||
onNoteDragOver={noteItem_noteDragOver}
|
||||
onTitleClick={noteItem_titleClick}
|
||||
onContextMenu={itemContextMenu}
|
||||
draggable={!props.parentFolderIsReadOnly}
|
||||
/>;
|
||||
// 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,
|
||||
|
@ -286,6 +293,7 @@ const NoteListComponent = (props: Props) => {
|
|||
props.searches,
|
||||
props.selectedSearchId,
|
||||
props.highlightedWords,
|
||||
props.parentFolderIsReadOnly,
|
||||
]);
|
||||
|
||||
const previousSelectedNoteIds = usePrevious(props.selectedNoteIds, []);
|
||||
|
@ -393,7 +401,8 @@ const NoteListComponent = (props: Props) => {
|
|||
if (noteIds.length && (keyCode === 46 || (keyCode === 8 && event.metaKey))) {
|
||||
// DELETE / CMD+Backspace
|
||||
event.preventDefault();
|
||||
await NoteListUtils.confirmDeleteNotes(noteIds);
|
||||
void CommandService.instance().execute('deleteNote', noteIds);
|
||||
// await NoteListUtils.confirmDeleteNotes(noteIds);
|
||||
}
|
||||
|
||||
if (noteIds.length && keyCode === 32) {
|
||||
|
@ -541,6 +550,9 @@ const NoteListComponent = (props: Props) => {
|
|||
};
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
const selectedFolder: FolderEntity = state.notesParentType === 'Folder' ? BaseModel.byId(state.folders, state.selectedFolderId) : null;
|
||||
const userId = state.settings['sync.userId'];
|
||||
|
||||
return {
|
||||
notes: state.notes,
|
||||
folders: state.folders,
|
||||
|
@ -560,6 +572,7 @@ const mapStateToProps = (state: AppState) => {
|
|||
plugins: state.pluginService.plugins,
|
||||
customCss: state.customCss,
|
||||
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[];
|
||||
visible: boolean;
|
||||
focusedField: string;
|
||||
parentFolderIsReadOnly: boolean;
|
||||
}
|
||||
|
|
|
@ -9,7 +9,8 @@ import Note from '@joplin/lib/models/Note';
|
|||
import { notesSortOrderNextField } from '../../services/sortOrder/notesSortOrderUtils';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
const { connect } = require('react-redux');
|
||||
const styled = require('styled-components').default;
|
||||
import styled from 'styled-components';
|
||||
import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext';
|
||||
|
||||
enum BaseBreakpoint {
|
||||
Sm = 75,
|
||||
|
@ -27,6 +28,8 @@ interface Props {
|
|||
height: number;
|
||||
width: number;
|
||||
onContentHeightChange: (sameRow: boolean)=> void;
|
||||
newNoteButtonEnabled: boolean;
|
||||
newTodoButtonEnabled: boolean;
|
||||
}
|
||||
|
||||
interface Breakpoints {
|
||||
|
@ -255,6 +258,7 @@ function NoteListControls(props: Props) {
|
|||
level={ButtonLevel.Primary}
|
||||
size={ButtonSize.Small}
|
||||
onClick={onNewNoteButtonClick}
|
||||
disabled={!props.newNoteButtonEnabled}
|
||||
/>
|
||||
<StyledButton ref={newTodoRef}
|
||||
className="new-todo-button"
|
||||
|
@ -264,6 +268,7 @@ function NoteListControls(props: Props) {
|
|||
level={ButtonLevel.Secondary}
|
||||
size={ButtonSize.Small}
|
||||
onClick={onNewTodoButtonClick}
|
||||
disabled={!props.newTodoButtonEnabled}
|
||||
/>
|
||||
</TopRow>
|
||||
);
|
||||
|
@ -300,9 +305,13 @@ function NoteListControls(props: Props) {
|
|||
}
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
const whenClauseContext = stateToWhenClauseContext(state);
|
||||
|
||||
return {
|
||||
// TODO: showNewNoteButtons and the logic associated is not needed anymore.
|
||||
showNewNoteButtons: true,
|
||||
newNoteButtonEnabled: CommandService.instance().isEnabled('newNote', whenClauseContext),
|
||||
newTodoButtonEnabled: CommandService.instance().isEnabled('newTodo', whenClauseContext),
|
||||
sortOrderButtonsVisible: state.settings['notes.sortOrder.buttonsVisible'],
|
||||
sortOrderField: state.settings['notes.sortOrder.field'],
|
||||
sortOrderReverse: state.settings['notes.sortOrder.reverse'],
|
||||
|
|
|
@ -58,6 +58,7 @@ interface NoteListItemProps {
|
|||
onNoteDragOver: any;
|
||||
onTitleClick: any;
|
||||
onContextMenu(event: React.MouseEvent<HTMLAnchorElement, MouseEvent>): void;
|
||||
draggable: boolean;
|
||||
}
|
||||
|
||||
function NoteListItem(props: NoteListItemProps, ref: any) {
|
||||
|
@ -185,7 +186,7 @@ function NoteListItem(props: NoteListItemProps, ref: any) {
|
|||
ref={anchorRef}
|
||||
onContextMenu={props.onContextMenu}
|
||||
href="#"
|
||||
draggable={true}
|
||||
draggable={props.draggable}
|
||||
style={listItemTitleStyle}
|
||||
onClick={onTitleClick}
|
||||
onDragStart={props.onDragStart}
|
||||
|
|
|
@ -11,6 +11,7 @@ interface NoteToolbarProps {
|
|||
themeId: number;
|
||||
style: any;
|
||||
toolbarButtonInfos: ToolbarButtonInfo[];
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
function styles_(props: NoteToolbarProps) {
|
||||
|
@ -27,7 +28,7 @@ function styles_(props: NoteToolbarProps) {
|
|||
|
||||
function NoteToolbar(props: NoteToolbarProps) {
|
||||
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());
|
||||
|
|
|
@ -2,7 +2,7 @@ import Dialog from '../Dialog';
|
|||
import DialogButtonRow, { ClickEvent, ButtonSpec } from '../DialogButtonRow';
|
||||
import DialogTitle from '../DialogTitle';
|
||||
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 Folder from '@joplin/lib/models/Folder';
|
||||
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 Logger from '@joplin/lib/Logger';
|
||||
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 { connect } from 'react-redux';
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
import useAsyncEffect, { AsyncEffectEvent } from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import { ChangeEvent, Dropdown, DropdownOptions, DropdownVariant } from '../Dropdown/Dropdown';
|
||||
|
||||
const logger = Logger.create('ShareFolderDialog');
|
||||
|
||||
|
@ -108,13 +109,20 @@ enum ShareState {
|
|||
}
|
||||
|
||||
function ShareFolderDialog(props: Props) {
|
||||
const permissionOptions: DropdownOptions = {
|
||||
'can_read': _('Can view'),
|
||||
'can_read_and_write': _('Can edit'),
|
||||
};
|
||||
|
||||
const [folder, setFolder] = useState<FolderEntity>(null);
|
||||
const [recipientEmail, setRecipientEmail] = useState<string>('');
|
||||
const [recipientPermissions, setRecipientPermissions] = useState<string>('can_read');
|
||||
const [latestError, setLatestError] = useState<Error>(null);
|
||||
const [share, setShare] = useState<StateShare>(null);
|
||||
const [shareUsers, setShareUsers] = useState<StateShareUser[]>([]);
|
||||
const [shareState, setShareState] = useState<ShareState>(ShareState.Idle);
|
||||
const [customButtons, setCustomButtons] = useState<ButtonSpec[]>([]);
|
||||
const [recipientsBeingUpdated, setRecipientsBeingUpdated] = useState<Record<string, boolean>>({});
|
||||
|
||||
async function synchronize(event: AsyncEffectEvent = null) {
|
||||
setShareState(ShareState.Synchronizing);
|
||||
|
@ -166,7 +174,14 @@ function ShareFolderDialog(props: Props) {
|
|||
void ShareService.instance().refreshShares();
|
||||
}, [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);
|
||||
setLatestError(null);
|
||||
|
||||
|
@ -192,7 +207,7 @@ function ShareFolderDialog(props: Props) {
|
|||
}
|
||||
|
||||
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) {
|
||||
// Handle the error but continue the process because we need to at
|
||||
// least refresh the shares since one has been created above.
|
||||
|
@ -212,7 +227,7 @@ function ShareFolderDialog(props: Props) {
|
|||
} finally {
|
||||
defer(null);
|
||||
}
|
||||
}
|
||||
}, [recipientPermissions, props.folderId, recipientEmail]);
|
||||
|
||||
function recipientEmail_change(event: any) {
|
||||
setRecipientEmail(event.target.value);
|
||||
|
@ -239,19 +254,41 @@ function ShareFolderDialog(props: Props) {
|
|||
);
|
||||
}
|
||||
|
||||
const recipientPermissions_change = useCallback((event: ChangeEvent) => {
|
||||
setRecipientPermissions(event.value);
|
||||
}, []);
|
||||
|
||||
function renderAddRecipient() {
|
||||
const disabled = shareState !== ShareState.Idle;
|
||||
|
||||
return (
|
||||
<StyledAddRecipient>
|
||||
<StyledFormLabel>{_('Add recipient:')}</StyledFormLabel>
|
||||
<StyledRecipientControls>
|
||||
<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>
|
||||
</StyledRecipientControls>
|
||||
</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) {
|
||||
const statusToIcon = {
|
||||
[ShareUserStatus.Waiting]: 'fas fa-question',
|
||||
|
@ -265,11 +302,15 @@ function ShareFolderDialog(props: Props) {
|
|||
[ShareUserStatus.Accepted]: _('Recipient has accepted the invitation'),
|
||||
};
|
||||
|
||||
const permission = shareUser.can_write ? 'can_read_and_write' : 'can_read';
|
||||
const enabled = !recipientsBeingUpdated[shareUser.id];
|
||||
|
||||
return (
|
||||
<StyledRecipient key={shareUser.user.email} index={index}>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
@ -335,7 +376,7 @@ function ShareFolderDialog(props: Props) {
|
|||
|
||||
function renderContent() {
|
||||
return (
|
||||
<StyledRoot>
|
||||
<StyledRoot className="share-folder-dialog">
|
||||
<DialogTitle title={_('Share Notebook')}/>
|
||||
{renderFolder()}
|
||||
{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();
|
||||
|
||||
let deleteMessage = '';
|
||||
let deleteButtonLabel = _('Remove');
|
||||
if (itemType === BaseModel.TYPE_FOLDER) {
|
||||
const folder = await Folder.load(itemId);
|
||||
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 deleteButtonLabel = _('Remove');
|
||||
|
||||
if (itemType === BaseModel.TYPE_TAG) {
|
||||
const tag = await Tag.load(itemId);
|
||||
deleteMessage = _('Remove tag "%s" from all notes?', substrWithEllipsis(tag.title, 0, 32));
|
||||
} else if (itemType === BaseModel.TYPE_SEARCH) {
|
||||
|
@ -321,29 +318,33 @@ const SidebarComponent = (props: Props) => {
|
|||
);
|
||||
}
|
||||
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: deleteButtonLabel,
|
||||
click: async () => {
|
||||
const ok = bridge().showConfirmMessageBox(deleteMessage, {
|
||||
buttons: [deleteButtonLabel, _('Cancel')],
|
||||
defaultId: 1,
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
if (itemType === BaseModel.TYPE_FOLDER) {
|
||||
await Folder.delete(itemId);
|
||||
} else 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) {
|
||||
menu.append(
|
||||
new MenuItem(menuUtils.commandToStatefulMenuItem('deleteFolder', itemId))
|
||||
);
|
||||
} else {
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: deleteButtonLabel,
|
||||
click: async () => {
|
||||
const ok = bridge().showConfirmMessageBox(deleteMessage, {
|
||||
buttons: [deleteButtonLabel, _('Cancel')],
|
||||
defaultId: 1,
|
||||
});
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
if (itemType === BaseModel.TYPE_TAG) {
|
||||
await Tag.untagAll(itemId);
|
||||
} else if (itemType === BaseModel.TYPE_SEARCH) {
|
||||
props.dispatch({
|
||||
type: 'SEARCH_DELETE',
|
||||
id: itemId,
|
||||
});
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied) {
|
||||
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('openFolderDialog', { folderId: itemId })));
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import useAsyncEffect, { AsyncEffectEvent } from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import themeToCss from '@joplin/lib/services/style/themeToCss';
|
||||
import { themeById } from '@joplin/lib/theme';
|
||||
import { addExtraStyles, themeById } from '@joplin/lib/theme';
|
||||
|
||||
interface Props {
|
||||
themeId: any;
|
||||
|
@ -21,7 +21,7 @@ export default function(props: Props): any {
|
|||
const [styleSheetContent, setStyleSheetContent] = useState('');
|
||||
|
||||
useAsyncEffect(async (event: AsyncEffectEvent) => {
|
||||
const theme = themeById(props.themeId);
|
||||
const theme = addExtraStyles(themeById(props.themeId));
|
||||
const themeCss = themeToCss(theme);
|
||||
if (event.cancelled) return;
|
||||
setStyleSheetContent(themeCss);
|
||||
|
|
|
@ -9,6 +9,7 @@ interface Props {
|
|||
themeId: number;
|
||||
style: any;
|
||||
items: any[];
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
class ToolbarBaseComponent extends React.Component<Props, any> {
|
||||
|
@ -46,6 +47,7 @@ class ToolbarBaseComponent extends React.Component<Props, any> {
|
|||
const props = {
|
||||
key: key,
|
||||
themeId: this.props.themeId,
|
||||
disabled: this.props.disabled,
|
||||
...o,
|
||||
};
|
||||
|
||||
|
|
|
@ -53,17 +53,7 @@ export default class NoteListUtils {
|
|||
);
|
||||
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
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),
|
||||
});
|
||||
}
|
||||
},
|
||||
})
|
||||
new MenuItem(menuUtils.commandToStatefulMenuItem('duplicateNote', noteIds))
|
||||
);
|
||||
|
||||
if (singleNoteId) {
|
||||
|
@ -73,22 +63,9 @@ export default class NoteListUtils {
|
|||
|
||||
if (noteIds.length <= 1) {
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: _('Switch between note and to-do type'),
|
||||
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 });
|
||||
}
|
||||
},
|
||||
})
|
||||
new MenuItem(
|
||||
menuUtils.commandToStatefulMenuItem('toggleNoteType', noteIds)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
const switchNoteType = async (noteIds: string[], type: string) => {
|
||||
|
@ -189,12 +166,9 @@ export default class NoteListUtils {
|
|||
}
|
||||
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: _('Delete'),
|
||||
click: async () => {
|
||||
await this.confirmDeleteNotes(noteIds);
|
||||
},
|
||||
})
|
||||
new MenuItem(
|
||||
menuUtils.commandToStatefulMenuItem('deleteNote', noteIds)
|
||||
)
|
||||
);
|
||||
|
||||
const pluginViewInfos = pluginUtils.viewInfosByType(props.plugins, 'menuItem');
|
||||
|
@ -213,19 +187,4 @@ export default class NoteListUtils {
|
|||
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/EncryptionConfigScreen/style.scss' as encryption-config-screen;
|
||||
@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;
|
|
@ -342,6 +342,8 @@ export function initCodeMirror(
|
|||
|
||||
...defaultKeymap, ...historyKeymap, indentWithTab, ...searchKeymap,
|
||||
]),
|
||||
|
||||
EditorState.readOnly.of(settings.readOnly),
|
||||
],
|
||||
doc: initialText,
|
||||
}),
|
||||
|
|
|
@ -31,6 +31,7 @@ interface MarkdownToolbarProps {
|
|||
editorSettings: EditorSettings;
|
||||
onAttach: OnAttachCallback;
|
||||
style?: ViewStyle;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
||||
|
@ -38,6 +39,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
|||
const styles = useStyles(props.style, themeData);
|
||||
const selState = props.selectionState;
|
||||
const editorControl = props.editorControl;
|
||||
const readOnly = props.readOnly;
|
||||
|
||||
const headerButtons: ButtonSpec[] = [];
|
||||
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
|
||||
// the others.
|
||||
priority: level < 3 ? 2 : 0,
|
||||
disabled: readOnly,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -73,6 +76,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
|||
}, [editorControl]),
|
||||
|
||||
priority: -2,
|
||||
disabled: readOnly,
|
||||
});
|
||||
|
||||
listButtons.push({
|
||||
|
@ -86,6 +90,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
|||
}, [editorControl]),
|
||||
|
||||
priority: -2,
|
||||
disabled: readOnly,
|
||||
});
|
||||
|
||||
listButtons.push({
|
||||
|
@ -99,6 +104,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
|||
}, [editorControl]),
|
||||
|
||||
priority: -2,
|
||||
disabled: readOnly,
|
||||
});
|
||||
|
||||
|
||||
|
@ -110,6 +116,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
|||
onPress: editorControl.decreaseIndent,
|
||||
|
||||
priority: -1,
|
||||
disabled: readOnly,
|
||||
});
|
||||
|
||||
listButtons.push({
|
||||
|
@ -120,6 +127,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
|||
onPress: editorControl.increaseIndent,
|
||||
|
||||
priority: -1,
|
||||
disabled: readOnly,
|
||||
});
|
||||
|
||||
|
||||
|
@ -134,6 +142,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
|||
onPress: editorControl.toggleBolded,
|
||||
|
||||
priority: 3,
|
||||
disabled: readOnly,
|
||||
});
|
||||
|
||||
inlineFormattingBtns.push({
|
||||
|
@ -145,6 +154,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
|||
onPress: editorControl.toggleItalicized,
|
||||
|
||||
priority: 2,
|
||||
disabled: readOnly,
|
||||
});
|
||||
|
||||
inlineFormattingBtns.push({
|
||||
|
@ -154,6 +164,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
|||
onPress: editorControl.toggleCode,
|
||||
|
||||
priority: 2,
|
||||
disabled: readOnly,
|
||||
});
|
||||
|
||||
if (props.editorSettings.katexEnabled) {
|
||||
|
@ -164,6 +175,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
|||
onPress: editorControl.toggleMath,
|
||||
|
||||
priority: 1,
|
||||
disabled: readOnly,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -176,6 +188,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
|||
onPress: editorControl.showLinkDialog,
|
||||
|
||||
priority: -3,
|
||||
disabled: readOnly,
|
||||
});
|
||||
|
||||
|
||||
|
@ -189,6 +202,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
|||
onPress: useCallback(() => {
|
||||
editorControl.insertText(time.formatDateToLocal(new Date()));
|
||||
}, [editorControl]),
|
||||
disabled: readOnly,
|
||||
});
|
||||
|
||||
const onDismissKeyboard = useCallback(() => {
|
||||
|
@ -208,6 +222,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
|||
onDismissKeyboard();
|
||||
props.onAttach();
|
||||
}, [props.onAttach, onDismissKeyboard]),
|
||||
disabled: readOnly,
|
||||
});
|
||||
|
||||
actionButtons.push({
|
||||
|
@ -227,6 +242,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
|||
}, [editorControl, props.searchState.dialogVisible]),
|
||||
|
||||
priority: -3,
|
||||
disabled: readOnly,
|
||||
});
|
||||
|
||||
const [keyboardVisible, setKeyboardVisible] = useState(false);
|
||||
|
|
|
@ -31,6 +31,7 @@ interface Props {
|
|||
style: ViewStyle;
|
||||
contentStyle?: ViewStyle;
|
||||
toolbarEnabled: boolean;
|
||||
readOnly: boolean;
|
||||
|
||||
onChange: ChangeEventHandler;
|
||||
onSelectionChange: SelectionChangeEventHandler;
|
||||
|
@ -226,6 +227,7 @@ function NoteEditor(props: Props, ref: any) {
|
|||
themeData: editorTheme(props.themeId),
|
||||
katexEnabled: Setting.value('markdown.plugin.katex'),
|
||||
spellcheckEnabled: Setting.value('editor.mobile.spellcheckEnabled'),
|
||||
readOnly: props.readOnly,
|
||||
};
|
||||
|
||||
const injectedJavaScript = `
|
||||
|
@ -377,6 +379,7 @@ function NoteEditor(props: Props, ref: any) {
|
|||
selectionState={selectionState}
|
||||
searchState={searchState}
|
||||
onAttach={props.onAttach}
|
||||
readOnly={props.readOnly}
|
||||
/>;
|
||||
|
||||
// - `scrollEnabled` prevents iOS from scrolling the document (has no effect on Android)
|
||||
|
|
|
@ -19,6 +19,7 @@ export interface EditorSettings {
|
|||
|
||||
katexEnabled: boolean;
|
||||
spellcheckEnabled: boolean;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
export interface ChangeEvent {
|
||||
|
|
|
@ -7,7 +7,7 @@ const Icon = require('react-native-vector-icons/Ionicons').default;
|
|||
const { BackButtonService } = require('../services/back-button.js');
|
||||
import NavService from '@joplin/lib/services/NavService';
|
||||
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 Note from '@joplin/lib/models/Note';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
|
@ -40,10 +40,11 @@ interface NavButtonPressEvent {
|
|||
screen: string;
|
||||
}
|
||||
|
||||
interface MenuOptionType {
|
||||
export interface MenuOptionType {
|
||||
onPress: OnPressCallback;
|
||||
isDivider?: boolean;
|
||||
title: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
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.flex = 1;
|
||||
styleObject.topIcon.textAlignVertical = 'center';
|
||||
|
@ -240,11 +246,15 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
|
|||
private async duplicateButton_press() {
|
||||
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' });
|
||||
|
||||
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() {
|
||||
|
@ -259,7 +269,12 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
|
|||
if (!ok) return;
|
||||
|
||||
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) {
|
||||
|
@ -470,8 +485,8 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
|
|||
menuOptionComponents.push(<View key={`menuOption_${key++}`} style={this.styles().divider} />);
|
||||
} else {
|
||||
menuOptionComponents.push(
|
||||
<MenuOption value={o.onPress} key={`menuOption_${key++}`} style={this.styles().contextMenuItem}>
|
||||
<Text style={this.styles().contextMenuItemText}>{o.title}</Text>
|
||||
<MenuOption value={o.onPress} key={`menuOption_${key++}`} style={this.styles().contextMenuItem} disabled={!!o.disabled}>
|
||||
<Text style={o.disabled ? this.styles().contextMenuItemTextDisabled : this.styles().contextMenuItemText}>{o.title}</Text>
|
||||
</MenuOption>
|
||||
);
|
||||
}
|
||||
|
@ -523,8 +538,13 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
|
|||
if (!ok) return;
|
||||
|
||||
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}
|
||||
|
|
|
@ -25,7 +25,7 @@ import BaseModel from '@joplin/lib/BaseModel';
|
|||
import ActionButton from '../ActionButton';
|
||||
const { fileExtension, safeFileExtension } = require('@joplin/lib/path-utils');
|
||||
const mimeUtils = require('@joplin/lib/mime-utils.js').mime;
|
||||
import ScreenHeader from '../ScreenHeader';
|
||||
import ScreenHeader, { MenuOptionType } from '../ScreenHeader';
|
||||
const NoteTagsDialog = require('./NoteTagsDialog');
|
||||
import time from '@joplin/lib/time';
|
||||
const { Checkbox } = require('../checkbox.js');
|
||||
|
@ -960,13 +960,14 @@ class NoteScreenComponent extends BaseScreenComponent {
|
|||
const note = this.state.note;
|
||||
const isTodo = note && !!note.is_todo;
|
||||
const isSaved = note && note.id;
|
||||
const readOnly = this.state.readOnly;
|
||||
|
||||
const cacheKey = md5([isTodo, isSaved].join('_'));
|
||||
if (!this.menuOptionsCache_) this.menuOptionsCache_ = {};
|
||||
|
||||
if (this.menuOptionsCache_[cacheKey]) return this.menuOptionsCache_[cacheKey];
|
||||
|
||||
const output = [];
|
||||
const output: MenuOptionType[] = [];
|
||||
|
||||
// The file attachement modules only work in Android >= 5 (Version 21)
|
||||
// https://github.com/react-community/react-native-image-picker/issues/606
|
||||
|
@ -981,6 +982,7 @@ class NoteScreenComponent extends BaseScreenComponent {
|
|||
output.push({
|
||||
title: _('Attach...'),
|
||||
onPress: () => this.showAttachMenu(),
|
||||
disabled: readOnly,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -990,6 +992,7 @@ class NoteScreenComponent extends BaseScreenComponent {
|
|||
onPress: () => {
|
||||
this.setState({ alarmDialogShown: true });
|
||||
},
|
||||
disabled: readOnly,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -998,6 +1001,7 @@ class NoteScreenComponent extends BaseScreenComponent {
|
|||
onPress: () => {
|
||||
void this.share_onPress();
|
||||
},
|
||||
disabled: readOnly,
|
||||
});
|
||||
|
||||
// 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.setState({ voiceTypingDialogShown: true });
|
||||
},
|
||||
disabled: readOnly,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -1024,6 +1029,7 @@ class NoteScreenComponent extends BaseScreenComponent {
|
|||
onPress: () => {
|
||||
this.toggleIsTodo_onPress();
|
||||
},
|
||||
disabled: readOnly,
|
||||
});
|
||||
if (isSaved) {
|
||||
output.push({
|
||||
|
@ -1044,6 +1050,7 @@ class NoteScreenComponent extends BaseScreenComponent {
|
|||
onPress: () => {
|
||||
void this.deleteNote_onPress();
|
||||
},
|
||||
disabled: readOnly,
|
||||
});
|
||||
|
||||
this.menuOptionsCache_ = {};
|
||||
|
@ -1106,7 +1113,7 @@ class NoteScreenComponent extends BaseScreenComponent {
|
|||
|
||||
public folderPickerOptions() {
|
||||
const options = {
|
||||
enabled: true,
|
||||
enabled: !this.state.readOnly,
|
||||
selectedFolderId: this.state.folder ? this.state.folder.id : null,
|
||||
onValueChange: this.folderPickerOptions_valueChanged,
|
||||
};
|
||||
|
@ -1244,6 +1251,7 @@ class NoteScreenComponent extends BaseScreenComponent {
|
|||
onSelectionChange={this.body_selectionChange}
|
||||
onUndoRedoDepthChange={this.onUndoRedoDepthChange}
|
||||
onAttach={() => this.showAttachMenu()}
|
||||
readOnly={this.state.readOnly}
|
||||
style={{
|
||||
...editorStyle,
|
||||
paddingLeft: 0,
|
||||
|
@ -1300,6 +1308,7 @@ class NoteScreenComponent extends BaseScreenComponent {
|
|||
keyboardAppearance={theme.keyboardAppearance}
|
||||
placeholder={_('Add title')}
|
||||
placeholderTextColor={theme.colorFaded}
|
||||
editable={!this.state.readOnly}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
@ -1326,6 +1335,7 @@ class NoteScreenComponent extends BaseScreenComponent {
|
|||
undoButtonDisabled={!this.state.undoRedoButtonState.canUndo && this.state.undoRedoButtonState.canRedo}
|
||||
onUndoButtonPress={this.screenHeader_undoButtonPress}
|
||||
onRedoButtonPress={this.screenHeader_redoButtonPress}
|
||||
title={this.state.folder ? this.state.folder.title : ''}
|
||||
/>
|
||||
{titleComp}
|
||||
{bodyComponent}
|
||||
|
|
|
@ -150,16 +150,20 @@ class NotesScreenComponent extends BaseScreenComponent<any> {
|
|||
}
|
||||
|
||||
public newNoteNavigate = async (folderId: string, isTodo: boolean) => {
|
||||
const newNote = await Note.save({
|
||||
parent_id: folderId,
|
||||
is_todo: isTodo ? 1 : 0,
|
||||
}, { provisional: true });
|
||||
try {
|
||||
const newNote = await Note.save({
|
||||
parent_id: folderId,
|
||||
is_todo: isTodo ? 1 : 0,
|
||||
}, { provisional: true });
|
||||
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Note',
|
||||
noteId: newNote.id,
|
||||
});
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Note',
|
||||
noteId: newNote.id,
|
||||
});
|
||||
} catch (error) {
|
||||
alert(_('Cannot create a new note: %s', error.message));
|
||||
}
|
||||
};
|
||||
|
||||
public parentItem(props: any = null) {
|
||||
|
|
|
@ -477,7 +477,7 @@ PODS:
|
|||
- React-Core
|
||||
- RNGestureHandler (2.10.2):
|
||||
- React-Core
|
||||
- RNLocalize (3.0.0):
|
||||
- RNLocalize (3.0.1):
|
||||
- React-Core
|
||||
- RNQuickAction (0.3.13):
|
||||
- React
|
||||
|
@ -855,7 +855,7 @@ SPEC CHECKSUMS:
|
|||
RNFileViewer: ce7ca3ac370e18554d35d6355cffd7c30437c592
|
||||
RNFS: 4ac0f0ea233904cb798630b3c077808c06931688
|
||||
RNGestureHandler: f75d81410b40aaa99e71ae8f8bb7a88620c95042
|
||||
RNLocalize: 5944c97d2fe8150913a51ddd5eab4e23a82bd80d
|
||||
RNLocalize: 6dd9226886fa61bf0cefc7644e3f9620770b1a31
|
||||
RNQuickAction: 6d404a869dc872cde841ad3147416a670d13fa93
|
||||
RNReanimated: 9976fbaaeb8a188c36026154c844bf374b3b7eeb
|
||||
RNSecureRandom: 07efbdf2cd99efe13497433668e54acd7df49fef
|
||||
|
|
|
@ -117,6 +117,7 @@ import sensorInfo, { SensorInfo } from './components/biometrics/sensorInfo';
|
|||
import { getCurrentProfile } from '@joplin/lib/services/profileConfig';
|
||||
import { getDatabaseName, getProfilesRootDir, getResourceDir, setDispatch } from './services/profiles';
|
||||
import { ReactNode } from 'react';
|
||||
import { parseShareCache } from '@joplin/lib/services/share/reducer';
|
||||
|
||||
type SideMenuPosition = 'left' | 'right';
|
||||
|
||||
|
@ -516,6 +517,7 @@ async function initialize(dispatch: Function) {
|
|||
if (!Setting.value('clientId')) Setting.setValue('clientId', uuid.create());
|
||||
reg.logger().info(`Client ID: ${Setting.value('clientId')}`);
|
||||
|
||||
BaseItem.syncShareCache = parseShareCache(Setting.value('sync.shareCache'));
|
||||
|
||||
if (Setting.value('firstStart')) {
|
||||
const detectedLocale = shim.detectAndSetLocale(Setting);
|
||||
|
|
|
@ -58,6 +58,7 @@ import RSA from './services/e2ee/RSA.node';
|
|||
import Resource from './models/Resource';
|
||||
import { ProfileConfig } from './services/profileConfig/types';
|
||||
import initProfile from './services/profileConfig/initProfile';
|
||||
import { parseShareCache } from './services/share/reducer';
|
||||
|
||||
import RotatingLogs from './RotatingLogs';
|
||||
|
||||
|
@ -840,6 +841,8 @@ export default class BaseApplication {
|
|||
|
||||
appLogger.info(`Client ID: ${Setting.value('clientId')}`);
|
||||
|
||||
BaseItem.syncShareCache = parseShareCache(Setting.value('sync.shareCache'));
|
||||
|
||||
if (initArgs?.isSafeMode) {
|
||||
Setting.setValue('isSafeMode', true);
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import Database from './database';
|
|||
import uuid from './uuid';
|
||||
import time from './time';
|
||||
import JoplinDatabase, { TableField } from './JoplinDatabase';
|
||||
import { LoadOptions } from './models/utils/types';
|
||||
import { LoadOptions, SaveOptions } from './models/utils/types';
|
||||
const Mutex = require('async-mutex').Mutex;
|
||||
|
||||
// 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
|
||||
// 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
|
||||
|
@ -546,7 +546,8 @@ class BaseModel {
|
|||
const mutexRelease = await this.saveMutex(o).acquire();
|
||||
|
||||
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,
|
||||
// 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 { generateKeyPair } from './services/e2ee/ppk';
|
||||
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 { Dirnames } = require('./services/synchronizer/utils/types');
|
||||
|
||||
|
@ -404,10 +408,6 @@ export default class Synchronizer {
|
|||
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
|
||||
// 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
|
||||
|
@ -425,8 +425,20 @@ export default class Synchronizer {
|
|||
|
||||
// Before synchronising make sure all share_id properties are set
|
||||
// correctly so as to share/unshare the right items.
|
||||
await Folder.updateAllShareIds(this.resourceService());
|
||||
if (this.shareService_) await this.shareService_.checkShareConsistency();
|
||||
try {
|
||||
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);
|
||||
|
||||
|
@ -515,22 +527,17 @@ export default class Synchronizer {
|
|||
// ========================================================================
|
||||
|
||||
if (syncSteps.indexOf('delete_remote') >= 0) {
|
||||
const deletedItems = await BaseItem.deletedItems(syncTargetId);
|
||||
for (let i = 0; i < deletedItems.length; i++) {
|
||||
if (this.cancelling()) break;
|
||||
|
||||
const item = deletedItems[i];
|
||||
const path = BaseItem.systemPath(item.item_id);
|
||||
this.logSyncOperation('deleteRemote', null, { id: item.item_id }, 'local has been deleted');
|
||||
await this.apiCall('delete', path);
|
||||
|
||||
if (item.item_type === BaseModel.TYPE_RESOURCE) {
|
||||
const remoteContentPath = resourceRemotePath(item.item_id);
|
||||
await this.apiCall('delete', remoteContentPath);
|
||||
}
|
||||
|
||||
await BaseItem.remoteDeletedItem(syncTargetId, item.item_id);
|
||||
}
|
||||
await syncDeleteStep(
|
||||
syncTargetId,
|
||||
this.cancelling(),
|
||||
(action, local, logSyncOperation, message, actionCount) => {
|
||||
this.logSyncOperation(action, local, logSyncOperation, message, actionCount);
|
||||
},
|
||||
(fnName, ...args) => {
|
||||
return this.apiCall(fnName, ...args);
|
||||
},
|
||||
action => { return this.dispatch(action); }
|
||||
);
|
||||
} // 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);
|
||||
let action = null;
|
||||
|
||||
let itemIsReadOnly = false;
|
||||
let reason = '';
|
||||
let remoteContent = null;
|
||||
|
||||
|
@ -698,6 +705,10 @@ export default class Synchronizer {
|
|||
if (isCannotSyncError(error)) {
|
||||
await handleCannotSyncItem(ItemClass, syncTargetId, local, error.message);
|
||||
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 {
|
||||
throw error;
|
||||
}
|
||||
|
@ -709,11 +720,16 @@ export default class Synchronizer {
|
|||
let canSync = true;
|
||||
try {
|
||||
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);
|
||||
} catch (error) {
|
||||
if (error && error.code === 'rejectedByTarget') {
|
||||
await handleCannotSyncItem(ItemClass, syncTargetId, local, error.message);
|
||||
canSync = false;
|
||||
} else if (error && error.code === ErrorCode.IsReadOnly) {
|
||||
action = getConflictType(local);
|
||||
itemIsReadOnly = true;
|
||||
canSync = false;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
|
@ -741,77 +757,18 @@ export default class Synchronizer {
|
|||
|
||||
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)) {
|
||||
// ------------------------------------------------------------------------------
|
||||
// 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 (remote) {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
await handleConflictAction(
|
||||
action as ConflictAction,
|
||||
ItemClass,
|
||||
!!remote,
|
||||
remoteContent,
|
||||
local,
|
||||
syncTargetId,
|
||||
itemIsReadOnly,
|
||||
(action: any) => this.dispatch(action)
|
||||
);
|
||||
|
||||
completeItemProcessing(path);
|
||||
}
|
||||
|
|
|
@ -1,13 +1,16 @@
|
|||
import { NoteEntity } from '../../services/database/types';
|
||||
import { reg } from '../../registry';
|
||||
import Folder from '../../models/Folder';
|
||||
import BaseModel from '../../BaseModel';
|
||||
import BaseModel, { ModelType } from '../../BaseModel';
|
||||
import Note from '../../models/Note';
|
||||
import Resource from '../../models/Resource';
|
||||
import ResourceFetcher from '../../services/ResourceFetcher';
|
||||
import DecryptionWorker from '../../services/DecryptionWorker';
|
||||
import Setting from '../../models/Setting';
|
||||
import { Mutex } from 'async-mutex';
|
||||
import { itemIsReadOnlySync, ItemSlice } from '../../models/utils/readOnly';
|
||||
import ItemChange from '../../models/ItemChange';
|
||||
import BaseItem from '../../models/BaseItem';
|
||||
|
||||
interface Shared {
|
||||
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 note = await Note.load(comp.props.noteId);
|
||||
|
||||
let mode = 'view';
|
||||
|
||||
if (isProvisionalNote && !comp.props.sharedData) {
|
||||
|
@ -236,6 +240,7 @@ shared.initState = async function(comp: any) {
|
|||
isLoading: false,
|
||||
fromShare: !!comp.props.sharedData,
|
||||
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;
|
||||
const Tag = require('../../models/Tag').default;
|
||||
const BaseModel = require('../../BaseModel').default;
|
||||
const Note = require('../../models/Note').default;
|
||||
const { reg } = require('../../registry.js');
|
||||
const ResourceFetcher = require('../../services/ResourceFetcher').default;
|
||||
const DecryptionWorker = require('../../services/DecryptionWorker').default;
|
||||
const eventManager = require('../../eventManager').default;
|
||||
import Setting from '../../models/Setting';
|
||||
import Tag from '../../models/Tag';
|
||||
import BaseModel from '../../BaseModel';
|
||||
import Note from '../../models/Note';
|
||||
import { reg } from '../../registry.js';
|
||||
import ResourceFetcher from '../../services/ResourceFetcher';
|
||||
import DecryptionWorker from '../../services/DecryptionWorker';
|
||||
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();
|
||||
|
||||
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
|
||||
// button, but not when a note is saved).
|
||||
if (action.type === 'SYNC_COMPLETED' && action.isFullSync) {
|
||||
DecryptionWorker.instance().scheduleStart();
|
||||
void DecryptionWorker.instance().scheduleStart();
|
||||
}
|
||||
|
||||
if (action.type === 'NOTE_DELETE' ||
|
||||
|
@ -87,7 +88,7 @@ const reduxSharedMiddleware = async function(store, next, action) {
|
|||
}
|
||||
|
||||
if (mustAutoAddResources) {
|
||||
ResourceFetcher.instance().autoAddResources();
|
||||
void ResourceFetcher.instance().autoAddResources();
|
||||
}
|
||||
|
||||
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
|
||||
// the note array, so in that case display a log statements so that it can
|
||||
// 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;
|
||||
}
|
||||
|
||||
private isReadyOnlyError(error: any) {
|
||||
return error && error.code === 'isReadOnly';
|
||||
}
|
||||
|
||||
public async put(path: string, content: any, options: any = null) {
|
||||
try {
|
||||
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)) {
|
||||
throw new JoplinError(error.message, 'rejectedByTarget');
|
||||
}
|
||||
|
||||
if (this.isReadyOnlyError(error)) {
|
||||
throw new JoplinError(error.message, 'isReadOnly');
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
@ -199,6 +208,8 @@ export default class FileApiDriverJoplinServer {
|
|||
for (const [, response] of Object.entries<any>(output.items)) {
|
||||
if (response.error && this.isRejectedBySyncTargetError(response.error)) {
|
||||
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 shim = require('./shim').default;
|
||||
const Buffer = require('buffer').Buffer;
|
||||
const { ltrimSlashes } = require('./path-utils');
|
||||
|
||||
class FileApiDriverOneDrive {
|
||||
constructor(api) {
|
||||
|
@ -196,11 +197,12 @@ class FileApiDriverOneDrive {
|
|||
|
||||
async clearRoot() {
|
||||
const recurseItems = async (path) => {
|
||||
path = ltrimSlashes(path);
|
||||
const result = await this.list(this.fileApi_.fullPath(path));
|
||||
const output = [];
|
||||
|
||||
for (const item of result.items) {
|
||||
const fullPath = `${path}/${item.path}`;
|
||||
const fullPath = ltrimSlashes(`${path}/${item.path}`);
|
||||
if (item.isDir) {
|
||||
await recurseItems(fullPath);
|
||||
}
|
||||
|
|
|
@ -49,7 +49,7 @@ function requestCanBeRepeated(error: any) {
|
|||
if (errorCode === 403) return false;
|
||||
|
||||
// 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
|
||||
// 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');
|
||||
}
|
||||
|
||||
public async chmod(_source: string, _mode: string | number) {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
public async mkdir(_path: string) {
|
||||
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) {
|
||||
try {
|
||||
await fs.unlink(path);
|
||||
|
|
|
@ -11,7 +11,10 @@ import ShareService from '../services/share/ShareService';
|
|||
import itemCanBeEncrypted from './utils/itemCanBeEncrypted';
|
||||
import { getEncryptionEnabled } from '../services/synchronizer/syncInfoUtils';
|
||||
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 moment = require('moment');
|
||||
|
||||
|
@ -45,6 +48,7 @@ export default class BaseItem extends BaseModel {
|
|||
public static encryptionService_: any = null;
|
||||
public static revisionService_: any = null;
|
||||
public static shareService_: ShareService = null;
|
||||
private static syncShareCache_: ShareState | null = null;
|
||||
|
||||
// Also update:
|
||||
// - itemsThatNeedSync()
|
||||
|
@ -83,6 +87,14 @@ export default class BaseItem extends BaseModel {
|
|||
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) {
|
||||
let counter = 1;
|
||||
let titleToTry = title;
|
||||
|
@ -200,10 +212,10 @@ export default class BaseItem extends BaseModel {
|
|||
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();
|
||||
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;
|
||||
}
|
||||
return null;
|
||||
|
@ -223,6 +235,21 @@ export default class BaseItem extends BaseModel {
|
|||
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) {
|
||||
const ItemClass = this.itemClass(itemType);
|
||||
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);
|
||||
|
||||
if (trackDeleted) {
|
||||
|
@ -863,13 +895,34 @@ export default class BaseItem extends BaseModel {
|
|||
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.userSideValidation === true) {
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { ErrorCode } from '../errors';
|
||||
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 Note from './Note';
|
||||
|
||||
|
@ -16,7 +17,7 @@ describe('models/Folder', () => {
|
|||
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 f2 = await Folder.save({ title: 'folder2', parent_id: f1.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);
|
||||
}));
|
||||
|
||||
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 f2 = await Folder.save({ title: 'folder2', parent_id: f1.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);
|
||||
}));
|
||||
|
||||
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 hasThrown = await checkThrowAsync(() => Folder.save({ id: f1.id, parent_id: f1.id }, { userSideValidation: true }));
|
||||
expect(hasThrown).toBe(true);
|
||||
|
@ -284,4 +285,42 @@ describe('models/Folder', () => {
|
|||
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 syncDebugLog from '../services/synchronizer/syncDebugLog';
|
||||
import ResourceService from '../services/ResourceService';
|
||||
import { LoadOptions } from './utils/types';
|
||||
const { substrWithEllipsis } = require('../string-utils.js');
|
||||
|
||||
const logger = Logger.create('models/Folder');
|
||||
|
@ -122,6 +123,8 @@ export default class Folder extends BaseItem {
|
|||
title: this.conflictFolderTitle(),
|
||||
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;
|
||||
}
|
||||
|
||||
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());
|
||||
return super.load(id);
|
||||
return super.load(id, options);
|
||||
}
|
||||
|
||||
public static defaultFolder() {
|
||||
|
@ -759,14 +762,14 @@ export default class Folder extends BaseItem {
|
|||
|
||||
syncDebugLog.info('Folder Save:', o);
|
||||
|
||||
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
|
||||
return super.save(o, options).then((folder: FolderEntity) => {
|
||||
this.dispatch({
|
||||
type: 'FOLDER_UPDATE_ONE',
|
||||
item: folder,
|
||||
});
|
||||
return folder;
|
||||
const savedFolder: FolderEntity = await super.save(o, options);
|
||||
|
||||
this.dispatch({
|
||||
type: 'FOLDER_UPDATE_ONE',
|
||||
item: savedFolder,
|
||||
});
|
||||
|
||||
return savedFolder;
|
||||
}
|
||||
|
||||
public static serializeIcon(icon: FolderIcon): string {
|
||||
|
|
|
@ -2,7 +2,7 @@ import Setting from './Setting';
|
|||
import BaseModel from '../BaseModel';
|
||||
import shim from '../shim';
|
||||
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 Note from './Note';
|
||||
import Tag from './Tag';
|
||||
|
@ -11,6 +11,7 @@ import Resource from './Resource';
|
|||
import { ResourceEntity } from '../services/database/types';
|
||||
import { toForwardSlashes } from '../path-utils';
|
||||
import * as ArrayUtils from '../ArrayUtils';
|
||||
import { ErrorCode } from '../errors';
|
||||
|
||||
async function allItems() {
|
||||
const folders = await Folder.all();
|
||||
|
@ -164,6 +165,8 @@ describe('models/Note', () => {
|
|||
|
||||
expect(note.body).toContain(resource.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 () => {
|
||||
|
@ -371,12 +374,14 @@ describe('models/Note', () => {
|
|||
|
||||
it('should create a conflict note', async () => {
|
||||
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);
|
||||
|
||||
expect(conflictedNote.is_conflict).toBe(1);
|
||||
expect(conflictedNote.conflict_original_id).toBe(origNote.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 () => {
|
||||
|
@ -441,4 +446,55 @@ describe('models/Note', () => {
|
|||
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 { _ } = require('../locale');
|
||||
import { pull, unique } from '../ArrayUtils';
|
||||
import { LoadOptions } from './utils/types';
|
||||
import { LoadOptions, SaveOptions } from './utils/types';
|
||||
const urlUtils = require('../urlUtils.js');
|
||||
const { isImageMimeType } = require('../resourceUtils');
|
||||
const { MarkupToHtml } = require('@joplin/renderer');
|
||||
|
@ -312,7 +312,7 @@ export default class Note extends BaseItem {
|
|||
public static previewFields(options: any = null) {
|
||||
options = { includeTimestamps: true, ...options };
|
||||
|
||||
const output = ['id', 'title', 'is_todo', 'todo_completed', 'todo_due', 'parent_id', 'encryption_applied', 'order', 'markup_language', 'is_conflict', 'is_shared'];
|
||||
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) {
|
||||
output.push('updated_time');
|
||||
|
@ -667,7 +667,7 @@ export default class Note extends BaseItem {
|
|||
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);
|
||||
|
||||
// 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 && !('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)
|
||||
// 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
|
||||
|
@ -720,7 +722,6 @@ export default class Note extends BaseItem {
|
|||
|
||||
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);
|
||||
|
||||
if (dispatchUpdateAction) {
|
||||
|
@ -1005,10 +1006,11 @@ export default class Note extends BaseItem {
|
|||
return new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
|
||||
}
|
||||
|
||||
|
||||
public static async createConflictNote(sourceNote: NoteEntity, changeSource: number): Promise<NoteEntity> {
|
||||
const conflictNote = { ...sourceNote };
|
||||
delete conflictNote.id;
|
||||
delete conflictNote.is_shared;
|
||||
delete conflictNote.share_id;
|
||||
conflictNote.is_conflict = 1;
|
||||
conflictNote.conflict_original_id = sourceNote.id;
|
||||
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 Note from '../models/Note';
|
||||
import Resource from '../models/Resource';
|
||||
import shim from '../shim';
|
||||
import { ErrorCode } from '../errors';
|
||||
import { remove, pathExists } from 'fs-extra';
|
||||
|
||||
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', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
|
@ -76,24 +95,27 @@ describe('models/Resource', () => {
|
|||
expect(originalStat.size).toBe(newStat.size);
|
||||
}));
|
||||
|
||||
// it('should encrypt a shared resource using the correct encryption key', (async () => {
|
||||
// const folder1 = await Folder.save({ title: 'folder1' });
|
||||
// const note1 = await Note.save({ title: 'ma note', parent_id: folder1.id });
|
||||
// await shim.attachFileToNote(note1, testImagePath);
|
||||
it('should not allow modifying a read-only resource', async () => {
|
||||
const { cleanup, resource } = await setupFolderNoteResourceReadOnly('123456789');
|
||||
await expectThrow(async () => Resource.save({ id: resource.id, share_id: '123456789', title: 'cannot do this!' }), ErrorCode.IsReadOnly);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// Resource.shareService_ = {
|
||||
// shareById: () => {
|
||||
// return {
|
||||
// master_key_id: '',
|
||||
// };
|
||||
// },
|
||||
// } as any;
|
||||
it('should not allow modifying a read-only resource content', async () => {
|
||||
const { cleanup, resource } = await setupFolderNoteResourceReadOnly('123456789');
|
||||
const tempFilePath = await createTempFile('something');
|
||||
await expectThrow(async () => Resource.updateResourceBlobContent(resource.id, tempFilePath), ErrorCode.IsReadOnly);
|
||||
await remove(tempFilePath);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// try {
|
||||
|
||||
// } finally {
|
||||
// Resource.shareService_ = null;
|
||||
// }
|
||||
// }));
|
||||
it('should not allow deleting a read-only resource', async () => {
|
||||
const { cleanup, resource } = await setupFolderNoteResourceReadOnly('123456789');
|
||||
expect(await pathExists(Resource.fullPath(resource))).toBe(true);
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
const id = typeof resourceOrId === 'object' ? resourceOrId.id : resourceOrId;
|
||||
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) {
|
||||
// For resources, there's not really batch deleting since there's the file data to delete
|
||||
// too, so each is processed one by one with the item being deleted last (since the db
|
||||
// call is the less likely to fail).
|
||||
// For resources, there's not really batch deletion since there's the
|
||||
// file data to delete too, so each is processed one by one with the
|
||||
// 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++) {
|
||||
const id = ids[i];
|
||||
const resource = await Resource.load(id);
|
||||
if (!resource) continue;
|
||||
|
||||
const path = Resource.fullPath(resource);
|
||||
await this.fsDriver().remove(path);
|
||||
await super.batchDelete([id], options);
|
||||
await this.fsDriver().remove(path);
|
||||
await NoteResource.deleteByResource(id); // Clean up note/resource relationships
|
||||
}
|
||||
|
||||
|
@ -362,12 +368,20 @@ export default class Resource extends BaseItem {
|
|||
await this.requireIsReady(resource);
|
||||
|
||||
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,
|
||||
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') {
|
||||
|
@ -383,6 +397,7 @@ export default class Resource extends BaseItem {
|
|||
let newResource: ResourceEntity = { ...resource };
|
||||
delete newResource.id;
|
||||
delete newResource.is_shared;
|
||||
delete newResource.share_id;
|
||||
newResource = await Resource.save(newResource);
|
||||
|
||||
const newLocalState = { ...localState };
|
||||
|
|
|
@ -26,10 +26,12 @@ export default class ResourceLocalState extends BaseModel {
|
|||
return result;
|
||||
}
|
||||
|
||||
public static async save(o: ResourceLocalStateEntity) {
|
||||
const queries = [{ sql: 'DELETE FROM resource_local_states WHERE resource_id = ?', params: [o.resource_id] }, Database.insertQuery(this.tableName(), o)];
|
||||
public static saveQueries(o: ResourceLocalStateEntity) {
|
||||
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) {
|
||||
|
|
|
@ -1699,6 +1699,12 @@ class Setting extends BaseModel {
|
|||
public: false,
|
||||
},
|
||||
|
||||
'sync.shareCache': {
|
||||
value: null,
|
||||
type: SettingItemType.String,
|
||||
public: false,
|
||||
},
|
||||
|
||||
'voiceTypingBaseUrl': {
|
||||
value: '',
|
||||
type: SettingItemType.String,
|
||||
|
@ -1708,7 +1714,6 @@ class Setting extends BaseModel {
|
|||
label: () => _('Voice typing language files (URL)'),
|
||||
section: 'note',
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
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 {
|
||||
ASC = 'ASC',
|
||||
DESC = 'DESC',
|
||||
|
@ -20,3 +22,15 @@ export interface LoadOptions {
|
|||
caseInsensitive?: boolean;
|
||||
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 Setting from '../../models/Setting';
|
||||
import Resource from '../../models/Resource';
|
||||
import { ResourceEntity } from '../database/types';
|
||||
const EventEmitter = require('events');
|
||||
const chokidar = require('chokidar');
|
||||
|
||||
|
@ -213,6 +214,20 @@ export default class ResourceEditWatcher {
|
|||
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> {
|
||||
let watchedItem = this.watchedItemByResourceId(resourceId);
|
||||
|
||||
|
@ -229,13 +244,7 @@ export default class ResourceEditWatcher {
|
|||
};
|
||||
|
||||
this.watchedItems_[resourceId] = watchedItem;
|
||||
|
||||
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 { resource, editFilePath } = await this.copyResourceToEditablePath(resourceId);
|
||||
const stat = await shim.fsDriver().stat(editFilePath);
|
||||
|
||||
watchedItem.path = editFilePath;
|
||||
|
@ -259,10 +268,18 @@ export default class ResourceEditWatcher {
|
|||
|
||||
public async openAndWatch(resourceId: string) {
|
||||
const watchedItem = await this.watch(resourceId);
|
||||
// bridge().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) {
|
||||
if (!resourceId) return;
|
||||
|
||||
|
|
|
@ -96,9 +96,13 @@ export default class MenuUtils {
|
|||
}
|
||||
|
||||
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);
|
||||
});
|
||||
menuItem.enabled = this.service.isEnabled(commandName, whenClauseContext);
|
||||
return menuItem;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import { State, stateUtils } from '../../reducer';
|
||||
import BaseModel from '../../BaseModel';
|
||||
import BaseModel, { ModelType } from '../../BaseModel';
|
||||
import Folder from '../../models/Folder';
|
||||
import MarkupToHtml from '@joplin/renderer/MarkupToHtml';
|
||||
import { isRootSharedFolder, isSharedFolderOwner } from '../share/reducer';
|
||||
import { FolderEntity, NoteEntity } from '../database/types';
|
||||
import { itemIsReadOnlySync, ItemSlice } from '../../models/utils/readOnly';
|
||||
import ItemChange from '../../models/ItemChange';
|
||||
|
||||
export interface WhenClauseContextOptions {
|
||||
commandFolderId?: string;
|
||||
|
@ -31,6 +33,8 @@ export interface WhenClauseContext {
|
|||
folderIsShareRoot: boolean;
|
||||
joplinServerConnected: boolean;
|
||||
hasMultiProfiles: boolean;
|
||||
noteIsReadOnly: boolean;
|
||||
folderIsReadOnly: boolean;
|
||||
}
|
||||
|
||||
export default function stateToWhenClauseContext(state: State, options: WhenClauseContextOptions = null): WhenClauseContext {
|
||||
|
@ -40,15 +44,15 @@ export default function stateToWhenClauseContext(state: State, options: WhenClau
|
|||
...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 commandNoteId = options.commandNoteId || selectedNoteId;
|
||||
// const commandNote:NoteEntity = commandNoteId ? BaseModel.byId(state.notes, commandNoteId) : null;
|
||||
|
||||
const commandFolderId = options.commandFolderId || state.selectedFolderId;
|
||||
const commandFolder: FolderEntity = commandFolderId ? BaseModel.byId(state.folders, commandFolderId) : null;
|
||||
|
||||
const settings = state.settings || {};
|
||||
|
||||
return {
|
||||
// Application state
|
||||
notesAreBeingSaved: stateUtils.hasNotesBeingSaved(state),
|
||||
|
@ -59,13 +63,13 @@ export default function stateToWhenClauseContext(state: State, options: WhenClau
|
|||
|
||||
// Note selection
|
||||
oneNoteSelected: !!selectedNote,
|
||||
someNotesSelected: state.selectedNoteIds.length > 0,
|
||||
multipleNotesSelected: state.selectedNoteIds.length > 1,
|
||||
noNotesSelected: !state.selectedNoteIds.length,
|
||||
someNotesSelected: selectedNoteIds.length > 0,
|
||||
multipleNotesSelected: selectedNoteIds.length > 1,
|
||||
noNotesSelected: !selectedNoteIds.length,
|
||||
|
||||
// Note history
|
||||
historyhasBackwardNotes: state.backwardHistoryNotes.length > 0,
|
||||
historyhasForwardNotes: state.forwardHistoryNotes.length > 0,
|
||||
historyhasBackwardNotes: state.backwardHistoryNotes && state.backwardHistoryNotes.length > 0,
|
||||
historyhasForwardNotes: state.forwardHistoryNotes && state.forwardHistoryNotes.length > 0,
|
||||
|
||||
// Folder selection
|
||||
oneFolderSelected: !!state.selectedFolderId,
|
||||
|
@ -82,8 +86,11 @@ export default function stateToWhenClauseContext(state: State, options: WhenClau
|
|||
folderIsShareRootAndOwnedByUser: commandFolder ? isRootSharedFolder(commandFolder) && isSharedFolderOwner(state, commandFolder.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,
|
||||
|
||||
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);
|
||||
|
||||
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');
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@ import { MasterKeyEntity } from '../e2ee/types';
|
|||
import { getMasterPassword } from '../e2ee/utils';
|
||||
import ResourceService from '../ResourceService';
|
||||
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');
|
||||
|
||||
|
@ -306,7 +306,7 @@ export default class ShareService {
|
|||
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;
|
||||
|
||||
if (getEncryptionEnabled()) {
|
||||
|
@ -330,6 +330,7 @@ export default class ShareService {
|
|||
return this.api().exec('POST', `api/shares/${shareId}/users`, {}, {
|
||||
email: recipientEmail,
|
||||
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) {
|
||||
logger.info('respondInvitation: ', shareUserId, accept);
|
||||
|
||||
|
|
|
@ -2,6 +2,9 @@ import { State as RootState } from '../../reducer';
|
|||
import { Draft } from 'immer';
|
||||
import { FolderEntity } from '../database/types';
|
||||
import { MasterKeyEntity } from '../e2ee/types';
|
||||
import Logger from '../../Logger';
|
||||
|
||||
const logger = Logger.create('share/reducer');
|
||||
|
||||
interface StateShareUserUser {
|
||||
id: string;
|
||||
|
@ -15,10 +18,17 @@ export enum ShareUserStatus {
|
|||
Rejected = 2,
|
||||
}
|
||||
|
||||
export interface SharePermissions {
|
||||
can_read: number;
|
||||
can_write: number;
|
||||
}
|
||||
|
||||
export interface StateShareUser {
|
||||
id: string;
|
||||
status: ShareUserStatus;
|
||||
user: StateShareUserUser;
|
||||
can_read: number;
|
||||
can_write: number;
|
||||
}
|
||||
|
||||
export interface StateShare {
|
||||
|
@ -35,6 +45,8 @@ export interface ShareInvitation {
|
|||
master_key: MasterKeyEntity;
|
||||
share: StateShare;
|
||||
status: ShareUserStatus;
|
||||
can_read: number;
|
||||
can_write: number;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
|
@ -53,6 +65,27 @@ export const defaultState: State = {
|
|||
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 {
|
||||
const userId = state.settings['sync.userId'];
|
||||
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;
|
||||
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':
|
||||
|
||||
draft.shareInvitations = action.shareInvitations;
|
||||
|
|
|
@ -61,40 +61,40 @@ const expected = `
|
|||
:root {
|
||||
--joplin-appearance: light;
|
||||
--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-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-selected-color2: #131313;
|
||||
--joplin-color3: #738598;
|
||||
--joplin-color4: #2D6BDC;
|
||||
--joplin-color-correct: green;
|
||||
--joplin-color-error: red;
|
||||
--joplin-color-error2: #ff6c6c;
|
||||
--joplin-color-faded: #7C8B9E;
|
||||
--joplin-color-warn: rgb(228,86,0);
|
||||
--joplin-color-warn2: #ffcb81;
|
||||
--joplin-color-warn3: #ff7626;
|
||||
--joplin-background-color3: #F4F5F6;
|
||||
--joplin-background-color-hover3: #CBDAF1;
|
||||
--joplin-color3: #738598;
|
||||
--joplin-background-color4: #ffffff;
|
||||
--joplin-color4: #2D6BDC;
|
||||
--joplin-color-warn-url: #155BDA;
|
||||
--joplin-divider-color: #dddddd;
|
||||
--joplin-odd-background-color: #eeeeee;
|
||||
--joplin-raised-background-color: #e5e5e5;
|
||||
--joplin-raised-color: #222222;
|
||||
--joplin-search-marker-background-color: #F7D26E;
|
||||
--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-code-background-color: rgb(243, 243, 243);
|
||||
--joplin-code-border-color: rgb(220, 220, 220);
|
||||
--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;
|
||||
--joplin-url-color: #155BDA;
|
||||
--joplin-warning-background-color: #FFD08D;
|
||||
}`;
|
||||
|
||||
describe('themeToCss', () => {
|
||||
|
|
|
@ -1,18 +1,23 @@
|
|||
import { Theme } from '../../themes/type';
|
||||
const { camelCaseToDash, formatCssSize } = require('../../string-utils');
|
||||
|
||||
// function quoteCssValue(name: string, value: string): string {
|
||||
// const needsQuote = ['appearance', 'codeMirrorTheme', 'codeThemeCss'].includes(name);
|
||||
// if (needsQuote) return `'${value}'`;
|
||||
// return value;
|
||||
// }
|
||||
const isColor = (v: any) => {
|
||||
return v && typeof v === 'object' && ('color' in v) && ('model' in v) && ('valpha' in v);
|
||||
};
|
||||
|
||||
export default function(theme: Theme) {
|
||||
const lines = [];
|
||||
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];
|
||||
|
||||
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 formattedValue = typeof value === 'number' && newName.indexOf('opacity') < 0 ? formatCssSize(value) : value;
|
||||
lines.push(`\t${newName}: ${formattedValue};`);
|
||||
|
|
|
@ -3,7 +3,8 @@ import BaseItem from '../../models/BaseItem';
|
|||
import Note from '../../models/Note';
|
||||
import { expectNotThrow, expectThrow, setupDatabaseAndSynchronizer, switchClient } from '../../testing/test-utils';
|
||||
import time from '../../time';
|
||||
import ItemUploader, { ApiCallFunction } from './ItemUploader';
|
||||
import ItemUploader from './ItemUploader';
|
||||
import { ApiCallFunction } from './utils/types';
|
||||
|
||||
interface ApiCall {
|
||||
name: string;
|
||||
|
|
|
@ -4,11 +4,10 @@ import JoplinError from '../../JoplinError';
|
|||
import Logger from '../../Logger';
|
||||
import BaseItem from '../../models/BaseItem';
|
||||
import { BaseItemEntity } from '../database/types';
|
||||
import { ApiCallFunction } from './utils/types';
|
||||
|
||||
const logger = Logger.create('ItemUploader');
|
||||
|
||||
export type ApiCallFunction = (fnName: string, ...args: any[])=> Promise<any>;
|
||||
|
||||
interface BatchItem extends MultiPutItem {
|
||||
localItemUpdatedTime: number;
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import Folder from '../../models/Folder';
|
|||
import Note from '../../models/Note';
|
||||
import BaseItem from '../../models/BaseItem';
|
||||
import WelcomeUtils from '../../WelcomeUtils';
|
||||
import { NoteEntity } from '../database/types';
|
||||
|
||||
describe('Synchronizer.basics', () => {
|
||||
|
||||
|
@ -12,6 +13,7 @@ describe('Synchronizer.basics', () => {
|
|||
await setupDatabaseAndSynchronizer(1);
|
||||
await setupDatabaseAndSynchronizer(2);
|
||||
await switchClient(1);
|
||||
synchronizer().testingHooks_ = [];
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
@ -260,6 +262,25 @@ describe('Synchronizer.basics', () => {
|
|||
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 () => {
|
||||
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 {
|
||||
Locks = 'locks',
|
||||
Resources = '.resource',
|
||||
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 { Dirnames } = require('../services/synchronizer/utils/types');
|
||||
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
|
||||
// suites can be run at the same time. suiteName is what is used to
|
||||
|
@ -138,7 +139,7 @@ function setSyncTargetName(name: string) {
|
|||
syncTargetName_ = name;
|
||||
syncTargetId_ = SyncTargetRegistry.nameToId(syncTargetName_);
|
||||
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_ = [];
|
||||
return previousName;
|
||||
}
|
||||
|
@ -150,6 +151,7 @@ setSyncTargetName('memory');
|
|||
// setSyncTargetName('onedrive');
|
||||
// setSyncTargetName('amazon_s3');
|
||||
// setSyncTargetName('joplinServer');
|
||||
// setSyncTargetName('joplinCloud');
|
||||
|
||||
// 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: ""}`);
|
||||
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));
|
||||
} else if (syncTargetId_ === SyncTargetRegistry.nameToId('joplinServer')) {
|
||||
} else if (syncTargetId_ === SyncTargetRegistry.nameToId('joplinServer') || syncTargetId_ === SyncTargetRegistry.nameToId('joplinCloud')) {
|
||||
mustRunInBand();
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
const createTempFile = async (content = '') => {
|
||||
const path = tempFilePath('txt');
|
||||
await fs.writeFile(path, content, 'utf8');
|
||||
return path;
|
||||
};
|
||||
|
||||
async function createTempDir() {
|
||||
const tempDirPath = `${baseTempDir}/${uuid.createNano()}`;
|
||||
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;
|
||||
}
|
||||
|
||||
function addExtraStyles(style: any) {
|
||||
export function addExtraStyles(style: any) {
|
||||
style.selectedDividerColor = Color(style.dividerColor).darken(0.2).hex();
|
||||
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