2026-01-15 11:34:29 +01:00
|
|
|
|
import type { ParsedEdge } from "./types";
|
|
|
|
|
|
|
2026-02-06 11:40:44 +01:00
|
|
|
|
// 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;
|
2026-01-15 11:34:29 +01:00
|
|
|
|
const TARGET_LINK_RE = /\[\[([^\]]+?)\]\]/g;
|
|
|
|
|
|
|
2026-02-06 11:40:44 +01:00
|
|
|
|
/** Sentinel: edge block has no quote level (plain lines); only ends on next edge line or end. */
|
|
|
|
|
|
const PLAIN_EDGE_LEVEL = -1;
|
|
|
|
|
|
|
2026-01-15 11:34:29 +01:00
|
|
|
|
/**
|
2026-02-06 11:40:44 +01:00
|
|
|
|
* 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)
|
2026-01-15 11:34:29 +01:00
|
|
|
|
* - Stop when:
|
|
|
|
|
|
* a) next [!edge] header appears, OR
|
2026-02-06 11:40:44 +01:00
|
|
|
|
* b) quoteLevel drops below edgeLevel (block ends), ignoring blank lines, OR
|
|
|
|
|
|
* c) in plain mode: next [!edge] line
|
2026-01-15 11:34:29 +01:00
|
|
|
|
*/
|
|
|
|
|
|
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 => {
|
2026-02-06 11:40:44 +01:00
|
|
|
|
const m = line.match(/^\s*(?:(?:>\s*)+)/);
|
|
|
|
|
|
if (!m || !m[0]) return 0;
|
|
|
|
|
|
return (m[0].match(/>/g) || []).length;
|
2026-01-15 11:34:29 +01:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
2026-02-06 11:40:44 +01:00
|
|
|
|
// Start of a new edge block (with callout quote)
|
2026-01-15 11:34:29 +01:00
|
|
|
|
const edgeMatch = line.match(EDGE_HEADER_RE);
|
|
|
|
|
|
if (edgeMatch && edgeMatch[1] && edgeMatch[2]) {
|
|
|
|
|
|
flush(i - 1);
|
2026-02-06 11:40:44 +01:00
|
|
|
|
currentEdgeLevel = (edgeMatch[1].match(/>/g) || []).length;
|
2026-01-15 11:34:29 +01:00
|
|
|
|
current = {
|
|
|
|
|
|
rawType: edgeMatch[2].trim(),
|
|
|
|
|
|
targets: [],
|
|
|
|
|
|
lineStart: i,
|
|
|
|
|
|
lineEnd: i,
|
|
|
|
|
|
};
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 11:40:44 +01:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 11:34:29 +01:00
|
|
|
|
if (!current) continue;
|
|
|
|
|
|
|
|
|
|
|
|
const trimmed = line.trim();
|
|
|
|
|
|
const ql = getQuoteLevel(line);
|
|
|
|
|
|
|
2026-02-06 11:40:44 +01:00
|
|
|
|
// 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
|
2026-01-15 11:34:29 +01:00
|
|
|
|
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;
|
|
|
|
|
|
}
|