forked from mirror/crumbs-original
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);
|
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 { eq, and, like, or, inArray, sql } from 'drizzle-orm';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { extractTags } from '$lib/utils/tags.js';
|
import { extractTags } from '$lib/utils/tags.js';
|
||||||
|
import { extractNoteLinks } from '$lib/utils/note-links.js';
|
||||||
import { fetchTagsForNotes, syncNoteTags } from './tags.js';
|
import { fetchTagsForNotes, syncNoteTags } from './tags.js';
|
||||||
|
import { syncNoteLinks, fetchBacklinksForNote } from './note-links.js';
|
||||||
import { fetchAttachmentsForNotes } from './attachments.js';
|
import { fetchAttachmentsForNotes } from './attachments.js';
|
||||||
import { fetchCollaboratorsForNotes } from './collaborators.js';
|
import { fetchCollaboratorsForNotes } from './collaborators.js';
|
||||||
import { fetchSharesForNotes } from './shares-service.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);
|
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 {
|
export interface CreateNoteInput {
|
||||||
@@ -197,6 +202,9 @@ export function createNote(db: Db, userId: number, input: CreateNoteInput) {
|
|||||||
});
|
});
|
||||||
syncNoteTags(db, id, extractedTags, userId);
|
syncNoteTags(db, id, extractedTags, userId);
|
||||||
|
|
||||||
|
const extractedLinks = extractNoteLinks(content);
|
||||||
|
syncNoteLinks(db, id, extractedLinks);
|
||||||
|
|
||||||
return { ...newNote, tags: extractedTags };
|
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 content = `${updated.title} ${updated.content}`;
|
||||||
const extractedTags = extractTags(content);
|
const extractedTags = extractTags(content);
|
||||||
syncNoteTags(db, id, extractedTags, existing.userId);
|
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