feat: 双链 link graph pure core — [[link]] parse + backlinks (F7)
Some checks failed
CI / Windows build (push) Has been cancelled
Some checks failed
CI / Windows build (push) Has been cancelled
Pure bidirectional-link engine for the sticky-note/board system (a user-named differentiator). parseLinkTargets extracts trimmed, de-duped [[targets]]; LinkGraph builds forward + backlink indices (fromTexts parses, fromLinks takes explicit targets), ignores self-links, and danglingTargets() surfaces links to unknown nodes. Widget-free + storage-free so it is fully unit-tested; the board UI + persistence wrap it later. Built ahead of its phase deliberately as a zero-rework-risk pure data structure (not rendering/perf — the P0.5 device gate can't invalidate it). flutter analyze lib/editor clean; 139/139 tests (+10: parsing, backlinks, self-link, dangling, immutability). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
85
lib/editor/link/link_graph.dart
Normal file
85
lib/editor/link/link_graph.dart
Normal file
@@ -0,0 +1,85 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
81
test/link_graph_test.dart
Normal file
81
test/link_graph_test.dart
Normal file
@@ -0,0 +1,81 @@
|
||||
// Tests for the 双链 (bidirectional link) pure core (F7): [[link]] parsing +
|
||||
// forward/backlink indices + dangling-link detection.
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:badnote/editor/link/link_graph.dart';
|
||||
|
||||
void main() {
|
||||
group('parseLinkTargets', () {
|
||||
test('extracts bracketed targets, trimmed', () {
|
||||
expect(parseLinkTargets('see [[Alpha]] and [[ Beta ]]'),
|
||||
['Alpha', 'Beta']);
|
||||
});
|
||||
|
||||
test('de-duplicates, keeping first-occurrence order', () {
|
||||
expect(parseLinkTargets('[[A]] [[B]] [[A]]'), ['A', 'B']);
|
||||
});
|
||||
|
||||
test('ignores empty / whitespace-only targets', () {
|
||||
expect(parseLinkTargets('x [[]] y [[ ]] z [[Real]]'), ['Real']);
|
||||
});
|
||||
|
||||
test('no links → empty', () {
|
||||
expect(parseLinkTargets('plain text, no links'), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('LinkGraph', () {
|
||||
test('backlinksOf collects every node linking to a target', () {
|
||||
final g = LinkGraph.fromTexts({
|
||||
'n1': 'links to [[n2]] and [[n3]]',
|
||||
'n2': 'links to [[n3]]',
|
||||
'n3': 'a leaf',
|
||||
});
|
||||
expect(g.linksFrom('n1'), {'n2', 'n3'});
|
||||
expect(g.linksFrom('n3'), isEmpty);
|
||||
expect(g.backlinksOf('n3'), {'n1', 'n2'});
|
||||
expect(g.backlinksOf('n2'), {'n1'});
|
||||
expect(g.backlinksOf('n1'), isEmpty);
|
||||
});
|
||||
|
||||
test('self-links are ignored', () {
|
||||
final g = LinkGraph.fromTexts({'n1': 'I reference [[n1]] myself'});
|
||||
expect(g.linksFrom('n1'), isEmpty);
|
||||
expect(g.backlinksOf('n1'), isEmpty);
|
||||
});
|
||||
|
||||
test('fromLinks builds the same indices from explicit targets', () {
|
||||
final g = LinkGraph.fromLinks({
|
||||
'a': ['b', 'c'],
|
||||
'b': ['c'],
|
||||
'c': [],
|
||||
});
|
||||
expect(g.backlinksOf('c'), {'a', 'b'});
|
||||
expect(g.linksFrom('a'), {'b', 'c'});
|
||||
});
|
||||
|
||||
test('danglingTargets surfaces links to unknown nodes', () {
|
||||
final g = LinkGraph.fromLinks({
|
||||
'a': ['b', 'ghost'],
|
||||
'b': [],
|
||||
});
|
||||
expect(g.danglingTargets(), {'ghost'});
|
||||
});
|
||||
|
||||
test('returned sets are unmodifiable', () {
|
||||
final g = LinkGraph.fromLinks({
|
||||
'a': ['b'],
|
||||
'b': [],
|
||||
});
|
||||
expect(() => g.linksFrom('a').add('x'), throwsUnsupportedError);
|
||||
expect(() => g.backlinksOf('b').add('x'), throwsUnsupportedError);
|
||||
});
|
||||
|
||||
test('unknown node ids yield empty (no crash)', () {
|
||||
final g = LinkGraph.fromLinks({'a': []});
|
||||
expect(g.linksFrom('nope'), isEmpty);
|
||||
expect(g.backlinksOf('nope'), isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user