diff --git a/.eslintrc.js b/.eslintrc.js index b4c5e067c..03ed3439b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -75,7 +75,6 @@ module.exports = { glob: true, log: true, EditorWatchdog: true, - // \src\share\canvas_share.js React: true, appState: true, ExcalidrawLib: true, diff --git a/package.json b/package.json index 74d9a3578..149dda04b 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "jimp": "0.22.10", "joplin-turndown-plugin-gfm": "1.0.12", "jsdom": "22.1.0", - "marked": "8.0.1", + "marked": "9.0.0", "mime-types": "2.1.35", "multer": "1.4.5-lts.1", "node-abi": "3.47.0", @@ -91,13 +91,13 @@ "tmp": "0.2.1", "turndown": "7.1.2", "unescape": "1.0.1", - "ws": "8.14.0", + "ws": "8.14.1", "xml2js": "0.6.2", "yauzl": "2.10.0" }, "devDependencies": { "cross-env": "7.0.3", - "electron": "25.8.0", + "electron": "25.8.1", "electron-builder": "24.6.4", "electron-packager": "17.1.2", "electron-rebuild": "3.2.9", diff --git a/src/becca/entities/bnote.js b/src/becca/entities/bnote.js index 36e788298..5725033ad 100644 --- a/src/becca/entities/bnote.js +++ b/src/becca/entities/bnote.js @@ -229,7 +229,9 @@ class BNote extends AbstractBeccaEntity { return this._getContent(); } - /** @returns {*} */ + /** + * @returns {*} + * @throws Error in case of invalid JSON */ getJsonContent() { const content = this.getContent(); @@ -240,6 +242,16 @@ class BNote extends AbstractBeccaEntity { return JSON.parse(content); } + /** @returns {*|null} valid object or null if the content cannot be parsed as JSON */ + getJsonContentSafely() { + try { + return this.getJsonContent(); + } + catch (e) { + return null; + } + } + /** * @param content * @param {object} [opts] @@ -1143,7 +1155,7 @@ class BNote extends AbstractBeccaEntity { } /** @returns {BAttachment[]} */ - getAttachmentByRole(role) { + getAttachmentsByRole(role) { return sql.getRows(` SELECT attachments.* FROM attachments @@ -1154,6 +1166,18 @@ class BNote extends AbstractBeccaEntity { .map(row => new BAttachment(row)); } + /** @returns {BAttachment} */ + getAttachmentByTitle(title) { + return sql.getRows(` + SELECT attachments.* + FROM attachments + WHERE ownerId = ? + AND title = ? + AND isDeleted = 0 + ORDER BY position`, [this.noteId, title]) + .map(row => new BAttachment(row))[0]; + } + /** * Gives all possible note paths leading to this note. Paths containing search note are ignored (could form cycles) * diff --git a/src/public/app/entities/fblob.js b/src/public/app/entities/fblob.js index 865afd53c..e335d7cb8 100644 --- a/src/public/app/entities/fblob.js +++ b/src/public/app/entities/fblob.js @@ -15,4 +15,25 @@ export default class FBlob { /** @type {string} */ this.utcDateModified = row.utcDateModified; } + + /** + * @returns {*} + * @throws Error in case of invalid JSON */ + getJsonContent() { + if (!this.content || !this.content.trim()) { + return null; + } + + return JSON.parse(this.content); + } + + /** @returns {*|null} valid object or null if the content cannot be parsed as JSON */ + getJsonContentSafely() { + try { + return this.getJsonContent(); + } + catch (e) { + return null; + } + } } diff --git a/src/public/app/entities/fnote.js b/src/public/app/entities/fnote.js index e770e0b69..f8715b369 100644 --- a/src/public/app/entities/fnote.js +++ b/src/public/app/entities/fnote.js @@ -255,6 +255,12 @@ class FNote { return this.attachments; } + /** @returns {Promise} */ + async getAttachmentsByRole(role) { + return (await this.getAttachments()) + .filter(attachment => attachment.role === role); + } + /** @returns {Promise} */ async getAttachmentById(attachmentId) { const attachments = await this.getAttachments(); diff --git a/src/public/app/menus/tree_context_menu.js b/src/public/app/menus/tree_context_menu.js index c0daf7f4f..38c245251 100644 --- a/src/public/app/menus/tree_context_menu.js +++ b/src/public/app/menus/tree_context_menu.js @@ -69,7 +69,8 @@ export default class TreeContextMenu { { title: 'Collapse subtree ', command: "collapseSubtree", uiIcon: "bx bx-collapse", enabled: noSelectedNotes }, { title: 'Sort by ... ', command: "sortChildNotes", uiIcon: "bx bx-empty", enabled: noSelectedNotes && notSearch }, { title: 'Recent changes in subtree', command: "recentChangesInSubtree", uiIcon: "bx bx-history", enabled: noSelectedNotes }, - { title: 'Convert to attachment', command: "convertNoteToAttachment", uiIcon: "bx bx-empty", enabled: isNotRoot && !isHoisted } + { title: 'Convert to attachment', command: "convertNoteToAttachment", uiIcon: "bx bx-empty", enabled: isNotRoot && !isHoisted }, + { title: 'Copy note path to clipboard', command: "copyNotePathToClipboard", uiIcon: "bx bx-empty", enabled: true } ] }, { title: "----" }, { title: "Protect subtree", command: "protectSubtree", uiIcon: "bx bx-check-shield", enabled: noSelectedNotes }, @@ -153,6 +154,9 @@ export default class TreeContextMenu { toastService.showMessage(`${converted} notes have been converted to attachments.`); } + else if (command === 'copyNotePathToClipboard') { + navigator.clipboard.writeText('#' + notePath); + } else { this.treeWidget.triggerCommand(command, { node: this.node, diff --git a/src/public/app/services/content_renderer.js b/src/public/app/services/content_renderer.js index 87871169c..6f66adfb0 100644 --- a/src/public/app/services/content_renderer.js +++ b/src/public/app/services/content_renderer.js @@ -33,7 +33,7 @@ async function getRenderedContent(entity, options = {}) { else if (type === 'code') { await renderCode(entity, $renderedContent); } - else if (type === 'image') { + else if (type === 'image' || type === 'canvas') { renderImage(entity, $renderedContent, options); } else if (!options.tooltip && ['file', 'pdf', 'audio', 'video'].includes(type)) { @@ -49,9 +49,6 @@ async function getRenderedContent(entity, options = {}) { $renderedContent.append($content); } - else if (type === 'canvas') { - await renderCanvas(entity, $renderedContent); - } else if (!options.tooltip && type === 'protectedSession') { const $button = $(``) .on('click', protectedSessionService.enterProtectedSession); @@ -125,7 +122,7 @@ function renderImage(entity, $renderedContent, options = {}) { let url; if (entity instanceof FNote) { - url = `api/images/${entity.noteId}/${sanitizedTitle}?${entity.utcDateModified}`; + url = `api/images/${entity.noteId}/${sanitizedTitle}?${Math.random()}`; } else if (entity instanceof FAttachment) { url = `api/attachments/${entity.attachmentId}/image/${sanitizedTitle}?${entity.utcDateModified}">`; } @@ -236,28 +233,6 @@ async function renderMermaid(note, $renderedContent) { } } -async function renderCanvas(note, $renderedContent) { - // make sure surrounding container has size of what is visible. Then image is shrinked to its boundaries - $renderedContent.css({height: "100%", width: "100%"}); - - const blob = await note.getBlob(); - const content = blob.content || ""; - - try { - const placeHolderSVG = ""; - const data = JSON.parse(content) - const svg = data.svg || placeHolderSVG; - /** - * maxWidth: size down to 100% (full) width of container but do not enlarge! - * height:auto to ensure that height scales with width - */ - $renderedContent.append($(svg).css({maxWidth: "100%", maxHeight: "100%", height: "auto", width: "auto"})); - } catch (err) { - console.error("error parsing content as JSON", content, err); - $renderedContent.append($("
").text("Error parsing content. Please check console.error() for more details.")); - } -} - /** * @param {jQuery} $renderedContent * @param {FNote} note diff --git a/src/public/app/services/link.js b/src/public/app/services/link.js index c859b84cf..ee991b202 100644 --- a/src/public/app/services/link.js +++ b/src/public/app/services/link.js @@ -194,6 +194,10 @@ function goToLink(evt) { const $link = $(evt.target).closest("a,.block-link"); const hrefLink = $link.attr('href') || $link.attr('data-href'); + return goToLinkExt(evt, hrefLink, $link); +} + +function goToLinkExt(evt, hrefLink, $link) { if (hrefLink?.startsWith("data:")) { return true; } @@ -201,7 +205,7 @@ function goToLink(evt) { evt.preventDefault(); evt.stopPropagation(); - const { notePath, viewScope } = parseNavigationStateFromUrl(hrefLink); + const {notePath, viewScope} = parseNavigationStateFromUrl(hrefLink); const ctrlKey = utils.isCtrlKey(evt); const isLeftClick = evt.which === 1; @@ -213,25 +217,23 @@ function goToLink(evt) { if (notePath) { if (openInNewTab) { - appContext.tabManager.openTabWithNoteWithHoisting(notePath, { viewScope }); - } - else if (isLeftClick) { + appContext.tabManager.openTabWithNoteWithHoisting(notePath, {viewScope}); + } else if (isLeftClick) { const ntxId = $(evt.target).closest("[data-ntx-id]").attr("data-ntx-id"); const noteContext = ntxId ? appContext.tabManager.getNoteContextById(ntxId) : appContext.tabManager.getActiveContext(); - noteContext.setNote(notePath, { viewScope }).then(() => { + noteContext.setNote(notePath, {viewScope}).then(() => { if (noteContext !== appContext.tabManager.getActiveContext()) { appContext.tabManager.activateNoteContext(noteContext.ntxId); } }); } - } - else if (hrefLink) { - const withinEditLink = $link.hasClass("ck-link-actions__preview"); - const outsideOfCKEditor = $link.closest("[contenteditable]").length === 0; + } else if (hrefLink) { + const withinEditLink = $link?.hasClass("ck-link-actions__preview"); + const outsideOfCKEditor = !$link || $link.closest("[contenteditable]").length === 0; if (openInNewTab || (withinEditLink && (leftClick || middleClick)) @@ -239,8 +241,7 @@ function goToLink(evt) { ) { if (hrefLink.toLowerCase().startsWith('http') || hrefLink.startsWith("api/")) { window.open(hrefLink, '_blank'); - } - else if (hrefLink.toLowerCase().startsWith('file:') && utils.isElectron()) { + } else if (hrefLink.toLowerCase().startsWith('file:') && utils.isElectron()) { const electron = utils.dynamicRequire('electron'); electron.shell.openPath(hrefLink); @@ -364,6 +365,7 @@ export default { getNotePathFromUrl, createLink, goToLink, + goToLinkExt, loadReferenceLinkTitle, getReferenceLinkTitle, getReferenceLinkTitleSync, diff --git a/src/public/app/widgets/buttons/note_actions.js b/src/public/app/widgets/buttons/note_actions.js index a985261ae..4d48cff41 100644 --- a/src/public/app/widgets/buttons/note_actions.js +++ b/src/public/app/widgets/buttons/note_actions.js @@ -98,7 +98,7 @@ export default class NoteActionsWidget extends NoteContextAwareWidget { this.toggleDisabled(this.$findInTextButton, ['text', 'code', 'book'].includes(note.type)); - this.toggleDisabled(this.$showSourceButton, ['text', 'relationMap', 'mermaid'].includes(note.type)); + this.toggleDisabled(this.$showSourceButton, ['text', 'code', 'relationMap', 'mermaid', 'canvas'].includes(note.type)); this.toggleDisabled(this.$printActiveNoteButton, ['text', 'code'].includes(note.type)); diff --git a/src/public/app/widgets/note_detail.js b/src/public/app/widgets/note_detail.js index 0bd8246f7..2fdef9bcc 100644 --- a/src/public/app/widgets/note_detail.js +++ b/src/public/app/widgets/note_detail.js @@ -86,6 +86,8 @@ export default class NoteDetailWidget extends NoteContextAwareWidget { protectedSessionHolder.touchProtectedSessionIfNecessary(note); await server.put(`notes/${noteId}/data`, data, this.componentId); + + this.getTypeWidget().dataSaved?.(); }); appContext.addBeforeUnloadListener(this); @@ -167,7 +169,7 @@ export default class NoteDetailWidget extends NoteContextAwareWidget { let type = note.type; const viewScope = this.noteContext.viewScope; - if (type === 'text' && viewScope.viewMode === 'source') { + if (viewScope.viewMode === 'source') { type = 'readOnlyCode'; } else if (viewScope.viewMode === 'attachments') { type = viewScope.attachmentId ? 'attachmentDetail' : 'attachmentList'; diff --git a/src/public/app/widgets/note_tree.js b/src/public/app/widgets/note_tree.js index bbbcc9905..a586dfbde 100644 --- a/src/public/app/widgets/note_tree.js +++ b/src/public/app/widgets/note_tree.js @@ -14,7 +14,6 @@ import keyboardActionsService from "../services/keyboard_actions.js"; import clipboard from "../services/clipboard.js"; import protectedSessionService from "../services/protected_session.js"; import linkService from "../services/link.js"; -import syncService from "../services/sync.js"; import options from "../services/options.js"; import protectedSessionHolder from "../services/protected_session_holder.js"; import dialogService from "../services/dialog.js"; @@ -586,6 +585,17 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { }); }, select: (event, {node}) => { + if (hoistedNoteService.getHoistedNoteId() === 'root' + && node.data.noteId === '_hidden' + && node.isSelected()) { + + // hidden is hackily hidden from the tree via CSS when root is hoisted + // make sure it's not selected by mistake, it could be e.g. deleted by mistake otherwise + node.setSelected(false); + + return; + } + $(node.span).find(".fancytree-custom-icon").attr("title", node.isSelected() ? "Apply bulk actions on selected notes" : ""); } @@ -799,7 +809,10 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { nodes.push(this.getActiveNode()); } - return nodes; + // hidden subtree is hackily hidden via CSS when hoisted to root + // make sure it's never selected for e.g. deletion in such a case + return nodes.filter(node => hoistedNoteService.getHoistedNoteId() !== 'root' + || node.data.noteId !== '_hidden'); } async setExpandedStatusForSubtree(node, isExpanded) { diff --git a/src/public/app/widgets/type_widgets/attachment_list.js b/src/public/app/widgets/type_widgets/attachment_list.js index 6b7ea0259..8f6aa9f9e 100644 --- a/src/public/app/widgets/type_widgets/attachment_list.js +++ b/src/public/app/widgets/type_widgets/attachment_list.js @@ -42,10 +42,12 @@ export default class AttachmentListTypeWidget extends TypeWidget { const $helpButton = $(''); utils.initHelpButtons($helpButton); + const noteLink = await linkService.createLink(this.noteId); // do separately to avoid race condition between empty() and .append() + this.$linksWrapper.empty().append( $('
').append( "Owning note: ", - await linkService.createLink(this.noteId), + noteLink, ), $('
').append( $('