feat: Phase 4 - Notes + Graph + Wikilinks
- 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)
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Graph Service
|
||||
*
|
||||
* Builds graph data (nodes + edges) for the D3 force-directed graph view.
|
||||
* Includes all entity types: task, habit, project, note, section, tag.
|
||||
* Edges come from: note_links, note_entity_links, task dependencies,
|
||||
* task→project, task→section, task→domain, habit→domain, project→domain.
|
||||
*/
|
||||
|
||||
import { db, notes, noteLinks, noteEntityLinks, tasks, taskDependencies, habits, projects, sections, tags as tagsTable, domains } from '@project-e/db';
|
||||
import { and, eq, inArray, isNull, or } from 'drizzle-orm';
|
||||
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
label: string;
|
||||
type: 'task' | 'habit' | 'project' | 'note' | 'section' | 'tag' | 'domain';
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
source: string;
|
||||
target: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface GraphData {
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
}
|
||||
|
||||
const ENTITY_COLORS: Record<string, string> = {
|
||||
task: '#3b82f6', // blue
|
||||
habit: '#10b981', // green
|
||||
project: '#8b5cf6', // purple
|
||||
note: '#f59e0b', // amber
|
||||
section: '#ec4899', // pink
|
||||
tag: '#6b7280', // gray
|
||||
domain: '#6366f1', // indigo
|
||||
};
|
||||
|
||||
/**
|
||||
* Get graph data for a single domain.
|
||||
*/
|
||||
export async function getGraphData(domainId: string): Promise<GraphData> {
|
||||
const nodes: GraphNode[] = [];
|
||||
const edges: GraphEdge[] = [];
|
||||
const nodeIds = new Set<string>();
|
||||
|
||||
function addNode(id: string, label: string, type: GraphNode['type']) {
|
||||
if (!nodeIds.has(id)) {
|
||||
nodeIds.add(id);
|
||||
nodes.push({ id, label, type, color: ENTITY_COLORS[type] || '#6b7280' });
|
||||
}
|
||||
}
|
||||
|
||||
function addEdge(source: string, target: string, type: string) {
|
||||
if (source !== target) {
|
||||
edges.push({ source, target, type });
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all entities in this domain
|
||||
const [noteRows, taskRows, habitRows, projectRows, sectionRows, tagRows, domainRows] = await Promise.all([
|
||||
db.select({ id: notes.id, title: notes.title }).from(notes).where(and(eq(notes.domainId, domainId), isNull(notes.deletedAt))),
|
||||
db.select({ id: tasks.id, title: tasks.title, projectId: tasks.projectId }).from(tasks).where(and(eq(tasks.domainId, domainId), isNull(tasks.deletedAt))),
|
||||
db.select({ id: habits.id, name: habits.name }).from(habits).where(and(eq(habits.domainId, domainId), isNull(habits.deletedAt))),
|
||||
db.select({ id: projects.id, name: projects.name }).from(projects).where(and(eq(projects.domainId, domainId), isNull(projects.deletedAt))),
|
||||
db.select({ id: sections.id, name: sections.name, projectId: sections.projectId }).from(sections).where(eq(sections.projectId, inArray(sections.projectId, (await db.select({ id: projects.id }).from(projects).where(eq(projects.domainId, domainId))).map(p => p.id)))),
|
||||
db.select({ id: tagsTable.id, name: tagsTable.name }).from(tagsTable),
|
||||
db.select({ id: domains.id, name: domains.name }).from(domains).where(eq(domains.id, domainId)),
|
||||
]);
|
||||
|
||||
// Add domain node
|
||||
for (const d of domainRows) {
|
||||
addNode(d.id, d.name, 'domain');
|
||||
}
|
||||
|
||||
// Add note nodes
|
||||
for (const n of noteRows) {
|
||||
addNode(n.id, n.title, 'note');
|
||||
}
|
||||
|
||||
// Add task nodes
|
||||
for (const t of taskRows) {
|
||||
addNode(t.id, t.title, 'task');
|
||||
}
|
||||
|
||||
// Add habit nodes
|
||||
for (const h of habitRows) {
|
||||
addNode(h.id, h.name, 'habit');
|
||||
}
|
||||
|
||||
// Add project nodes
|
||||
for (const p of projectRows) {
|
||||
addNode(p.id, p.name, 'project');
|
||||
}
|
||||
|
||||
// Add section nodes
|
||||
for (const s of sectionRows) {
|
||||
addNode(s.id, s.name, 'section');
|
||||
}
|
||||
|
||||
// Add tag nodes
|
||||
for (const t of tagRows) {
|
||||
addNode(t.id, t.name, 'tag');
|
||||
}
|
||||
|
||||
// --- Edges ---
|
||||
|
||||
// Note-to-note links
|
||||
const noteIds = noteRows.map(n => n.id);
|
||||
if (noteIds.length > 0) {
|
||||
const linkRows = await db.select()
|
||||
.from(noteLinks)
|
||||
.where(inArray(noteLinks.sourceNoteId, noteIds));
|
||||
for (const l of linkRows) {
|
||||
addEdge(l.sourceNoteId, l.targetNoteId, 'note_link');
|
||||
}
|
||||
}
|
||||
|
||||
// Note-to-entity links
|
||||
if (noteIds.length > 0) {
|
||||
const entityLinkRows = await db.select()
|
||||
.from(noteEntityLinks)
|
||||
.where(inArray(noteEntityLinks.noteId, noteIds));
|
||||
for (const l of entityLinkRows) {
|
||||
addEdge(l.noteId, l.entityId, `note_${l.entityType}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Task dependencies
|
||||
const taskIds = taskRows.map(t => t.id);
|
||||
if (taskIds.length > 0) {
|
||||
const depRows = await db.select()
|
||||
.from(taskDependencies)
|
||||
.where(inArray(taskDependencies.taskId, taskIds));
|
||||
for (const d of depRows) {
|
||||
addEdge(d.taskId, d.dependsOnTaskId, 'depends_on');
|
||||
}
|
||||
}
|
||||
|
||||
// Task → project
|
||||
for (const t of taskRows) {
|
||||
if (t.projectId) {
|
||||
addEdge(t.id, t.projectId, 'task_project');
|
||||
}
|
||||
}
|
||||
|
||||
// Task → domain
|
||||
for (const t of taskRows) {
|
||||
addEdge(t.id, domainId, 'task_domain');
|
||||
}
|
||||
|
||||
// Habit → domain
|
||||
for (const h of habitRows) {
|
||||
addEdge(h.id, domainId, 'habit_domain');
|
||||
}
|
||||
|
||||
// Project → domain
|
||||
for (const p of projectRows) {
|
||||
addEdge(p.id, domainId, 'project_domain');
|
||||
}
|
||||
|
||||
// Note → domain
|
||||
for (const n of noteRows) {
|
||||
addEdge(n.id, domainId, 'note_domain');
|
||||
}
|
||||
|
||||
// Section → project
|
||||
for (const s of sectionRows) {
|
||||
if (s.projectId) {
|
||||
addEdge(s.id, s.projectId, 'section_project');
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get global graph data (all domains).
|
||||
*/
|
||||
export async function getGlobalGraphData(): Promise<GraphData> {
|
||||
const allDomains = await db.select({ id: domains.id }).from(domains);
|
||||
const allNodes: GraphNode[] = [];
|
||||
const allEdges: GraphEdge[] = [];
|
||||
const seenNodeIds = new Set<string>();
|
||||
|
||||
for (const d of allDomains) {
|
||||
const domainGraph = await getGraphData(d.id);
|
||||
for (const node of domainGraph.nodes) {
|
||||
if (!seenNodeIds.has(node.id)) {
|
||||
seenNodeIds.add(node.id);
|
||||
allNodes.push(node);
|
||||
}
|
||||
}
|
||||
allEdges.push(...domainGraph.edges);
|
||||
}
|
||||
|
||||
return { nodes: allNodes, edges: allEdges };
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* 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, ne, or, sql } from 'drizzle-orm';
|
||||
import { extractLinkTargets } from './wikilink-parser';
|
||||
|
||||
/**
|
||||
* Resolve a single link target to its entity ID.
|
||||
* Returns null if no match found.
|
||||
*/
|
||||
async function resolveTarget(entityType: string, title: string): Promise<{ entityId: string; entityType: string } | null> {
|
||||
const trimmedTitle = title.trim();
|
||||
|
||||
if (!entityType) {
|
||||
// Resolve as a note
|
||||
const [note] = await db
|
||||
.select({ id: notes.id })
|
||||
.from(notes)
|
||||
.where(and(eq(notes.title, trimmedTitle), 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), 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), 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), 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), 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.
|
||||
* Idempotent — deletes stale links, inserts new ones.
|
||||
*/
|
||||
export async function syncNoteLinks(noteId: string, content: string): Promise<void> {
|
||||
const targets = extractLinkTargets(content);
|
||||
|
||||
// Resolve all targets to entity IDs
|
||||
const resolvedTargets: { entityType: string; entityId: string }[] = [];
|
||||
for (const target of targets) {
|
||||
const resolved = await resolveTarget(target.entityType, target.title);
|
||||
if (resolved) {
|
||||
resolvedTargets.push(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
// Separate into note-to-note links and entity links
|
||||
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));
|
||||
|
||||
// Delete stale links
|
||||
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),
|
||||
));
|
||||
}
|
||||
|
||||
// Insert new links
|
||||
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}`));
|
||||
|
||||
// Delete stale entity links
|
||||
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),
|
||||
));
|
||||
}
|
||||
|
||||
// Insert new entity links
|
||||
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 || '', noteId),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a short excerpt around the first mention of a note title in content.
|
||||
*/
|
||||
function extractExcerpt(content: string, noteId: string): string {
|
||||
// Try to find [[Title]] pattern
|
||||
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;
|
||||
}
|
||||
|
||||
// Fallback: first 100 chars
|
||||
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 }[];
|
||||
}> {
|
||||
// Note-to-note links
|
||||
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),
|
||||
));
|
||||
|
||||
// Entity links
|
||||
const entityLinkRows = await db
|
||||
.select({ entityType: noteEntityLinks.entityType, entityId: noteEntityLinks.entityId })
|
||||
.from(noteEntityLinks)
|
||||
.where(eq(noteEntityLinks.noteId, noteId));
|
||||
|
||||
// Resolve entity titles
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
Reference in New Issue
Block a user