From 7d16a3a0faeae6c9899192d3c6d76d525f717d6f Mon Sep 17 00:00:00 2001 From: chris <1010084+cloverich@users.noreply.github.com> Date: Sun, 8 Dec 2024 07:36:10 -0800 Subject: [PATCH] udpate note links when journal changes (#277) - when changing journal of note A, find all notes that point to it and update their links too --- src/electron/migrations/20211005142122.sql | 11 +++ src/markdown/index.ts | 29 ++++++ src/preload/client/documents.ts | 96 +++++++++++++++++-- src/preload/client/files.ts | 12 ++- src/preload/client/importer.ts | 16 +--- .../client/importer/FilesImportResolver.ts | 15 +-- src/preload/client/sync.ts | 2 + .../editor/features/note-linking/toMdast.ts | 7 +- 8 files changed, 150 insertions(+), 38 deletions(-) diff --git a/src/electron/migrations/20211005142122.sql b/src/electron/migrations/20211005142122.sql index 2fd4a5f..3aa2015 100644 --- a/src/electron/migrations/20211005142122.sql +++ b/src/electron/migrations/20211005142122.sql @@ -38,7 +38,18 @@ CREATE TABLE IF NOT EXISTS "document_tags" ( PRIMARY KEY ("documentId", "tag") ); +CREATE TABLE IF NOT EXISTS "document_links" ( + "documentId" TEXT NOT NULL, + -- tagetId is not a foreign key, because if we delete the document, we leave + -- orphaned links in the original (would be weird to remove markdown links in the dependent notes) + "targetId" TEXT NOT NULL, + "targetJournal" TEXT NOT NULL, + "resolvedAt" TEXT, + FOREIGN KEY ("documentId") REFERENCES "documents" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + PRIMARY KEY ("documentId", "targetId") +); +CREATE INDEX IF NOT EXISTS "document_links_target_idx" ON "document_links"("targetId"); CREATE INDEX IF NOT EXISTS "documents_title_idx" ON "documents"("title"); CREATE INDEX IF NOT EXISTS "documents_createdat_idx" ON "documents"("createdAt"); CREATE INDEX IF NOT EXISTS "tags_name_idx" ON "document_tags"("tag"); diff --git a/src/markdown/index.ts b/src/markdown/index.ts index 1b5af6f..a549e43 100644 --- a/src/markdown/index.ts +++ b/src/markdown/index.ts @@ -86,3 +86,32 @@ export const stringToSlate = (input: string, parse = parseMarkdown) => { export const slateToString = (nodes: SlateCustom.SlateNode[]) => { return mdastToString(wrapImages(slateToMdast(nodes))); }; + +// is a markdown link a link to another note? +// NOTE: Confusing name; dont have a distinct noteLink type in mdast, maybe +// should be targetsNote or something +export const isNoteLink = (mdast: mdast.RootContent): mdast is mdast.Link => { + if (mdast.type !== "link") return false; + + // we are only interested in markdown links + if (!mdast.url.endsWith(".md")) return false; + + // ensure its not a url with an .md domain + if (mdast.url.includes("://")) return false; + + return true; +}; + +export const selectNoteLinks = ( + mdast: mdast.Content | mdast.Root, +): mdast.Link[] => { + const links: mdast.Link[] = []; + if (mdast.type === "link" && isNoteLink(mdast)) { + links.push(mdast); + } else if ("children" in mdast) { + for (const child of mdast.children as any) { + links.push(...selectNoteLinks(child)); + } + } + return links; +}; diff --git a/src/preload/client/documents.ts b/src/preload/client/documents.ts index 8cc0a76..c5cc9b6 100644 --- a/src/preload/client/documents.ts +++ b/src/preload/client/documents.ts @@ -1,6 +1,8 @@ import { Database } from "better-sqlite3"; import { Knex } from "knex"; import { uuidv7obj } from "uuidv7"; +import { mdastToString, parseMarkdown, selectNoteLinks } from "../../markdown"; +import { parseNoteLink } from "../../views/edit/editor/features/note-linking/toMdast"; import { IFilesClient } from "./files"; export interface GetDocumentResponse { @@ -13,6 +15,14 @@ export interface GetDocumentResponse { tags: string[]; } +// table structure of document_links +interface DocumentLinkDb { + documentId: string; + targetId: string; + targetJournal: string; + resolvedAt: string; // todo: unused +} + /** * Structure for searching journal content. */ @@ -241,7 +251,7 @@ updatedAt: ${document.updatedAt} ); if (index) { - return [this.createIndex({ id, ...args }), docPath]; + return [await this.createIndex({ id, ...args }), docPath]; } else { return [id, docPath]; } @@ -257,12 +267,47 @@ updatedAt: ${document.updatedAt} if (origDoc.journal !== args.journal) { // no await, optimistic delete this.files.deleteDocument(args.id!, origDoc.journal); + this.updateDependentLinks([args.id!], args.journal); } - return this.updateIndex(args); + return await this.updateIndex(args); + }; + + // todo: also need to update dependent title, if the title of the original note + // changes...again wikilinks simplify this. + updateDependentLinks = async (documentIds: string[], journal: string) => { + for (const targetId of documentIds) { + const links = await this.knex("document_links").where({ + targetId, + }); + + for (const link of links) { + const dependentNote = await this.findById({ id: link.documentId }); + console.log("udating links for", dependentNote.title, dependentNote.id); + const mdast = parseMarkdown(dependentNote.content); + const noteLinks = selectNoteLinks(mdast); + + // update the note links to point to the new journal + noteLinks.forEach((link) => { + const parsed = parseNoteLink(link.url); + if (!parsed) return; + const { noteId } = parsed; + if (noteId === targetId) { + // update url to new journal + link.url = `../${journal}/${noteId}.md`; + link.journalName = journal; + } + }); + + await this.save({ + ...dependentNote, + content: mdastToString(mdast), + }); + } + } }; - createIndex = ({ + createIndex = async ({ id, createdAt, updatedAt, @@ -270,12 +315,12 @@ updatedAt: ${document.updatedAt} content, title, tags, - }: SaveRequest): string => { + }: SaveRequest): Promise => { if (!id) { throw new Error("id required to create document index"); } - return this.db.transaction(() => { + return this.db.transaction(async () => { this.db .prepare( `INSERT INTO documents (id, journal, content, title, createdAt, updatedAt) VALUES (:id, :journal, :content, :title, :createdAt, :updatedAt)`, @@ -298,11 +343,13 @@ updatedAt: ${document.updatedAt} .run({ documentId: id }); } + await this.addNoteLinks(id, content); + return id; })(); }; - updateIndex = ({ + updateIndex = async ({ id, createdAt, updatedAt, @@ -310,8 +357,8 @@ updatedAt: ${document.updatedAt} content, title, tags, - }: SaveRequest): void => { - return this.db.transaction(() => { + }: SaveRequest): Promise => { + return this.db.transaction(async () => { this.db .prepare( `UPDATE documents SET journal=:journal, content=:content, title=:title, updatedAt=:updatedAt, createdAt=:createdAt WHERE id=:id`, @@ -325,6 +372,7 @@ updatedAt: ${document.updatedAt} createdAt, }); + // re-create tags to avoid diffing this.db .prepare(`DELETE FROM document_tags WHERE documentId = :documentId`) .run({ documentId: id }); @@ -336,9 +384,41 @@ updatedAt: ${document.updatedAt} ) .run({ documentId: id }); } + + // re-create note links to avoid diffing + await this.knex("document_links").where({ documentId: id }).del(); + await this.addNoteLinks(id!, content); })(); }; + private addNoteLinks = async (documentId: string, content: string) => { + const mdast = parseMarkdown(content); + const noteLinks = selectNoteLinks(mdast) + .map((link) => parseNoteLink(link.url)) + .filter(Boolean) as { noteId: string; journalName: string }[]; + + // drop duplicate note links, should only point to a noteId once + const seen = new Set(); + const noteLinksUnique = noteLinks.filter((link) => { + if (seen.has(link.noteId)) { + return false; + } else { + seen.add(link.noteId); + return true; + } + }); + + if (noteLinks.length > 0) { + await this.knex("document_links").insert( + noteLinksUnique.map((link) => ({ + documentId, + targetId: link.noteId, + targetJournal: link.journalName, + })), + ); + } + }; + /** * When removing a journal, call this to de-index all documents from that journal. */ diff --git a/src/preload/client/files.ts b/src/preload/client/files.ts index cd7a39c..cd44a79 100644 --- a/src/preload/client/files.ts +++ b/src/preload/client/files.ts @@ -172,7 +172,17 @@ export class FilesClient { const baseDir = this.settings.get("NOTES_DIR") as string; const newPath = path.join(baseDir, name); - return fs.promises.mkdir(newPath); + try { + await fs.promises.mkdir(newPath, { recursive: true }); + } catch (err) { + // If it already exists, good to go + // note: ts can't find this type: instanceof ErrnoException + if ((err as any).code === "EEXIST") { + return newPath; + } else { + throw err; + } + } }; removeFolder = async (name: string) => { diff --git a/src/preload/client/importer.ts b/src/preload/client/importer.ts index ab902dd..4c06c5d 100644 --- a/src/preload/client/importer.ts +++ b/src/preload/client/importer.ts @@ -18,6 +18,7 @@ export type IImporterClient = ImporterClient; import { uuidv7obj } from "uuidv7"; import { + isNoteLink, mdastToString, parseMarkdownForImport as stringToMdast, } from "../../markdown"; @@ -419,17 +420,6 @@ export class ImporterClient { return linkMapping; }; - // check if a markdown link is a link to a (markdown) note - private isNoteLink = (url: string) => { - // we are only interested in markdown links - if (!url.endsWith(".md")) return false; - - // ensure its not a url with an .md domain - if (url.includes("://")) return false; - - return true; - }; - private updateNoteLinks = async ( mdast: mdast.Root | mdast.Content, item: StagedNote, @@ -440,8 +430,8 @@ export class ImporterClient { ) => { // todo: update ofmWikilink // todo: update links that point to local files - if (mdast.type === "link" && this.isNoteLink(mdast.url)) { - const url = decodeURIComponent(mdast.url); + if (isNoteLink(mdast as mdast.RootContent)) { + const url = decodeURIComponent((mdast as mdast.Link).url); const sourceFolderPath = path.dirname(item.sourcePath); const sourceUrlResolved = path.resolve(sourceFolderPath, url); const mapped = linkMapping[sourceUrlResolved]; diff --git a/src/preload/client/importer/FilesImportResolver.ts b/src/preload/client/importer/FilesImportResolver.ts index cd90f46..843c82d 100644 --- a/src/preload/client/importer/FilesImportResolver.ts +++ b/src/preload/client/importer/FilesImportResolver.ts @@ -3,6 +3,7 @@ import { Knex } from "knex"; import mdast from "mdast"; import path from "path"; import { uuidv7obj } from "uuidv7"; +import { isNoteLink } from "../../../markdown"; import { PathStatsFile } from "../../files"; import { IFilesClient } from "../files"; @@ -135,25 +136,13 @@ export class FilesImportResolver { } }; - // todo: Move this back out to importer, just copy pasted to get things working - // check if a markdown link is a link to a (markdown) note - private isNoteLink = (url: string) => { - // we are only interested in markdown links - if (!url.endsWith(".md")) return false; - - // ensure its not a url with an .md domain - if (url.includes("://")) return false; - - return true; - }; - // Determine if an mdast node is a file link isFileLink = ( mdast: mdast.Content | mdast.Root, ): mdast is mdast.Image | mdast.Link | mdast.OfmWikiEmbedding => { return ( (((mdast.type === "image" || mdast.type === "link") && - !this.isNoteLink(mdast.url)) || + !isNoteLink(mdast)) || mdast.type === "ofmWikiembedding") && !/^(https?|mailto|#|\/|\.|tel|sms|geo|data):/.test(mdast.url) ); diff --git a/src/preload/client/sync.ts b/src/preload/client/sync.ts index 46f9ec2..ac56ef5 100644 --- a/src/preload/client/sync.ts +++ b/src/preload/client/sync.ts @@ -107,6 +107,8 @@ updatedAt: ${document.updatedAt} this.db.exec("delete from document_tags"); this.db.exec("delete from documents"); this.db.exec("delete from journals"); + // should be automatic, from documents delete cascade + this.db.exec("delete from document_links"); const rootDir = await this.preferences.get("NOTES_DIR"); diff --git a/src/views/edit/editor/features/note-linking/toMdast.ts b/src/views/edit/editor/features/note-linking/toMdast.ts index c9b789f..4bc563d 100644 --- a/src/views/edit/editor/features/note-linking/toMdast.ts +++ b/src/views/edit/editor/features/note-linking/toMdast.ts @@ -35,10 +35,11 @@ const noteLinkRegex = /^\..\/(?:(.+)\/)?([a-zA-Z0-9-]+)\.md$/; /** * Check if url conforms to the note link format. * - * ex: `../journal/note_id.md` + * todo: fuse with isNoteLink in markdown/index.ts * + * ex: `../journal/note_id.md` */ -export function checkNoteLink(url: string) { +export function parseNoteLink(url: string) { if (!url) return null; const match = url.match(noteLinkRegex); @@ -64,7 +65,7 @@ export function toSlateNoteLink({ deco, children, }: ToSlateLink) { - const res = checkNoteLink(url); + const res = parseNoteLink(url); if (res) { return {