Files
ProjectE/apps/api/src/routes/note-link-service.ts
T
bot-hermes 639306a26f feat: plane-lift schema (states/modules/cycles/links)
Phase 1 of the Plane feature lift into Project E.

Schema changes:
- Add stateGroupEnum, moduleStatusEnum, linkTypeEnum
- Add states table (per-project workflow states with group enum)
- Add modules table (project-scoped planning buckets)
- Add cycles table (time-boxed sprints)
- Add links table (canonical cross-entity mesh)
- Drop taskStatusEnum and tasks.status column
- Add stateId, moduleId, cycleId FKs to tasks
- Drop taskDependencies, noteLinks, noteEntityLinks tables

Project creation bootstrap:
- Seed 5 default states (Backlog/Todo/In Progress/Done/Cancelled) on new project

Minimal API fixes for typecheck:
- Remove references to dropped tables/columns
- Replace status-based queries with completedAt checks
- Stub deprecated dependency/status endpoints for Phase 2

Drizzle migration: 0008_plane-lift-schema.sql (custom, big-bang)
2026-09-07 17:02:04 +00:00

243 lines
8.2 KiB
TypeScript

/**
* 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<void> {
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,
};
}