diff --git a/lib/src/graph/graph_filter.dart b/lib/src/graph/graph_filter.dart new file mode 100644 index 00000000..faf88b4c --- /dev/null +++ b/lib/src/graph/graph_filter.dart @@ -0,0 +1,47 @@ +/// Client-side filtering of a loaded [VaultGraph] (T-323): narrow to the notes +/// near the active one (depth-from-active), and/or by tag include/exclude. +/// +/// Pure — the controller feeds in the already-queried tag map, so this runs +/// under `dart test`. The file glob is NOT here: a different glob is a +/// different file set, so it re-queries pql; these three refine what's already +/// loaded. +library; + +import 'package:clide/src/graph/vault_graph.dart'; + +class GraphFilter { + const GraphFilter({this.depth, this.includeTags = const {}, this.excludeTags = const {}}); + + /// Hops from the active note to keep; null = the whole graph (no depth limit). + final int? depth; + + /// Keep only notes tagged with at least one of these (empty = no filter). + final Set includeTags; + + /// Drop notes tagged with any of these. + final Set excludeTags; + + bool get isEmpty => depth == null && includeTags.isEmpty && excludeTags.isEmpty; + + GraphFilter copyWith({int? depth, bool clearDepth = false, Set? includeTags, Set? excludeTags}) => + GraphFilter(depth: clearDepth ? null : (depth ?? this.depth), includeTags: includeTags ?? this.includeTags, excludeTags: excludeTags ?? this.excludeTags); + + /// Apply this filter to [full], using [tagsByPath] for the tag predicates and + /// [activePath] as the depth root. A depth filter with no active path (or one + /// that isn't a node) yields an empty graph — there's no local graph to show. + VaultGraph apply(VaultGraph full, {required Map> tagsByPath, String? activePath}) { + if (isEmpty) return full; + Set tagsOf(String id) => tagsByPath[id] ?? const {}; + var keep = {for (final n in full.nodes) n.id}; + if (includeTags.isNotEmpty) { + keep = keep.where((id) => tagsOf(id).any(includeTags.contains)).toSet(); + } + if (excludeTags.isNotEmpty) { + keep = keep.where((id) => !tagsOf(id).any(excludeTags.contains)).toSet(); + } + if (depth != null) { + keep = activePath == null ? {} : keep.intersection(full.nodesWithin(activePath, depth!)); + } + return full.subgraph(keep); + } +} diff --git a/lib/src/graph/vault_graph.dart b/lib/src/graph/vault_graph.dart index a38f7097..2d1da9ac 100644 Binary files a/lib/src/graph/vault_graph.dart and b/lib/src/graph/vault_graph.dart differ diff --git a/test/src/graph/graph_filter_test.dart b/test/src/graph/graph_filter_test.dart new file mode 100644 index 00000000..7b11d86b --- /dev/null +++ b/test/src/graph/graph_filter_test.dart @@ -0,0 +1,56 @@ +import 'package:clide/src/graph/graph_filter.dart'; +import 'package:clide/src/graph/vault_graph.dart'; +import 'package:test/test.dart'; + +void main() { + final g = VaultGraph.fromOutlinks({ + 'a.md': const ['b.md'], + 'b.md': const ['c.md'], + 'c.md': const [], + 'x.md': const [], + }); // a—b—c chain, x isolated + final tags = { + 'a.md': {'project'}, + 'b.md': {'note', 'project'}, + 'c.md': {'note'}, + }; + + test('an empty filter returns the full graph unchanged', () { + const f = GraphFilter(); + expect(identical(f.apply(g, tagsByPath: tags), g), isTrue); // no-op short-circuit + }); + + test('includeTags keeps only notes carrying a matching tag', () { + const f = GraphFilter(includeTags: {'project'}); + expect(f.apply(g, tagsByPath: tags).nodes.map((n) => n.id), unorderedEquals(['a.md', 'b.md'])); + }); + + test('excludeTags drops notes carrying a matching tag', () { + const f = GraphFilter(excludeTags: {'note'}); + // b and c carry 'note' → dropped; a (project) and x (untagged) survive. + expect(f.apply(g, tagsByPath: tags).nodes.map((n) => n.id), unorderedEquals(['a.md', 'x.md'])); + }); + + test('depth keeps the local graph around the active note', () { + const f = GraphFilter(depth: 1); + expect(f.apply(g, tagsByPath: tags, activePath: 'a.md').nodes.map((n) => n.id), unorderedEquals(['a.md', 'b.md'])); + }); + + test('a depth filter with no active note yields nothing', () { + const f = GraphFilter(depth: 2); + expect(f.apply(g, tagsByPath: tags, activePath: null).isEmpty, isTrue); + }); + + test('tag and depth compose by intersection', () { + // include project → {a,b}; depth 2 from c → {c,b,a}; intersection → {a,b}. + const f = GraphFilter(includeTags: {'project'}, depth: 2); + expect(f.apply(g, tagsByPath: tags, activePath: 'c.md').nodes.map((n) => n.id), unorderedEquals(['a.md', 'b.md'])); + }); + + test('copyWith sets and clears the depth, keeping other fields', () { + const f = GraphFilter(depth: 2, includeTags: {'project'}); + expect(f.copyWith(depth: 3).depth, 3); + expect(f.copyWith(clearDepth: true).depth, isNull); + expect(f.copyWith(clearDepth: true).includeTags, {'project'}); + }); +} diff --git a/test/src/graph/vault_graph_test.dart b/test/src/graph/vault_graph_test.dart index ec27a702..2f642049 100644 --- a/test/src/graph/vault_graph_test.dart +++ b/test/src/graph/vault_graph_test.dart @@ -48,4 +48,39 @@ void main() { expect(g.neighborhood('b.md'), {'b.md', 'a.md', 'c.md'}); expect(g.neighborhood('x.md'), {'x.md'}); // isolated node }); + + group('nodesWithin', () { + final g = VaultGraph.fromOutlinks({ + 'a.md': const ['b.md'], + 'b.md': const ['c.md'], + 'c.md': const [], + 'x.md': const [], + }); // a—b—c chain, x isolated + + test('depth 0 is the root alone', () { + expect(g.nodesWithin('a.md', 0), {'a.md'}); + }); + + test('depth grows the frontier over undirected edges, both directions', () { + expect(g.nodesWithin('a.md', 1), {'a.md', 'b.md'}); + expect(g.nodesWithin('a.md', 2), {'a.md', 'b.md', 'c.md'}); + expect(g.nodesWithin('c.md', 2), {'c.md', 'b.md', 'a.md'}); // walks backwards too + }); + + test('an isolated node is just itself; an unknown root is empty', () { + expect(g.nodesWithin('x.md', 3), {'x.md'}); + expect(g.nodesWithin('ghost.md', 3), isEmpty); + }); + }); + + test('subgraph keeps only the kept nodes and edges between them', () { + final g = VaultGraph.fromOutlinks({ + 'a.md': const ['b.md', 'c.md'], + 'b.md': const ['c.md'], + 'c.md': const [], + }); + final sub = g.subgraph({'a.md', 'b.md'}); + expect(sub.nodes.map((n) => n.id), unorderedEquals(['a.md', 'b.md'])); + expect(sub.edgePairs, [('a.md', 'b.md')]); // a—c and b—c drop with c gone + }); }