diff --git a/packages/core/src/api/vault.ts b/packages/core/src/api/vault.ts index 28fb16835..e21696e15 100644 --- a/packages/core/src/api/vault.ts +++ b/packages/core/src/api/vault.ts @@ -306,7 +306,7 @@ export default class Vault { data = rawContent.data; type = rawContent.type; } else if (data && type) { - data = await this.db.content.extractAttachments({ + data = await this.db.content.postProcess({ data, type, noteId: id diff --git a/packages/core/src/collections/content.ts b/packages/core/src/collections/content.ts index 9438df674..0d1199cab 100644 --- a/packages/core/src/collections/content.ts +++ b/packages/core/src/collections/content.ts @@ -33,6 +33,7 @@ import Database from "../api"; import { getOutputType } from "./attachments"; import { SQLCollection } from "../database/sql-collection"; import { NoteContent } from "./session-content"; +import { InternalLink } from "../utils/internal-link"; export const EMPTY_CONTENT = (noteId: string): UnencryptedContentItem => ({ noteId, @@ -96,7 +97,7 @@ export class Content implements ICollection { typeof content.data === "string" ? content.data : undefined; if (unencryptedData && content.type && content.noteId) - unencryptedData = await this.extractAttachments({ + unencryptedData = await this.postProcess({ type: content.type, data: unencryptedData, noteId: content.noteId @@ -299,19 +300,22 @@ export class Content implements ICollection { await this.add(contentItem); } - async extractAttachments( - contentItem: NoteContent & { noteId: string } - ) { - // if (contentItem.localOnly) return contentItem; - + async postProcess(contentItem: NoteContent & { noteId: string }) { const content = getContentFromData(contentItem.type, contentItem.data); if (!content) return contentItem.data; - const { data, hashes } = await content.extractAttachments( + const { data, hashes, internalLinks } = await content.postProcess( this.db.attachments.save ); + await this.processInternalLinks(contentItem.noteId, internalLinks); + await this.processLinkedAttachments(contentItem.noteId, hashes); + + return data; + } + + private async processLinkedAttachments(noteId: string, hashes: string[]) { const noteAttachments = await this.db.relations - .from({ type: "note", id: contentItem.noteId }, "attachment") + .from({ type: "note", id: noteId }, "attachment") .selector.filter.select(["id", "hash"]) .execute(); @@ -322,7 +326,7 @@ export class Content implements ICollection { for (const attachment of toDelete) { await this.db.relations.unlink( { - id: contentItem.noteId, + id: noteId, type: "note" }, { id: attachment.id, type: "attachment" } @@ -340,14 +344,51 @@ export class Content implements ICollection { for (const attachment of attachments) { await this.db.relations.add( { - id: contentItem.noteId, + id: noteId, type: "note" }, { id: attachment.id, type: "attachment" } ); } + } - return data; + private async processInternalLinks( + noteId: string, + internalLinks: InternalLink[] + ) { + const links = await this.db.relations + .from({ type: "note", id: noteId }, "note") + .get(); + + const toDelete = links.filter((link) => { + return internalLinks.every((l) => l.id !== link.toId); + }); + + const toAdd = internalLinks.filter((link) => { + return links.every((l) => link.id !== l.toId); + }); + + for (const link of toDelete) { + await this.db.relations.unlink( + { + id: noteId, + type: "note" + }, + { id: link.toId, type: link.toType } + ); + } + + for (const link of toAdd) { + const note = await this.db.notes.exists(link.id); + if (!note) continue; + await this.db.relations.add( + { + id: noteId, + type: "note" + }, + link + ); + } } } diff --git a/packages/core/src/content-types/tiptap.ts b/packages/core/src/content-types/tiptap.ts index a7d02aa92..fe2e0f577 100644 --- a/packages/core/src/content-types/tiptap.ts +++ b/packages/core/src/content-types/tiptap.ts @@ -36,6 +36,7 @@ import { } from "../utils/html-parser"; import { HTMLRewriter } from "../utils/html-rewriter"; import { ContentBlock } from "../types"; +import { InternalLink, parseInternalLink } from "../utils/internal-link"; export type ResolveHashes = ( hashes: string[] @@ -46,6 +47,7 @@ const ATTRIBUTES = { mime: "data-mime", filename: "data-filename", src: "src", + href: "href", blockId: "data-block-id" }; @@ -142,8 +144,8 @@ export class Tiptap { }).transform(this.data); } - async extractAttachments( - store: ( + async postProcess( + saveAttachment: ( data: string, mime: string, filename?: string @@ -151,13 +153,17 @@ export class Tiptap { ) { if ( !this.data.includes(ATTRIBUTES.src) && - !this.data.includes(ATTRIBUTES.hash) + !this.data.includes(ATTRIBUTES.hash) && + // check for internal links + !this.data.includes("nn://") ) return { data: this.data, - hashes: [] + hashes: [], + internalLinks: [] }; + const internalLinks: InternalLink[] = []; const sources: { src: string; filename?: string; @@ -168,6 +174,7 @@ export class Tiptap { ontag: (name, attr, pos) => { const hash = attr[ATTRIBUTES.hash]; const src = attr[ATTRIBUTES.src]; + const href = attr[ATTRIBUTES.href]; if (name === "img" && !hash && src) { sources.push({ src, @@ -175,6 +182,10 @@ export class Tiptap { mime: attr[ATTRIBUTES.mime], id: `${pos.start}${pos.end}` }); + } else if (name === "a" && href && href.startsWith("nn://")) { + const internalLink = parseInternalLink(href); + if (!internalLink) return; + internalLinks.push(internalLink); } } }).parse(this.data); @@ -184,7 +195,7 @@ export class Tiptap { try { const { data, mimeType } = dataurl.toObject(image.src); if (!data || !mimeType) continue; - const hash = await store(data, mimeType, image.filename); + const hash = await saveAttachment(data, mimeType, image.filename); if (!hash) continue; images[image.id] = hash; @@ -228,7 +239,8 @@ export class Tiptap { return { data: html, - hashes + hashes, + internalLinks }; } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ce05c7d7c..2e3b5d033 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -21,3 +21,8 @@ export * from "./types"; export { VirtualizedGrouping } from "./utils/virtualized-grouping"; export { DefaultColors } from "./collections/colors"; export { type BackupFile, type LegacyBackupFile } from "./database/backup"; +export { + createInternalLink, + parseInternalLink, + type InternalLink +} from "./utils/internal-link"; diff --git a/packages/core/src/utils/internal-link.ts b/packages/core/src/utils/internal-link.ts new file mode 100644 index 000000000..8acf74e51 --- /dev/null +++ b/packages/core/src/utils/internal-link.ts @@ -0,0 +1,61 @@ +/* +This file is part of the Notesnook project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +const InternalLinkTypes = ["note"] as const; +type InternalLinkType = (typeof InternalLinkTypes)[number]; +export type InternalLink = { + type: T; + id: string; + params?: Partial; +}; +type InternalLinkParams = { + note: { blockId: string }; +}; +export function createInternalLink( + type: T, + id: string, + params?: InternalLinkParams[T] +) { + let link = `nn://${type}/${id}`; + if (params) { + link += + "?" + + Object.entries(params) + .map(([key, value]) => `${key}=${value}`) + .join("&"); + } + return link; +} + +export function parseInternalLink(link: string): InternalLink | undefined { + const url = new URL(link); + if (url.protocol !== "nn:") return; + const [type, id] = url.pathname.split("/").slice(2); + if (!type || !id || !isValidInternalType(type)) return; + + return { + type, + id, + params: Object.fromEntries(url.searchParams.entries()) + }; +} + +function isValidInternalType(type: string): type is InternalLinkType { + return InternalLinkTypes.includes(type as any); +}