diff --git a/apps/server/src/services/notes.spec.ts b/apps/server/src/services/notes.spec.ts new file mode 100644 index 0000000000..f19b9be4b7 --- /dev/null +++ b/apps/server/src/services/notes.spec.ts @@ -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 = `

Hello

World

`; + expect(findBookmarks(content)).toEqual(["chapter-1"]); + }); + + it("extracts multiple bookmarks", () => { + const content = `

Text

`; + expect(findBookmarks(content)).toEqual(["intro", "conclusion"]); + }); + + it("returns empty array when no bookmarks exist", () => { + const content = `

No bookmarks here

`; + expect(findBookmarks(content)).toEqual([]); + }); + + it("ignores anchor tags with href (regular links, not bookmarks)", () => { + const content = `link`; + expect(findBookmarks(content)).toEqual([]); + }); + + it("handles bookmarks with various valid ID characters", () => { + const content = ``; + expect(findBookmarks(content)).toEqual(["my_bookmark-2.0"]); + }); + + it("does not produce duplicates", () => { + const content = ``; + expect(findBookmarks(content)).toEqual(["same"]); + }); +}); diff --git a/apps/server/src/services/notes.ts b/apps/server/src/services/notes.ts index 708cab285d..719afbe225 100644 --- a/apps/server/src/services/notes.ts +++ b/apps/server/src/services/notes.ts @@ -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 (`` 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>/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") {