Skip to content

Commit f236420

Browse files
release: 4.21.0
feat(edge-audit): add report-scoped Ignore paths setting
1 parent 5d59fd9 commit f236420

10 files changed

Lines changed: 158 additions & 30 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
## 4.X
1111

12+
### [4.21.0](https://github.com/michaelpporter/breadcrumbs/compare/4.20.0...4.21.0) (2026-06-30)
13+
14+
### Features
15+
16+
* The edge audit report now has an **Ignore paths** setting (**Settings → Commands → Edge audit**). Notes inside a listed folder/path are left out of the whole report — orphans, dangling edges, and the field checks alike. Matching uses folder semantics (one path per line; a note matches if its path equals, or is inside, a listed path), so `Templates` covers everything under that folder. This is report-only: unlike the global **Excluded folders** setting, ignored notes still get their breadcrumb edges in the graph — they just don't clutter the audit.
17+
1218
### [4.20.0](https://github.com/michaelpporter/breadcrumbs/compare/4.19.4...4.20.0) (2026-06-29)
1319

1420
### Features

manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"id": "breadcrumbs",
33
"name": "Breadcrumbs",
4-
"version": "4.20.0",
4+
"version": "4.21.0",
55
"minAppVersion": "1.13.0",
66
"description": "Add structured hierarchies to your notes.",
77
"author": "MichaelPPorter",

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "breadcrumbs",
3-
"version": "4.20.0",
3+
"version": "4.21.0",
44
"description": "Add typed-links to your Obsidian notes",
55
"main": "main.js",
66
"scripts": {

src/commands/edge_audit/analyze.ts

Lines changed: 48 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,39 @@ export interface EdgeAuditReport {
5151
/** Stable key for a directed (source → target) edge pair. */
5252
const pair_key = (e: EdgeFact) => `${e.source}\n${e.target}`;
5353

54+
/**
55+
* True if `path` is inside (or equal to) any ignore entry. Entries are
56+
* folder/path prefixes, matched the same way as the graph's excluded folders.
57+
* (Kept local so this module stays free of the Obsidian/WASM-coupled helper.)
58+
*/
59+
const is_ignored = (path: string, ignore_paths: string[]) =>
60+
ignore_paths.some((raw) => {
61+
const entry = raw.replace(/\/+$/, "");
62+
if (!entry) return false;
63+
return path === entry || path.startsWith(entry + "/");
64+
});
65+
66+
/**
67+
* Drop ignored notes and every edge touching one, so all checks below run as if
68+
* those paths don't exist in the graph. Report-scoped: the graph itself is
69+
* untouched. Entries are folder/path prefixes.
70+
*/
71+
export const prune_ignored = (
72+
facts: GraphFacts,
73+
ignore_paths: string[],
74+
): GraphFacts => {
75+
if (ignore_paths.length === 0) return facts;
76+
77+
return {
78+
nodes: facts.nodes.filter((n) => !is_ignored(n.path, ignore_paths)),
79+
edges: facts.edges.filter(
80+
(e) =>
81+
!is_ignored(e.source, ignore_paths) &&
82+
!is_ignored(e.target, ignore_paths),
83+
),
84+
};
85+
};
86+
5487
/** Edge fields defined in settings that produce no edges at all (explicit or implied). */
5588
export const find_unused_fields = (
5689
facts: GraphFacts,
@@ -114,26 +147,16 @@ export const find_mergeable_fields = (facts: GraphFacts): MergeableGroup[] => {
114147
.sort((a, b) => a.fields[0].localeCompare(b.fields[0]));
115148
};
116149

117-
/**
118-
* Resolved notes with no edges in or out — outside the breadcrumb structure.
119-
* `exclude_paths` keeps the report file itself off the list.
120-
*/
121-
export const find_orphan_notes = (
122-
facts: GraphFacts,
123-
exclude_paths: string[] = [],
124-
): string[] => {
150+
/** Resolved notes with no edges in or out — outside the breadcrumb structure. */
151+
export const find_orphan_notes = (facts: GraphFacts): string[] => {
125152
const connected = new Set<string>();
126153
for (const e of facts.edges) {
127154
connected.add(e.source);
128155
connected.add(e.target);
129156
}
130157

131-
const excluded = new Set(exclude_paths);
132-
133158
return facts.nodes
134-
.filter(
135-
(n) => n.resolved && !connected.has(n.path) && !excluded.has(n.path),
136-
)
159+
.filter((n) => n.resolved && !connected.has(n.path))
137160
.map((n) => n.path)
138161
.sort();
139162
};
@@ -152,11 +175,15 @@ export const find_dangling_edges = (facts: GraphFacts): DanglingEdge[] =>
152175

153176
export const build_edge_audit = (
154177
facts: GraphFacts,
155-
opts: { field_labels: string[]; exclude_paths?: string[] },
156-
): EdgeAuditReport => ({
157-
unused_fields: find_unused_fields(facts, opts.field_labels),
158-
implied_only_fields: find_implied_only_fields(facts),
159-
mergeable_groups: find_mergeable_fields(facts),
160-
orphan_notes: find_orphan_notes(facts, opts.exclude_paths),
161-
dangling_edges: find_dangling_edges(facts),
162-
});
178+
opts: { field_labels: string[]; ignore_paths?: string[] },
179+
): EdgeAuditReport => {
180+
const pruned = prune_ignored(facts, opts.ignore_paths ?? []);
181+
182+
return {
183+
unused_fields: find_unused_fields(pruned, opts.field_labels),
184+
implied_only_fields: find_implied_only_fields(pruned),
185+
mergeable_groups: find_mergeable_fields(pruned),
186+
orphan_notes: find_orphan_notes(pruned),
187+
dangling_edges: find_dangling_edges(pruned),
188+
};
189+
};

src/commands/edge_audit/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,11 @@ export const generate_edge_audit_report = async (plugin: BreadcrumbsPlugin) => {
143143

144144
const report = build_edge_audit(facts, {
145145
field_labels: plugin.settings.edge_fields.map((f) => f.label),
146-
// Keep the report note itself off the orphan list.
147-
exclude_paths: [report_path],
146+
// User-configured ignores, plus the report note itself.
147+
ignore_paths: [
148+
...plugin.settings.commands.edge_audit.ignore_paths,
149+
report_path,
150+
],
148151
});
149152

150153
const content = render_edge_audit_report(report, new Date());

src/const/settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@ export const DEFAULT_SETTINGS: BreadcrumbsSettings = {
269269

270270
edge_audit: {
271271
report_path: "Breadcrumbs Edge Audit.md",
272+
ignore_paths: [],
272273
},
273274
},
274275

src/interfaces/settings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,8 @@ export interface BreadcrumbsSettings {
247247

248248
edge_audit: {
249249
report_path: string;
250+
/** Folder/path prefixes whose notes are left out of the audit report. */
251+
ignore_paths: string[];
250252
};
251253
};
252254

src/settings/EdgeAuditSettings.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Setting } from "obsidian";
12
import type BreadcrumbsPlugin from "src/main";
23
import { new_setting } from "src/utils/settings";
34

@@ -21,4 +22,27 @@ export const _add_settings_edge_audit = (
2122
},
2223
},
2324
});
25+
26+
new Setting(contentEl)
27+
.setName("Ignore paths")
28+
.setDesc(
29+
"Notes inside these paths are left out of the audit report (orphans, dangling edges, and field checks). One folder path per line. A note is ignored if its path equals, or is inside, a listed path. This is report-only — graph edges are unaffected.",
30+
)
31+
.addTextArea((text) => {
32+
text.setPlaceholder("Templates\narchive/old").setValue(
33+
settings.commands.edge_audit.ignore_paths.join("\n"),
34+
);
35+
36+
text.inputEl.rows = 4;
37+
38+
text.inputEl.onblur = async () => {
39+
settings.commands.edge_audit.ignore_paths = text
40+
.getValue()
41+
.split("\n")
42+
.map((line) => line.trim())
43+
.filter((line) => line.length > 0);
44+
45+
await plugin.commitSettings("none");
46+
};
47+
});
2448
};

