86 lines
3.3 KiB
Dart
86 lines
3.3 KiB
Dart
|
|
// 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<String> parseLinkTargets(String text) {
|
||
|
|
final seen = <String>{};
|
||
|
|
final result = <String>[];
|
||
|
|
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<String, String> 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<String, List<String>> targetsById) {
|
||
|
|
final forward = <String, Set<String>>{};
|
||
|
|
final backward = <String, Set<String>>{};
|
||
|
|
final nodeIds = targetsById.keys.toSet();
|
||
|
|
|
||
|
|
for (final entry in targetsById.entries) {
|
||
|
|
final from = entry.key;
|
||
|
|
final targets = forward.putIfAbsent(from, () => <String>{});
|
||
|
|
for (final to in entry.value) {
|
||
|
|
if (to == from) continue; // ignore self-links
|
||
|
|
targets.add(to);
|
||
|
|
backward.putIfAbsent(to, () => <String>{}).add(from);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return LinkGraph._(forward, backward, nodeIds);
|
||
|
|
}
|
||
|
|
|
||
|
|
final Map<String, Set<String>> _forward;
|
||
|
|
final Map<String, Set<String>> _backward;
|
||
|
|
final Set<String> _nodeIds;
|
||
|
|
|
||
|
|
/// Targets that [id] links TO (outbound). Empty when [id] has no links.
|
||
|
|
Set<String> linksFrom(String id) =>
|
||
|
|
Set.unmodifiable(_forward[id] ?? const <String>{});
|
||
|
|
|
||
|
|
/// Node ids that link TO [id] (inbound / backlinks). Empty when nothing
|
||
|
|
/// references [id].
|
||
|
|
Set<String> backlinksOf(String id) =>
|
||
|
|
Set.unmodifiable(_backward[id] ?? const <String>{});
|
||
|
|
|
||
|
|
/// All link targets that are not themselves known node ids (broken links).
|
||
|
|
Set<String> danglingTargets() {
|
||
|
|
final targets = <String>{};
|
||
|
|
for (final set in _forward.values) {
|
||
|
|
targets.addAll(set);
|
||
|
|
}
|
||
|
|
targets.removeAll(_nodeIds);
|
||
|
|
return Set.unmodifiable(targets);
|
||
|
|
}
|
||
|
|
}
|