2026-08-01 01:38:25 +00:00
|
|
|
/**
|
|
|
|
|
* Note Link Service
|
|
|
|
|
*
|
|
|
|
|
* Handles wikilink resolution and note_links / note_entity_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, noteLinks, noteEntityLinks, notes, tasks, habits, projects, sections, tags as tagsTable } from "@project-e/db";
|
|
|
|
|
import { and, eq, inArray, isNull, desc } from "drizzle-orm";
|
|
|
|
|
import { extractLinkTargets } from "./wikilink-parser";
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Resolve a single link target to its entity ID.
|
2026-08-10 12:41:44 +00:00
|
|
|
*
|
|
|
|
|
* scoped to the source note's workspace so [[Title]] links never resolve to an
|
|
|
|
|
* entity in a different domain of the same user.
|
2026-08-01 01:38:25 +00:00
|
|
|
*/
|
2026-08-10 12:41:44 +00:00
|
|
|
async function resolveTarget(entityType: string, title: string, domainId: string): Promise<{ entityId: string; entityType: string } | null> {
|
2026-08-01 01:38:25 +00:00
|
|
|
const trimmedTitle = title.trim();
|
|
|
|
|
|
|
|
|
|
if (!entityType) {
|
|
|
|
|
const [note] = await db
|
|
|
|
|
.select({ id: notes.id })
|
|
|
|
|
.from(notes)
|
2026-08-10 12:41:44 +00:00
|
|
|
.where(and(eq(notes.title, trimmedTitle), eq(notes.domainId, domainId), isNull(notes.deletedAt)))
|
2026-08-01 01:38:25 +00:00
|
|
|
.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)
|
2026-08-10 12:41:44 +00:00
|
|
|
.where(and(eq(notes.title, trimmedTitle), eq(notes.domainId, domainId), isNull(notes.deletedAt)))
|
2026-08-01 01:38:25 +00:00
|
|
|
.limit(1);
|
|
|
|
|
if (note) return { entityId: note.id, entityType: "note" };
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
case "task": {
|
|
|
|
|
const [task] = await db
|
|
|
|
|
.select({ id: tasks.id })
|
|
|
|
|
.from(tasks)
|
2026-08-10 12:41:44 +00:00
|
|
|
.where(and(eq(tasks.title, trimmedTitle), eq(tasks.domainId, domainId), isNull(tasks.deletedAt)))
|
2026-08-01 01:38:25 +00:00
|
|
|
.limit(1);
|
|
|
|
|
if (task) return { entityId: task.id, entityType: "task" };
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
case "habit": {
|
|
|
|
|
const [habit] = await db
|
|
|
|
|
.select({ id: habits.id })
|
|
|
|
|
.from(habits)
|
2026-08-10 12:41:44 +00:00
|
|
|
.where(and(eq(habits.name, trimmedTitle), eq(habits.domainId, domainId), isNull(habits.deletedAt)))
|
2026-08-01 01:38:25 +00:00
|
|
|
.limit(1);
|
|
|
|
|
if (habit) return { entityId: habit.id, entityType: "habit" };
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
case "project": {
|
|
|
|
|
const [project] = await db
|
|
|
|
|
.select({ id: projects.id })
|
|
|
|
|
.from(projects)
|
2026-08-10 12:41:44 +00:00
|
|
|
.where(and(eq(projects.name, trimmedTitle), eq(projects.domainId, domainId), isNull(projects.deletedAt)))
|
2026-08-01 01:38:25 +00:00
|
|
|
.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.
|
|
|
|
|
*/
|
2026-08-10 12:41:44 +00:00
|
|
|
export async function syncNoteLinks(noteId: string, content: string, domainId: string): Promise<void> {
|
2026-08-01 01:38:25 +00:00
|
|
|
const targets = extractLinkTargets(content);
|
|
|
|
|
|
|
|
|
|
const resolvedTargets: { entityType: string; entityId: string }[] = [];
|
|
|
|
|
for (const target of targets) {
|
2026-08-10 12:41:44 +00:00
|
|
|
const resolved = await resolveTarget(target.entityType, target.title, domainId);
|
2026-08-01 01:38:25 +00:00
|
|
|
if (resolved) {
|
|
|
|
|
resolvedTargets.push(resolved);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const noteToNoteLinks = resolvedTargets.filter(t => t.entityType === "note");
|
|
|
|
|
const entityLinks = resolvedTargets.filter(t => t.entityType !== "note");
|
|
|
|
|
|
|
|
|
|
// --- Sync note_links ---
|
|
|
|
|
const existingNoteLinks = await db
|
|
|
|
|
.select({ targetNoteId: noteLinks.targetNoteId })
|
|
|
|
|
.from(noteLinks)
|
|
|
|
|
.where(eq(noteLinks.sourceNoteId, noteId));
|
|
|
|
|
|
|
|
|
|
const existingTargetIds = new Set(existingNoteLinks.map(l => l.targetNoteId));
|
|
|
|
|
const newTargetIds = new Set(noteToNoteLinks.map(l => l.entityId));
|
|
|
|
|
|
|
|
|
|
const staleTargetIds = [...existingTargetIds].filter(id => !newTargetIds.has(id));
|
|
|
|
|
if (staleTargetIds.length > 0) {
|
|
|
|
|
await db
|
|
|
|
|
.delete(noteLinks)
|
|
|
|
|
.where(and(
|
|
|
|
|
eq(noteLinks.sourceNoteId, noteId),
|
|
|
|
|
inArray(noteLinks.targetNoteId, staleTargetIds),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const missingTargetIds = [...newTargetIds].filter(id => !existingTargetIds.has(id));
|
|
|
|
|
if (missingTargetIds.length > 0) {
|
|
|
|
|
await db.insert(noteLinks).values(
|
|
|
|
|
missingTargetIds.map(targetNoteId => ({ sourceNoteId: noteId, targetNoteId }))
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Sync note_entity_links ---
|
|
|
|
|
const existingEntityLinks = await db
|
|
|
|
|
.select({ entityType: noteEntityLinks.entityType, entityId: noteEntityLinks.entityId })
|
|
|
|
|
.from(noteEntityLinks)
|
|
|
|
|
.where(eq(noteEntityLinks.noteId, noteId));
|
|
|
|
|
|
|
|
|
|
const existingEntityKeySet = new Set(existingEntityLinks.map(l => `${l.entityType}:${l.entityId}`));
|
|
|
|
|
const newEntityKeySet = new Set(entityLinks.map(l => `${l.entityType}:${l.entityId}`));
|
|
|
|
|
|
|
|
|
|
const staleEntityLinks = existingEntityLinks.filter(l => !newEntityKeySet.has(`${l.entityType}:${l.entityId}`));
|
|
|
|
|
for (const link of staleEntityLinks) {
|
|
|
|
|
await db
|
|
|
|
|
.delete(noteEntityLinks)
|
|
|
|
|
.where(and(
|
|
|
|
|
eq(noteEntityLinks.noteId, noteId),
|
|
|
|
|
eq(noteEntityLinks.entityType, link.entityType),
|
|
|
|
|
eq(noteEntityLinks.entityId, link.entityId),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const missingEntityLinks = entityLinks.filter(l => !existingEntityKeySet.has(`${l.entityType}:${l.entityId}`));
|
|
|
|
|
if (missingEntityLinks.length > 0) {
|
|
|
|
|
await db.insert(noteEntityLinks).values(
|
|
|
|
|
missingEntityLinks.map(l => ({ noteId, entityType: l.entityType, entityId: l.entityId }))
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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(noteLinks)
|
|
|
|
|
.innerJoin(notes, eq(noteLinks.sourceNoteId, notes.id))
|
|
|
|
|
.where(and(
|
|
|
|
|
eq(noteLinks.targetNoteId, noteId),
|
|
|
|
|
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 noteLinkRows = await db
|
|
|
|
|
.select({ id: notes.id, title: notes.title })
|
|
|
|
|
.from(noteLinks)
|
|
|
|
|
.innerJoin(notes, eq(noteLinks.targetNoteId, notes.id))
|
|
|
|
|
.where(and(
|
|
|
|
|
eq(noteLinks.sourceNoteId, noteId),
|
|
|
|
|
isNull(notes.deletedAt),
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
const entityLinkRows = await db
|
|
|
|
|
.select({ entityType: noteEntityLinks.entityType, entityId: noteEntityLinks.entityId })
|
|
|
|
|
.from(noteEntityLinks)
|
|
|
|
|
.where(eq(noteEntityLinks.noteId, noteId));
|
|
|
|
|
|
|
|
|
|
const entityLinksWithTitles: { entityType: string; entityId: string; title: string | null }[] = [];
|
|
|
|
|
for (const link of entityLinkRows) {
|
|
|
|
|
let title: string | null = null;
|
|
|
|
|
switch (link.entityType) {
|
|
|
|
|
case "task": {
|
|
|
|
|
const [t] = await db.select({ title: tasks.title }).from(tasks).where(eq(tasks.id, link.entityId)).limit(1);
|
|
|
|
|
title = t?.title ?? null;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case "habit": {
|
|
|
|
|
const [h] = await db.select({ name: habits.name }).from(habits).where(eq(habits.id, link.entityId)).limit(1);
|
|
|
|
|
title = h?.name ?? null;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case "project": {
|
|
|
|
|
const [p] = await db.select({ name: projects.name }).from(projects).where(eq(projects.id, link.entityId)).limit(1);
|
|
|
|
|
title = p?.name ?? null;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case "section": {
|
|
|
|
|
const [s] = await db.select({ name: sections.name }).from(sections).where(eq(sections.id, link.entityId)).limit(1);
|
|
|
|
|
title = s?.name ?? null;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case "tag": {
|
|
|
|
|
const [t] = await db.select({ name: tagsTable.name }).from(tagsTable).where(eq(tagsTable.id, link.entityId)).limit(1);
|
|
|
|
|
title = t?.name ?? null;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
entityLinksWithTitles.push({ entityType: link.entityType, entityId: link.entityId, title });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
noteLinks: noteLinkRows,
|
|
|
|
|
entityLinks: entityLinksWithTitles,
|
|
|
|
|
};
|
|
|
|
|
}
|