tests/commands/edge_audit.test.ts

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
find_mergeable_fields,
66
find_orphan_notes,
77
find_unused_fields,
8+
prune_ignored,
89
type EdgeFact,
910
type GraphFacts,
1011
type NodeFact,
@@ -138,19 +139,18 @@ describe("find_mergeable_fields", () => {
138139
});
139140

140141
describe("find_orphan_notes", () => {
141-
test("resolved notes with no edges, excluding the report file", () => {
142+
test("resolved notes with no edges; unresolved notes are not orphans", () => {
142143
const f = facts(
143144
[
144145
node("a.md"),
145146
node("b.md"),
146147
node("lonely.md"),
147-
node("report.md"),
148148
node("ghost.md", false),
149149
],
150150
[edge("up", "a.md", "b.md")],
151151
);
152152

153-
expect(find_orphan_notes(f, ["report.md"])).toStrictEqual(["lonely.md"]);
153+
expect(find_orphan_notes(f)).toStrictEqual(["lonely.md"]);
154154
});
155155

156156
test("a note that is only an edge target is not an orphan", () => {
@@ -179,6 +179,50 @@ describe("find_dangling_edges", () => {
179179
});
180180
});
181181

182+
describe("prune_ignored", () => {
183+
test("drops ignored notes and every edge touching one", () => {
184+
const f = facts(
185+
[node("a.md"), node("Templates/t.md"), node("b.md")],
186+
[
187+
edge("up", "a.md", "b.md"),
188+
edge("up", "a.md", "Templates/t.md"),
189+
edge("up", "Templates/t.md", "b.md"),
190+
],
191+
);
192+
193+
expect(prune_ignored(f, ["Templates"])).toStrictEqual(
194+
facts([node("a.md"), node("b.md")], [edge("up", "a.md", "b.md")]),
195+
);
196+
});
197+
198+
test("matches folder prefixes, not bare string prefixes", () => {
199+
const f = facts(
200+
[node("Templates/t.md"), node("TemplatesArchive/x.md")],
201+
[],
202+
);
203+
204+
// "TemplatesArchive" must not be caught by the "Templates" entry.
205+
expect(prune_ignored(f, ["Templates"])).toStrictEqual(
206+
facts([node("TemplatesArchive/x.md")], []),
207+
);
208+
});
209+
210+
test("an exact path entry ignores just that note", () => {
211+
const f = facts([node("a.md"), node("report.md")], []);
212+
213+
expect(prune_ignored(f, ["report.md"])).toStrictEqual(
214+
facts([node("a.md")], []),
215+
);
216+
});
217+
218+
test("blank and empty entries are no-ops", () => {
219+
const f = facts([node("a.md")], []);
220+
221+
expect(prune_ignored(f, [])).toStrictEqual(f);
222+
expect(prune_ignored(f, ["", " "])).toStrictEqual(f);
223+
});
224+
});
225+
182226
describe("build_edge_audit", () => {
183227
test("assembles every section", () => {
184228
const f = facts(
@@ -192,7 +236,6 @@ describe("build_edge_audit", () => {
192236

193237
const report = build_edge_audit(f, {
194238
field_labels: ["up", "parent", "unused"],
195-
exclude_paths: [],
196239
});
197240

198241
expect(report).toStrictEqual({
@@ -205,4 +248,25 @@ describe("build_edge_audit", () => {
205248
],
206249
});
207250
});
251+
252+
test("ignore_paths removes notes from every check", () => {
253+
const f = facts(
254+
[node("a.md"), node("b.md"), node("Templates/lonely.md")],
255+
[
256+
edge("up", "a.md", "b.md"),
257+
// a dangling edge from an ignored note must not be reported
258+
edge("up", "Templates/lonely.md", "missing.md", {
259+
target_resolved: false,
260+
}),
261+
],
262+
);
263+
264+
const report = build_edge_audit(f, {
265+
field_labels: ["up"],
266+
ignore_paths: ["Templates"],
267+
});
268+
269+
expect(report.orphan_notes).toStrictEqual([]);
270+
expect(report.dangling_edges).toStrictEqual([]);
271+
});
208272
});

versions.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,5 +63,6 @@
6363
"4.19.2": "1.13.0",
6464
"4.19.3": "1.13.0",
6565
"4.19.4": "1.13.0",
66-
"4.20.0": "1.13.0"
66+
"4.20.0": "1.13.0",
67+
"4.21.0": "1.13.0"
6768
}

0 commit comments

Comments
 (0)