mindnet_obsidian/src/parser/parseEdgesFromCallouts.ts

113 lines
3.5 KiB
TypeScript
Raw Normal View History

import type { ParsedEdge } from "./types";
// Match edge header: allow spaces between '>' characters (Obsidian uses "> >" for nesting)
const EDGE_HEADER_RE = /^\s*((?:>\s*)+)\s*\[!edge\]\s*(.+?)\s*$/i;
// Match edge header WITHOUT leading '>' (plain line) – so edges outside callout blocks are also found
const EDGE_HEADER_PLAIN_RE = /^\s*\[!edge\]\s+(.+?)\s*$/i;
const TARGET_LINK_RE = /\[\[([^\]]+?)\]\]/g;
/** Sentinel: edge block has no quote level (plain lines); only ends on next edge line or end. */
const PLAIN_EDGE_LEVEL = -1;
/**
* Extract edges from any callout nesting and from plain lines:
* - Edge with callout: > [!edge] <type> (any number of '>' allowed)
* - Edge without callout: [!edge] <type> (so edges outside abstract block are found)
* - Collect targets from subsequent lines while quoteLevel >= edgeLevel (or plain mode)
* - Stop when:
* a) next [!edge] header appears, OR
* b) quoteLevel drops below edgeLevel (block ends), ignoring blank lines, OR
* c) in plain mode: next [!edge] line
*/
export function parseEdgesFromCallouts(markdown: string): ParsedEdge[] {
const lines = markdown.split(/\r?\n/);
const edges: ParsedEdge[] = [];
let current: ParsedEdge | null = null;
let currentEdgeLevel = 0;
const getQuoteLevel = (line: string): number => {
const m = line.match(/^\s*(?:(?:>\s*)+)/);
if (!m || !m[0]) return 0;
return (m[0].match(/>/g) || []).length;
};
const flush = (endLine: number) => {
if (!current) return;
current.lineEnd = endLine;
if (current.targets.length > 0) edges.push(current);
current = null;
currentEdgeLevel = 0;
};
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line === undefined) continue;
// Start of a new edge block (with callout quote)
const edgeMatch = line.match(EDGE_HEADER_RE);
if (edgeMatch && edgeMatch[1] && edgeMatch[2]) {
flush(i - 1);
currentEdgeLevel = (edgeMatch[1].match(/>/g) || []).length;
current = {
rawType: edgeMatch[2].trim(),
targets: [],
lineStart: i,
lineEnd: i,
};
continue;
}
// Start of edge block without '>' (plain line) – e.g. edge written outside abstract block
const plainMatch = line.match(EDGE_HEADER_PLAIN_RE);
if (plainMatch && plainMatch[1]) {
flush(i - 1);
currentEdgeLevel = PLAIN_EDGE_LEVEL;
current = {
rawType: plainMatch[1].trim(),
targets: [],
lineStart: i,
lineEnd: i,
};
continue;
}
if (!current) continue;
const trimmed = line.trim();
const ql = getQuoteLevel(line);
// In plain mode, only end on next edge line (already handled above); collect [[links]] from any line
if (currentEdgeLevel === PLAIN_EDGE_LEVEL) {
TARGET_LINK_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = TARGET_LINK_RE.exec(line)) !== null) {
if (m[1]) {
const t = m[1].trim();
if (t) current.targets.push(t);
}
}
continue;
}
// End of quoted edge block if quote level drops below the edge header level
if (trimmed !== "" && ql < currentEdgeLevel) {
flush(i - 1);
continue;
}
// Collect targets (multiple per line allowed)
TARGET_LINK_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = TARGET_LINK_RE.exec(line)) !== null) {
if (m[1]) {
const t = m[1].trim();
if (t) current.targets.push(t);
}
}
}
flush(lines.length - 1);
return edges;
}