chore(server): start processing bookmarks

This commit is contained in:
Elian Doran
2026-04-18 10:06:32 +03:00
parent c0b1ff31e5
commit 5b957dd111
2 changed files with 83 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { findBookmarks } from "./notes.js";
describe("findBookmarks", () => {
it("extracts bookmark IDs from empty anchor tags", () => {
const content = `<p>Hello</p><a id="chapter-1"></a><p>World</p>`;
expect(findBookmarks(content)).toEqual(["chapter-1"]);
});
it("extracts multiple bookmarks", () => {
const content = `<a id="intro"></a><p>Text</p><a id="conclusion"></a>`;
expect(findBookmarks(content)).toEqual(["intro", "conclusion"]);
});
it("returns empty array when no bookmarks exist", () => {
const content = `<p>No bookmarks here</p>`;
expect(findBookmarks(content)).toEqual([]);
});
it("ignores anchor tags with href (regular links, not bookmarks)", () => {
const content = `<a href="#root/abc123" id="some-id">link</a>`;
expect(findBookmarks(content)).toEqual([]);
});
it("handles bookmarks with various valid ID characters", () => {
const content = `<a id="my_bookmark-2.0"></a>`;
expect(findBookmarks(content)).toEqual(["my_bookmark-2.0"]);
});
it("does not produce duplicates", () => {
const content = `<a id="same"></a><a id="same"></a>`;
expect(findBookmarks(content)).toEqual(["same"]);
});
});

View File

@@ -454,6 +454,54 @@ function findImageLinks(content: string, foundLinks: FoundLink[]) {
return content.replace(/src="[^"]*\/api\/images\//g, 'src="api/images/');
}
/**
* Extracts bookmark IDs from CKEditor bookmark anchors (`<a id="..."></a>` without href).
* Bookmarks are stored as labels on the note so they can be looked up without parsing content.
*/
export function findBookmarks(content: string): string[] {
const re = /<a\s+id="([^"]+)"[^>]*><\/a>/g;
const bookmarks: string[] = [];
let match;
while ((match = re.exec(content))) {
// Skip anchors that also have an href (those are regular links, not bookmarks)
if (match[0].includes("href=")) {
continue;
}
const id = match[1];
if (!bookmarks.includes(id)) {
bookmarks.push(id);
}
}
return bookmarks;
}
function saveBookmarks(note: BNote, content: string) {
const foundBookmarks = findBookmarks(content);
const existingBookmarks = note.getLabels("internalBookmark");
for (const bookmarkId of foundBookmarks) {
const existing = existingBookmarks.find((l) => l.value === bookmarkId);
if (!existing) {
new BAttribute({
noteId: note.noteId,
type: "label",
name: "internalBookmark",
value: bookmarkId
}).save();
}
}
// Remove bookmarks that are no longer in the content
const unusedBookmarks = existingBookmarks.filter((l) => !foundBookmarks.includes(l.value));
for (const unused of unusedBookmarks) {
unused.markAsDeleted();
}
}
function findInternalLinks(content: string, foundLinks: FoundLink[]) {
const re = /href="[^"]*#root[a-zA-Z0-9_\/]*\/([a-zA-Z0-9_]+)\/?"/g;
let match;
@@ -695,6 +743,7 @@ function saveLinks(note: BNote, content: string | Buffer) {
content = findImageLinks(content, foundLinks);
content = findInternalLinks(content, foundLinks);
content = findIncludeNoteLinks(content, foundLinks);
saveBookmarks(note, content);
({ forceFrontendReload, content } = checkImageAttachments(note, content));
} else if (note.type === "relationMap" && typeof content === "string") {