- Wikilink parser: [[Title]], [[Title|Display]], [[entity_type:Title]] patterns - Notes REST API: CRUD under /api/domains/[domainId]/notes/ with wikilink sync - Note link service: idempotent wikilink resolution, backlinks, outgoing links - Notes list page: search, filter (all/pinned/archived), domain selector - Note editor: TipTap with backlinks panel and outgoing links display - Graph data API: /api/domains/[domainId]/graph and /api/graph - Graph view page: D3 force-directed graph with entity type filters, search, zoom - Keyboard shortcuts: g g → graph, c n → new note - 18 passing wikilink parser tests - All API routes follow AGENTS.md contract (activity + pg_notify)
93 lines
2.7 KiB
TypeScript
93 lines
2.7 KiB
TypeScript
/**
|
|
* Wikilink Parser
|
|
*
|
|
* Parses note content for [[wikilink]] patterns:
|
|
* - [[Title]] — links to a note by title
|
|
* - [[Title|Display]] — links to a note with custom display text
|
|
* - [[entity_type:Title]] — cross-entity links (e.g. [[task:Buy milk]], [[habit:Exercise]])
|
|
*
|
|
* Supported entity types: task, habit, project, note, section, tag
|
|
*/
|
|
|
|
export interface WikilinkMatch {
|
|
/** The full matched text including brackets, e.g. "[[Buy milk]]" */
|
|
raw: string;
|
|
/** Entity type prefix (empty for note links), e.g. "task", "habit" */
|
|
entityType: string;
|
|
/** The target title (after entity type prefix), e.g. "Buy milk" */
|
|
title: string;
|
|
/** Optional display text (after | separator), e.g. "Buy milk" */
|
|
displayText: string | null;
|
|
}
|
|
|
|
/**
|
|
* Regex for matching wikilink patterns:
|
|
* [[Title]] or [[Title|Display]] or [[entity_type:Title]] or [[entity_type:Title|Display]]
|
|
*
|
|
* Group 1: optional entity_type + colon (e.g. "task:")
|
|
* Group 2: the title portion
|
|
* Group 3: optional |display text
|
|
*/
|
|
const WIKILINK_REGEX = /\[\[(?:([a-zA-Z_]+):)?([^\]|]+)(?:\|([^\]]+))?\]\]/g;
|
|
|
|
/**
|
|
* Parse wikilinks from note content.
|
|
* Returns an array of all wikilink matches found.
|
|
*/
|
|
export function parseWikilinks(content: string): WikilinkMatch[] {
|
|
const matches: WikilinkMatch[] = [];
|
|
let match: RegExpExecArray | null;
|
|
|
|
while ((match = WIKILINK_REGEX.exec(content)) !== null) {
|
|
const entityType = (match[1] || '').toLowerCase();
|
|
const title = match[2].trim();
|
|
const displayText = match[3]?.trim() || null;
|
|
|
|
matches.push({
|
|
raw: match[0],
|
|
entityType,
|
|
title,
|
|
displayText,
|
|
});
|
|
}
|
|
|
|
return matches;
|
|
}
|
|
|
|
/**
|
|
* Extract unique link targets from content.
|
|
* Returns deduplicated list of { entityType, title } pairs.
|
|
*/
|
|
export function extractLinkTargets(content: string): { entityType: string; title: string }[] {
|
|
const seen = new Set<string>();
|
|
const targets: { entityType: string; title: string }[] = [];
|
|
|
|
for (const match of parseWikilinks(content)) {
|
|
const key = `${match.entityType}:${match.title}`;
|
|
if (!seen.has(key)) {
|
|
seen.add(key);
|
|
targets.push({ entityType: match.entityType, title: match.title });
|
|
}
|
|
}
|
|
|
|
return targets;
|
|
}
|
|
|
|
/**
|
|
* Resolve a wikilink target to a display-friendly string.
|
|
* For note links: returns the title.
|
|
* For entity links: returns "entity_type: Title".
|
|
*/
|
|
export function formatWikilinkDisplay(match: WikilinkMatch): string {
|
|
if (match.displayText) return match.displayText;
|
|
if (match.entityType) return `${match.entityType}: ${match.title}`;
|
|
return match.title;
|
|
}
|
|
|
|
/**
|
|
* Check if content contains any wikilinks.
|
|
*/
|
|
export function hasWikilinks(content: string): boolean {
|
|
return WIKILINK_REGEX.test(content);
|
|
}
|