/** * Note Link Service * * Handles wikilink resolution and links management. * On note save, parses content for [[wikilinks]], resolves each to a note_id or entity_id, * and diffs the existing links to produce idempotent deletes+inserts. */ import { db, links, notes, tasks, habits, projects, sections, tags as tagsTable } from "@project-e/db"; import { and, eq, inArray, isNull } from "drizzle-orm"; import { extractLinkTargets } from "./wikilink-parser"; /** * Resolve a single link target to its entity ID. * * scoped to the source note's workspace so [[Title]] links never resolve to an * entity in a different domain of the same user. */ async function resolveTarget(entityType: string, title: string, domainId: string): Promise<{ entityId: string; entityType: string } | null> { const trimmedTitle = title.trim(); if (!entityType) { const [note] = await db .select({ id: notes.id }) .from(notes) .where(and(eq(notes.title, trimmedTitle), eq(notes.domainId, domainId), isNull(notes.deletedAt))) .limit(1); if (note) return { entityId: note.id, entityType: "note" }; return null; } switch (entityType) { case "note": { const [note] = await db .select({ id: notes.id }) .from(notes) .where(and(eq(notes.title, trimmedTitle), eq(notes.domainId, domainId), isNull(notes.deletedAt))) .limit(1); if (note) return { entityId: note.id, entityType: "note" }; return null; } case "task": { const [task] = await db .select({ id: tasks.id }) .from(tasks) .where(and(eq(tasks.title, trimmedTitle), eq(tasks.domainId, domainId), isNull(tasks.deletedAt))) .limit(1); if (task) return { entityId: task.id, entityType: "task" }; return null; } case "habit": { const [habit] = await db .select({ id: habits.id }) .from(habits) .where(and(eq(habits.name, trimmedTitle), eq(habits.domainId, domainId), isNull(habits.deletedAt))) .limit(1); if (habit) return { entityId: habit.id, entityType: "habit" }; return null; } case "project": { const [project] = await db .select({ id: projects.id }) .from(projects) .where(and(eq(projects.name, trimmedTitle), eq(projects.domainId, domainId), isNull(projects.deletedAt))) .limit(1); if (project) return { entityId: project.id, entityType: "project" }; return null; } case "section": { const [section] = await db .select({ id: sections.id }) .from(sections) .where(eq(sections.name, trimmedTitle)) .limit(1); if (section) return { entityId: section.id, entityType: "section" }; return null; } case "tag": { const [tag] = await db .select({ id: tagsTable.id }) .from(tagsTable) .where(eq(tagsTable.name, trimmedTitle)) .limit(1); if (tag) return { entityId: tag.id, entityType: "tag" }; return null; } default: return null; } } /** * Sync wikilinks for a note: parse content, resolve targets, diff existing links. */ export async function syncNoteLinks(noteId: string, content: string, domainId: string): Promise { const targets = extractLinkTargets(content); const resolvedTargets: { entityType: string; entityId: string }[] = []; for (const target of targets) { const resolved = await resolveTarget(target.entityType, target.title, domainId); if (resolved) { resolvedTargets.push(resolved); } } // --- Sync all links from this note via the canonical links table --- const existingLinks = await db .select({ targetId: links.targetId, targetType: links.targetType }) .from(links) .where(and(eq(links.sourceId, noteId), eq(links.sourceType, "note"))); const existingKeySet = new Set(existingLinks.map(l => `${l.targetType}:${l.targetId}`)); const newKeySet = new Set(resolvedTargets.map(l => `${l.entityType}:${l.entityId}`)); // Delete stale links const staleLinks = existingLinks.filter(l => !newKeySet.has(`${l.targetType}:${l.targetId}`)); if (staleLinks.length > 0) { const staleIds = staleLinks.map(l => l.targetId); await db .delete(links) .where(and( eq(links.sourceId, noteId), eq(links.sourceType, "note"), inArray(links.targetId, staleIds), )); } // Insert missing links const missingTargets = resolvedTargets.filter(l => !existingKeySet.has(`${l.entityType}:${l.entityId}`)); if (missingTargets.length > 0) { await db.insert(links).values( missingTargets.map(l => ({ sourceType: "note", sourceId: noteId, targetType: l.entityType, targetId: l.entityId, linkType: "relates" as const, })) ); } } /** * Get backlinks for a note — notes that link to this note. */ export async function getBacklinks(noteId: string): Promise<{ id: string; title: string; excerpt: string }[]> { const rows = await db .select({ id: notes.id, title: notes.title, content: notes.content, }) .from(links) .innerJoin(notes, eq(links.sourceId, notes.id)) .where(and( eq(links.targetId, noteId), eq(links.sourceType, "note"), eq(links.targetType, "note"), isNull(notes.deletedAt), )); return rows.map(row => ({ id: row.id, title: row.title, excerpt: extractExcerpt(row.content || "", row.title), })); } function extractExcerpt(content: string, title: string): string { const linkMatch = content.match(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/); if (linkMatch) { const idx = content.indexOf(linkMatch[0]); const start = Math.max(0, idx - 40); const end = Math.min(content.length, idx + linkMatch[0].length + 40); let excerpt = content.slice(start, end).replace(/\n/g, " "); if (start > 0) excerpt = "..." + excerpt; if (end < content.length) excerpt = excerpt + "..."; return excerpt; } return content.slice(0, 100).replace(/\n/g, " ") + (content.length > 100 ? "..." : ""); } /** * Get all outgoing links for a note. */ export async function getOutgoingLinks(noteId: string): Promise<{ noteLinks: { id: string; title: string }[]; entityLinks: { entityType: string; entityId: string; title: string | null }[]; }> { const outgoingLinks = await db .select({ targetId: links.targetId, targetType: links.targetType }) .from(links) .where(and(eq(links.sourceId, noteId), eq(links.sourceType, "note"))); const noteLinkRows: { id: string; title: string }[] = []; const entityLinksWithTitles: { entityType: string; entityId: string; title: string | null }[] = []; for (const link of outgoingLinks) { if (link.targetType === "note") { const [note] = await db.select({ id: notes.id, title: notes.title }) .from(notes) .where(and(eq(notes.id, link.targetId), isNull(notes.deletedAt))) .limit(1); if (note) noteLinkRows.push({ id: note.id, title: note.title }); } else { let title: string | null = null; switch (link.targetType) { case "task": { const [t] = await db.select({ title: tasks.title }).from(tasks).where(eq(tasks.id, link.targetId)).limit(1); title = t?.title ?? null; break; } case "habit": { const [h] = await db.select({ name: habits.name }).from(habits).where(eq(habits.id, link.targetId)).limit(1); title = h?.name ?? null; break; } case "project": { const [p] = await db.select({ name: projects.name }).from(projects).where(eq(projects.id, link.targetId)).limit(1); title = p?.name ?? null; break; } case "section": { const [s] = await db.select({ name: sections.name }).from(sections).where(eq(sections.id, link.targetId)).limit(1); title = s?.name ?? null; break; } case "tag": { const [t] = await db.select({ name: tagsTable.name }).from(tagsTable).where(eq(tagsTable.id, link.targetId)).limit(1); title = t?.name ?? null; break; } } entityLinksWithTitles.push({ entityType: link.targetType, entityId: link.targetId, title }); } } return { noteLinks: noteLinkRows, entityLinks: entityLinksWithTitles, }; }