mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 12:06:57 +02:00
server functions and service wiring on save and attach backlinks
This commit is contained in:
committed by
Fred Cabassut
parent
1ca2ba5b79
commit
1eebd009ac
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Extract crumb-note://<id> 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)];
|
||||
}
|
||||
Reference in New Issue
Block a user