Skip to content

Commit

Permalink
udpate note links when journal changes (#277)
Browse files Browse the repository at this point in the history
- when changing journal of note A, find all notes that point to it and update their links too
  • Loading branch information
cloverich authored Dec 8, 2024
1 parent fe301bd commit 7d16a3a
Show file tree
Hide file tree
Showing 8 changed files with 150 additions and 38 deletions.
11 changes: 11 additions & 0 deletions src/electron/migrations/20211005142122.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
29 changes: 29 additions & 0 deletions src/markdown/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
96 changes: 88 additions & 8 deletions src/preload/client/documents.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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.
*/
Expand Down Expand Up @@ -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];
}
Expand All @@ -257,25 +267,60 @@ 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<DocumentLinkDb>("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,
journal,
content,
title,
tags,
}: SaveRequest): string => {
}: SaveRequest): Promise<string> => {
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)`,
Expand All @@ -298,20 +343,22 @@ updatedAt: ${document.updatedAt}
.run({ documentId: id });
}

await this.addNoteLinks(id, content);

return id;
})();
};

updateIndex = ({
updateIndex = async ({
id,
createdAt,
updatedAt,
journal,
content,
title,
tags,
}: SaveRequest): void => {
return this.db.transaction(() => {
}: SaveRequest): Promise<void> => {
return this.db.transaction(async () => {
this.db
.prepare(
`UPDATE documents SET journal=:journal, content=:content, title=:title, updatedAt=:updatedAt, createdAt=:createdAt WHERE id=:id`,
Expand All @@ -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 });
Expand All @@ -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<string>();
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.
*/
Expand Down
12 changes: 11 additions & 1 deletion src/preload/client/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
16 changes: 3 additions & 13 deletions src/preload/client/importer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export type IImporterClient = ImporterClient;

import { uuidv7obj } from "uuidv7";
import {
isNoteLink,
mdastToString,
parseMarkdownForImport as stringToMdast,
} from "../../markdown";
Expand Down Expand Up @@ -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,
Expand All @@ -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];
Expand Down
15 changes: 2 additions & 13 deletions src/preload/client/importer/FilesImportResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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)
);
Expand Down
2 changes: 2 additions & 0 deletions src/preload/client/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
7 changes: 4 additions & 3 deletions src/views/edit/editor/features/note-linking/toMdast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -64,7 +65,7 @@ export function toSlateNoteLink({
deco,
children,
}: ToSlateLink) {
const res = checkNoteLink(url);
const res = parseNoteLink(url);

if (res) {
return {
Expand Down

0 comments on commit 7d16a3a

Please sign in to comment.