From 9d9420a35c4444a8e104eeaff05d9cbaf763e835 Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Sun, 3 Apr 2022 19:19:24 +0100 Subject: [PATCH] Desktop: Support for Joplin Cloud recursive linked notes --- packages/app-desktop/gui/ShareNoteDialog.tsx | 61 ++++++++----------- packages/app-desktop/main-html.js | 10 ++- packages/app-desktop/main.scss | 16 +++++ packages/lib/BaseSyncTarget.ts | 4 ++ packages/lib/SyncTargetJoplinCloud.ts | 4 ++ packages/lib/SyncTargetRegistry.ts | 24 ++------ .../lib/services/share/ShareService.test.ts | 2 +- packages/lib/services/share/ShareService.ts | 7 ++- 8 files changed, 72 insertions(+), 56 deletions(-) diff --git a/packages/app-desktop/gui/ShareNoteDialog.tsx b/packages/app-desktop/gui/ShareNoteDialog.tsx index efaaebe44e..1e3e4c2d48 100644 --- a/packages/app-desktop/gui/ShareNoteDialog.tsx +++ b/packages/app-desktop/gui/ShareNoteDialog.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import JoplinServerApi from '@joplin/lib/JoplinServerApi'; import { _, _n } from '@joplin/lib/locale'; import Note from '@joplin/lib/models/Note'; @@ -15,6 +15,7 @@ import Button from './Button/Button'; import { connect } from 'react-redux'; import { AppState } from '../app.reducer'; import { getEncryptionEnabled } from '@joplin/lib/services/synchronizer/syncInfoUtils'; +import SyncTargetRegistry from '../../lib/SyncTargetRegistry'; const { clipboard } = require('electron'); interface Props { @@ -22,6 +23,7 @@ interface Props { noteIds: Array; onClose: Function; shares: StateShare[]; + syncTargetId: number; } function styles_(props: Props) { @@ -69,9 +71,10 @@ export function ShareNoteDialog(props: Props) { console.info('Render ShareNoteDialog'); const [notes, setNotes] = useState([]); + const [recursiveShare, setRecursiveShare] = useState(false); const [sharesState, setSharesState] = useState('unknown'); - // const [shares, setShares] = useState({}); + const syncTargetInfo = useMemo(() => SyncTargetRegistry.infoById(props.syncTargetId), [props.syncTargetId]); const noteCount = notes.length; const theme = themeStyle(props.themeId); const styles = styles_(props); @@ -102,7 +105,7 @@ export function ShareNoteDialog(props: Props) { clipboard.writeText(links.join('\n')); }; - const shareLinkButton_click = async () => { + const shareLinkButton_click = useCallback(async () => { const service = ShareService.instance(); let hasSynced = false; @@ -121,7 +124,7 @@ export function ShareNoteDialog(props: Props) { const newShares: StateShare[] = []; for (const note of notes) { - const share = await service.shareNote(note.id); + const share = await service.shareNote(note.id, recursiveShare); newShares.push(share); } @@ -149,17 +152,7 @@ export function ShareNoteDialog(props: Props) { break; } - }; - - // const removeNoteButton_click = (event: any) => { - // const newNotes = []; - // for (let i = 0; i < notes.length; i++) { - // const n = notes[i]; - // if (n.id === event.noteId) continue; - // newNotes.push(n); - // } - // setNotes(newNotes); - // }; + }, [recursiveShare, notes]); const unshareNoteButton_click = async (event: any) => { await ShareService.instance().unshareNote(event.noteId); @@ -171,22 +164,6 @@ export function ShareNoteDialog(props: Props) { - // ); - - // const removeButton = notes.length <= 1 ? null : ( - // - // ); - return (
{note.title}{unshareButton} @@ -214,11 +191,26 @@ export function ShareNoteDialog(props: Props) { return
{_('Note: When a note is shared, it will no longer be encrypted on the server.')}
; } - function renderContent() { + const onRecursiveShareChange = useCallback(() => { + setRecursiveShare(v => !v); + }, []); + + const renderRecursiveShareCheckbox = () => { + if (!syncTargetInfo.supportsRecursiveLinkedNotes) return null; + return ( -
+
+ +
+ ); + }; + + const renderContent = () => { + return ( +
{renderNoteList(notes)} + {renderRecursiveShareCheckbox()}
{statusMessage(sharesState)}
{renderEncryptionWarningMessage()} @@ -230,7 +222,7 @@ export function ShareNoteDialog(props: Props) { />
); - } + }; return ( @@ -240,6 +232,7 @@ export function ShareNoteDialog(props: Props) { const mapStateToProps = (state: AppState) => { return { shares: state.shareService.shares.filter(s => !!s.note_id), + syncTargetId: state.settings['sync.target'], }; }; diff --git a/packages/app-desktop/main-html.js b/packages/app-desktop/main-html.js index ada712f5e2..8f4a200bee 100644 --- a/packages/app-desktop/main-html.js +++ b/packages/app-desktop/main-html.js @@ -112,7 +112,15 @@ document.addEventListener('auxclick', event => event.preventDefault()); // Each link (rendered as a button or list item) has its own custom click event // so disable the default. In particular this will disable Ctrl+Clicking a link // which would open a new browser window. -document.addEventListener('click', (event) => event.preventDefault()); +document.addEventListener('click', (event) => { + // We don't apply this to labels and inputs because it would break + // checkboxes. Such a global event handler is probably not a good idea + // anyway but keeping it for now, as it doesn't seem to break anything else. + // https://github.com/facebook/react/issues/13477#issuecomment-489274045 + if (['LABEL', 'INPUT'].includes(event.target.nodeName)) return; + + event.preventDefault(); +}); app().start(bridge().processArgv()).then((result) => { if (!result || !result.action) { diff --git a/packages/app-desktop/main.scss b/packages/app-desktop/main.scss index e72d31bbf5..9b37943458 100644 --- a/packages/app-desktop/main.scss +++ b/packages/app-desktop/main.scss @@ -180,6 +180,22 @@ h2 { margin-bottom: 10px; } +.form > .form-input-group-checkbox { + display: flex; + flex-direction: row; + align-items: center; +} + +.form > .form-input-group-checkbox > input { + display: flex; + margin-right: 6px; +} + +.form > .form-input-group-checkbox > label { + display: flex; + margin-bottom: 0; +} + .bold { font-weight: bold; } diff --git a/packages/lib/BaseSyncTarget.ts b/packages/lib/BaseSyncTarget.ts index f8c418147c..e199ae0318 100644 --- a/packages/lib/BaseSyncTarget.ts +++ b/packages/lib/BaseSyncTarget.ts @@ -33,6 +33,10 @@ export default class BaseSyncTarget { return true; } + public static supportsRecursiveLinkedNotes(): boolean { + return false; + } + public option(name: string, defaultValue: any = null) { return this.options_ && name in this.options_ ? this.options_[name] : defaultValue; } diff --git a/packages/lib/SyncTargetJoplinCloud.ts b/packages/lib/SyncTargetJoplinCloud.ts index f781a693d1..1068a31c8b 100644 --- a/packages/lib/SyncTargetJoplinCloud.ts +++ b/packages/lib/SyncTargetJoplinCloud.ts @@ -38,6 +38,10 @@ export default class SyncTargetJoplinCloud extends BaseSyncTarget { return false; } + public static supportsRecursiveLinkedNotes(): boolean { + return true; + } + public async isAuthenticated() { return true; } diff --git a/packages/lib/SyncTargetRegistry.ts b/packages/lib/SyncTargetRegistry.ts index 3e2bf80054..40126e07da 100644 --- a/packages/lib/SyncTargetRegistry.ts +++ b/packages/lib/SyncTargetRegistry.ts @@ -4,33 +4,16 @@ export interface SyncTargetInfo { label: string; supportsSelfHosted: boolean; supportsConfigCheck: boolean; + supportsRecursiveLinkedNotes: boolean; description: string; classRef: any; } -// const syncTargetOrder = [ -// 'joplinCloud', -// 'dropbox', -// 'onedrive', -// ]; - export default class SyncTargetRegistry { private static reg_: Record = {}; private static get reg() { - // if (!this.reg_[0]) { - // this.reg_[0] = { - // id: 0, - // name: SyncTargetNone.targetName(), - // label: SyncTargetNone.label(), - // classRef: SyncTargetNone, - // description: SyncTargetNone.description(), - // supportsSelfHosted: false, - // supportsConfigCheck: false, - // }; - // } - return this.reg_; } @@ -47,6 +30,10 @@ export default class SyncTargetRegistry { throw new Error(`Unknown name: ${name}`); } + public static infoById(id: number): SyncTargetInfo { + return this.infoByName(this.idToName(id)); + } + public static addClass(SyncTargetClass: any) { this.reg[SyncTargetClass.id()] = { id: SyncTargetClass.id(), @@ -56,6 +43,7 @@ export default class SyncTargetRegistry { description: SyncTargetClass.description(), supportsSelfHosted: SyncTargetClass.supportsSelfHosted(), supportsConfigCheck: SyncTargetClass.supportsConfigCheck(), + supportsRecursiveLinkedNotes: SyncTargetClass.supportsRecursiveLinkedNotes(), }; } diff --git a/packages/lib/services/share/ShareService.test.ts b/packages/lib/services/share/ShareService.test.ts index 508dc213d0..53fd99e2a4 100644 --- a/packages/lib/services/share/ShareService.test.ts +++ b/packages/lib/services/share/ShareService.test.ts @@ -53,7 +53,7 @@ describe('ShareService', function() { }, }); await msleep(1); - await service.shareNote(note.id); + await service.shareNote(note.id, false); function checkTimestamps(previousNote: NoteEntity, newNote: NoteEntity) { // After sharing or unsharing, only the updated_time property should diff --git a/packages/lib/services/share/ShareService.ts b/packages/lib/services/share/ShareService.ts index c886a9fa89..02025c8b58 100644 --- a/packages/lib/services/share/ShareService.ts +++ b/packages/lib/services/share/ShareService.ts @@ -228,11 +228,14 @@ export default class ShareService { } } - public async shareNote(noteId: string): Promise { + public async shareNote(noteId: string, recursive: boolean): Promise { const note = await Note.load(noteId); if (!note) throw new Error(`No such note: ${noteId}`); - const share = await this.api().exec('POST', 'api/shares', {}, { note_id: noteId }); + const share = await this.api().exec('POST', 'api/shares', {}, { + note_id: noteId, + recursive: recursive ? 1 : 0, + }); await Note.save({ id: note.id,