From 1eebd009ac0bf19aa9876af3c047fe6c6e07f683 Mon Sep 17 00:00:00 2001 From: Arkaprava Gaine Date: Tue, 4 Aug 2026 20:36:05 +0530 Subject: [PATCH] server functions and service wiring on save and attach backlinks --- src/lib/server/note-links.test.ts | 98 ++++++++++++++++++++++++++++ src/lib/server/notes-service.test.ts | 42 ++++++++++++ src/lib/server/notes-service.ts | 12 +++- src/lib/utils/note-links.ts | 17 +++++ 4 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 src/lib/server/note-links.test.ts create mode 100644 src/lib/utils/note-links.ts diff --git a/src/lib/server/note-links.test.ts b/src/lib/server/note-links.test.ts new file mode 100644 index 0000000..ab0f5e3 --- /dev/null +++ b/src/lib/server/note-links.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createTestDb } from './db/test-helpers.js'; +import { notes, noteLinks, noteCollaborators, users } from './db/schema.js'; +import type { Db } from './db/index.js'; +import { syncNoteLinks, fetchBacklinksForNote } from './note-links.js'; + +let db: Db; +const OWNER_ID = 1; +const OTHER_USER_ID = 2; + +function seedUser(id: number, email: string) { + db.insert(users) + .values({ id, email, displayName: email, role: 'user', authProvider: 'password', createdAt: new Date() }) + .run(); +} + +function seedNote(id: string, userId: number, title = '') { + db.insert(notes) + .values({ id, userId, title, content: '', createdAt: new Date(), updatedAt: new Date() }) + .run(); +} + +beforeEach(() => { + const testDb = createTestDb(); + db = testDb.db; + seedUser(OWNER_ID, 'owner@test.com'); + seedUser(OTHER_USER_ID, 'other@test.com'); +}); + +describe('syncNoteLinks', () => { + it('should create link rows for each target', () => { + seedNote('source', OWNER_ID); + seedNote('target-1', OWNER_ID); + seedNote('target-2', OWNER_ID); + + syncNoteLinks(db, 'source', ['target-1', 'target-2']); + + const rows = db.select().from(noteLinks).all(); + expect(rows).toHaveLength(2); + }); + + it('should remove old links and add new ones on re-sync', () => { + seedNote('source', OWNER_ID); + seedNote('target-1', OWNER_ID); + seedNote('target-2', OWNER_ID); + + syncNoteLinks(db, 'source', ['target-1']); + syncNoteLinks(db, 'source', ['target-2']); + + const rows = db.select().from(noteLinks).all(); + expect(rows).toEqual([{ sourceNoteId: 'source', targetNoteId: 'target-2' }]); + }); + + it('should handle an empty target list (removes all links)', () => { + seedNote('source', OWNER_ID); + seedNote('target-1', OWNER_ID); + + syncNoteLinks(db, 'source', ['target-1']); + syncNoteLinks(db, 'source', []); + + expect(db.select().from(noteLinks).all()).toEqual([]); + }); +}); + +describe('fetchBacklinksForNote', () => { + it('should return notes that link to the given note', () => { + seedNote('source', OWNER_ID, 'Source Note'); + seedNote('target', OWNER_ID, 'Target Note'); + syncNoteLinks(db, 'source', ['target']); + + const backlinks = fetchBacklinksForNote(db, 'target', OWNER_ID); + expect(backlinks).toEqual([{ id: 'source', title: 'Source Note' }]); + }); + + it('should return an empty array when nothing links to the note', () => { + seedNote('target', OWNER_ID, 'Target Note'); + expect(fetchBacklinksForNote(db, 'target', OWNER_ID)).toEqual([]); + }); + + it('should exclude a backlink from a source note the requesting user cannot access', () => { + seedNote('source', OTHER_USER_ID, 'Other Users Note'); + seedNote('target', OWNER_ID, 'Target Note'); + syncNoteLinks(db, 'source', ['target']); + + expect(fetchBacklinksForNote(db, 'target', OWNER_ID)).toEqual([]); + }); + + it('should include a backlink from a source note shared with the requesting user', () => { + seedNote('source', OTHER_USER_ID, 'Shared Source'); + seedNote('target', OWNER_ID, 'Target Note'); + syncNoteLinks(db, 'source', ['target']); + db.insert(noteCollaborators) + .values({ noteId: 'source', userId: OWNER_ID, addedBy: OTHER_USER_ID, addedAt: new Date() }) + .run(); + + expect(fetchBacklinksForNote(db, 'target', OWNER_ID)).toEqual([{ id: 'source', title: 'Shared Source' }]); + }); +}); diff --git a/src/lib/server/notes-service.test.ts b/src/lib/server/notes-service.test.ts index d3bc084..ebe5c96 100644 --- a/src/lib/server/notes-service.test.ts +++ b/src/lib/server/notes-service.test.ts @@ -374,3 +374,45 @@ describe('reorderNotes', () => { expect(collabView!.sortOrder).toBe(5); }); }); + +describe('note links', () => { + it('should sync note-links when a note is created with a reference', () => { + const target = createNote(db, OWNER_ID, { title: 'Target' }); + const source = createNote(db, OWNER_ID, { + title: 'Source', + content: `See [Target](crumb-note://${target.id})` + }); + + const reopened = getNote(db, OWNER_ID, target.id); + expect(reopened?.backlinks).toEqual([{ id: source.id, title: 'Source' }]); + }); + + it('should sync note-links when a note is updated with a reference', () => { + const target = createNote(db, OWNER_ID, { title: 'Target' }); + const source = createNote(db, OWNER_ID, { title: 'Source', content: '' }); + + updateNote(db, OWNER_ID, source.id, { content: `See [Target](crumb-note://${target.id})` }); + + const reopened = getNote(db, OWNER_ID, target.id); + expect(reopened?.backlinks).toEqual([{ id: source.id, title: 'Source' }]); + }); + + it('should remove a backlink when the referencing note is edited to drop the reference', () => { + const target = createNote(db, OWNER_ID, { title: 'Target' }); + const source = createNote(db, OWNER_ID, { + title: 'Source', + content: `See [Target](crumb-note://${target.id})` + }); + + updateNote(db, OWNER_ID, source.id, { content: 'No longer references anything' }); + + const reopened = getNote(db, OWNER_ID, target.id); + expect(reopened?.backlinks).toEqual([]); + }); + + it('should return an empty backlinks array for a note nothing links to', () => { + const note = createNote(db, OWNER_ID, { title: 'Lonely note' }); + const reopened = getNote(db, OWNER_ID, note.id); + expect(reopened?.backlinks).toEqual([]); + }); +}); diff --git a/src/lib/server/notes-service.ts b/src/lib/server/notes-service.ts index 0d6b6e9..fc61861 100644 --- a/src/lib/server/notes-service.ts +++ b/src/lib/server/notes-service.ts @@ -3,7 +3,9 @@ import { notes, noteTags, tags, noteCollaborators, noteUserState } from './db/sc import { eq, and, like, or, inArray, sql } from 'drizzle-orm'; import { v4 as uuidv4 } from 'uuid'; import { extractTags } from '$lib/utils/tags.js'; +import { extractNoteLinks } from '$lib/utils/note-links.js'; import { fetchTagsForNotes, syncNoteTags } from './tags.js'; +import { syncNoteLinks, fetchBacklinksForNote } from './note-links.js'; import { fetchAttachmentsForNotes } from './attachments.js'; import { fetchCollaboratorsForNotes } from './collaborators.js'; import { fetchSharesForNotes } from './shares-service.js'; @@ -155,7 +157,10 @@ export function getNote(db: Db, userId: number, id: string) { } const result = hydrateNotes(db, [effectiveNote], userId); - return result[0] ?? null; + const hydrated = result[0]; + if (!hydrated) return null; + + return { ...hydrated, backlinks: fetchBacklinksForNote(db, id, userId) }; } export interface CreateNoteInput { @@ -197,6 +202,9 @@ export function createNote(db: Db, userId: number, input: CreateNoteInput) { }); syncNoteTags(db, id, extractedTags, userId); + const extractedLinks = extractNoteLinks(content); + syncNoteLinks(db, id, extractedLinks); + return { ...newNote, tags: extractedTags }; } @@ -326,6 +334,8 @@ export function updateNote(db: Db, userId: number, id: string, input: UpdateNote const content = `${updated.title} ${updated.content}`; const extractedTags = extractTags(content); syncNoteTags(db, id, extractedTags, existing.userId); + const extractedLinks = extractNoteLinks(content); + syncNoteLinks(db, id, extractedLinks); } } diff --git a/src/lib/utils/note-links.ts b/src/lib/utils/note-links.ts new file mode 100644 index 0000000..f6154ac --- /dev/null +++ b/src/lib/utils/note-links.ts @@ -0,0 +1,17 @@ +/** + * Extract crumb-note:// references from markdown content. + * Mirrors extractTags' code-block/inline-code exclusion so a reference + * mentioned as an example inside a code sample isn't treated as a real link. + */ +export function extractNoteLinks(content: string): string[] { + if (!content) return []; + + const withoutCodeBlocks = content.replace(/```[\s\S]*?```/g, ''); + const withoutInlineCode = withoutCodeBlocks.replace(/`[^`]*`/g, ''); + + const matches = withoutInlineCode.match(/crumb-note:\/\/([\w-]+)/g); + if (!matches) return []; + + const ids = matches.map((m) => m.replace('crumb-note://', '')); + return [...new Set(ids)]; +}