diff --git a/src/lib/server/note-links.ts b/src/lib/server/note-links.ts new file mode 100644 index 0000000..d11278a --- /dev/null +++ b/src/lib/server/note-links.ts @@ -0,0 +1,48 @@ +import type { Db } from './db/index.js'; +import { noteLinks, notes } from './db/schema.js'; +import { eq, inArray } from 'drizzle-orm'; +import { canAccessNote } from './api-utils.js'; + +/** + * Sync link rows for a note: removes old associations, inserts the current set. + * Mirrors syncNoteTags — called right after a note's content is saved. + */ +export function syncNoteLinks(db: Db, noteId: string, targetIds: string[]): void { + db.delete(noteLinks).where(eq(noteLinks.sourceNoteId, noteId)).run(); + + for (const targetId of targetIds) { + db.insert(noteLinks).values({ sourceNoteId: noteId, targetNoteId: targetId }).run(); + } +} + +/** + * Find notes that link to the given note, scoped to what the requesting user + * can access — this both respects normal sharing boundaries and, as a side + * effect, prevents an unrelated note from appearing in someone's backlinks + * just because it happens to reference a note ID it was never shared to see. + */ +export function fetchBacklinksForNote( + db: Db, + noteId: string, + userId: number +): { id: string; title: string }[] { + const linkRows = db + .select({ sourceNoteId: noteLinks.sourceNoteId }) + .from(noteLinks) + .where(eq(noteLinks.targetNoteId, noteId)) + .all(); + + if (linkRows.length === 0) return []; + + const sourceIds = linkRows.map((r) => r.sourceNoteId); + const accessibleIds = sourceIds.filter((id) => canAccessNote(db, id, userId).canAccess); + if (accessibleIds.length === 0) return []; + + const sourceNotes = db + .select({ id: notes.id, title: notes.title }) + .from(notes) + .where(inArray(notes.id, accessibleIds)) + .all(); + + return sourceNotes; +} diff --git a/src/lib/utils/note-links.test.ts b/src/lib/utils/note-links.test.ts new file mode 100644 index 0000000..67f0f9b --- /dev/null +++ b/src/lib/utils/note-links.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { extractNoteLinks } from './note-links.js'; + +describe('extractNoteLinks', () => { + it('should extract a single note-link reference', () => { + expect(extractNoteLinks('See [Grocery List](crumb-note://abc-123)')).toEqual(['abc-123']); + }); + + it('should extract multiple distinct references', () => { + const content = '[A](crumb-note://id-1) and [B](crumb-note://id-2)'; + expect(extractNoteLinks(content)).toEqual(['id-1', 'id-2']); + }); + + it('should deduplicate repeated references', () => { + const content = '[A](crumb-note://id-1) again: [A again](crumb-note://id-1)'; + expect(extractNoteLinks(content)).toEqual(['id-1']); + }); + + it('should return an empty array for content with no note-links', () => { + expect(extractNoteLinks('Just [a normal link](https://example.com)')).toEqual([]); + }); + + it('should return an empty array for empty content', () => { + expect(extractNoteLinks('')).toEqual([]); + }); + + it('should not match crumb-note:// occurring inside a code block', () => { + const content = '```\n[A](crumb-note://id-1)\n```'; + expect(extractNoteLinks(content)).toEqual([]); + }); + + it('should not match crumb-note:// occurring inside inline code', () => { + expect(extractNoteLinks('Use `crumb-note://id-1` as an example')).toEqual([]); + }); +});