From 852eb389eec4ccf39a981c23e154df3000044de8 Mon Sep 17 00:00:00 2001 From: Akiba So Date: Tue, 23 Jun 2026 03:19:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8F=8C=E9=93=BE=20link=20graph=20pur?= =?UTF-8?q?e=20core=20=E2=80=94=20[[link]]=20parse=20+=20backlinks=20(F7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- lib/editor/link/link_graph.dart | 85 +++++++++++++++++++++++++++++++++ test/link_graph_test.dart | 81 +++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 lib/editor/link/link_graph.dart create mode 100644 test/link_graph_test.dart diff --git a/lib/editor/link/link_graph.dart b/lib/editor/link/link_graph.dart new file mode 100644 index 0000000..5cf875b --- /dev/null +++ b/lib/editor/link/link_graph.dart @@ -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 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); + } +} diff --git a/test/link_graph_test.dart b/test/link_graph_test.dart new file mode 100644 index 0000000..c1bbd93 --- /dev/null +++ b/test/link_graph_test.dart @@ -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); + }); + }); +}