// lib/editor/link/link_graph.dart // // Pure bidirectional-link (双链) core for the sticky-note / board system (F7). // Notes reference each other with `[[target]]` wiki-style links; this builds the // forward + backlink indices so a note can show "what links here" (backlinks). // // Deliberately pure + widget-free + storage-free: nodes are (id, text) pairs and // links are matched by the bracketed TARGET string (a note title or id). The // board UI + persistence wrap this; the graph logic is fully unit-testable. /// Matches `[[target]]` spans. The target is everything up to the first `]`, /// trimmed; empty targets (`[[]]`) are ignored by [parseLinkTargets]. final RegExp _linkPattern = RegExp(r'\[\[([^\]]*)\]\]'); /// Extracts the ordered, de-duplicated list of `[[link]]` targets in [text]. /// Targets are trimmed; blank targets are skipped. Order is first-occurrence. List parseLinkTargets(String text) { final seen = {}; final result = []; for (final match in _linkPattern.allMatches(text)) { final target = (match.group(1) ?? '').trim(); if (target.isEmpty) continue; if (seen.add(target)) result.add(target); } return result; } /// An immutable forward + backward link index over a set of nodes. /// /// Build with [LinkGraph.fromTexts] (id → raw text, links parsed from the text) /// or [LinkGraph.fromLinks] (id → explicit target list). Links are matched by /// the bracketed target string; a target that is not itself a node id is still /// recorded (a dangling link) so [danglingTargets] can surface broken links. class LinkGraph { LinkGraph._(this._forward, this._backward, this._nodeIds); /// Build from raw note texts, parsing `[[targets]]` out of each. factory LinkGraph.fromTexts(Map textById) { return LinkGraph.fromLinks({ for (final entry in textById.entries) entry.key: parseLinkTargets(entry.value), }); } /// Build from explicit per-node target lists. factory LinkGraph.fromLinks(Map> targetsById) { final forward = >{}; final backward = >{}; final nodeIds = targetsById.keys.toSet(); for (final entry in targetsById.entries) { final from = entry.key; final targets = forward.putIfAbsent(from, () => {}); for (final to in entry.value) { if (to == from) continue; // ignore self-links targets.add(to); backward.putIfAbsent(to, () => {}).add(from); } } return LinkGraph._(forward, backward, nodeIds); } final Map> _forward; final Map> _backward; final Set _nodeIds; /// Targets that [id] links TO (outbound). Empty when [id] has no links. Set linksFrom(String id) => Set.unmodifiable(_forward[id] ?? const {}); /// Node ids that link TO [id] (inbound / backlinks). Empty when nothing /// references [id]. Set backlinksOf(String id) => Set.unmodifiable(_backward[id] ?? const {}); /// All link targets that are not themselves known node ids (broken links). Set danglingTargets() { final targets = {}; for (final set in _forward.values) { targets.addAll(set); } targets.removeAll(_nodeIds); return Set.unmodifiable(targets); } }