diff --git a/CLAUDE.md b/CLAUDE.md index 0c91217f..78c1e04f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,7 +55,9 @@ bun run wasm:test # Rust unit tests via wasm-pack The build output is `main.js` and `styles.css` in the repo root. These are loaded directly by Obsidian from `.obsidian/plugins/breadcrumbs/`. -> **Note:** `main.js`, `styles.css`, `*.map`, `node_modules/`, `coverage/`, `data.json`, and `wasm/pkg/` are git-ignored. Don't read or reference them — check source files under `src/` and `wasm/src/` instead. +> **Note:** `main.js`, `styles.css`, `*.map`, `node_modules/`, `coverage/`, and `data.json` are git-ignored. Don't read or reference them — check source files under `src/` and `wasm/src/` instead. +> +> `wasm/pkg/` is **generated by `wasm-pack` but committed** (so builds don't require a Rust toolchain). Don't hand-edit it — it's regenerated by `bun run wasm:build`, which runs a `wasm:postbuild` step that writes `wasm/pkg/.gitignore` and strips the `eslint-disable` line wasm-bindgen emits in the `.d.ts` files. Reference the bindings type at `wasm/pkg/breadcrumbs_graph_wasm.d.ts` only when needed. ## Architecture diff --git a/notes/perf-cleanup-log.md b/notes/perf-cleanup-log.md new file mode 100644 index 00000000..68aa3d7c --- /dev/null +++ b/notes/perf-cleanup-log.md @@ -0,0 +1,155 @@ +# Perf + cleanup pass — progress log + +Tracks an incremental cleanup/performance pass. **One item per commit.** Resuming a +session? Read this file first, find the next `todo`, do that one item, verify, update +the row, stop. + +Full plan rationale lives in the approved plan file (Claude plan +`do-you-suggest-any-agile-kazoo`). Branch: `perf-cleanup`. + +## Items + +| # | Item | Status | Commit | Notes | +|---|------|--------|--------|-------| +| 1a | Debounce view setting write-backs (TreeView/Matrix/TrailView → `saveSettingsDebounced`) | done | d892ace | build clean, 0 errors | +| 1b | Index date_note period lookups (Map instead of `.find()`, O(n·m)→O(n)) | done | ab8a84f | build clean | +| 1c | Debounce opt-in layout-change rebuild (`main.ts:218` → `rebuildGraphDebounced`) | done | 70fb9f7 | build clean | +| 2a | Remove dead code (`utils/markmap.ts`, commented Traverse import, EdgeToAdd type) | done | ea8364a | build clean | +| 2b | Tighten `any` casts (dataview plugin access, metadataTypeManager) | done | 565fdce | build + eslint clean | +| 2c | Remove dead `all_files.dataview` file-source field + branches | done | 05d5292 | build + 242 tests green | +| 3 | Dedup `validate_edge_field` across 9 explicit builders | done | b9f438c | build + lint + 68 tests green | +| 4 | Tests for date_note builder (guards 1b + week_start) | done | fd88a59 | 9 tests, full suite 221 green | +| 4b | Tests for list_note, folder_note, dataview_note, traverse_note | done | 6af2c6b | +21 tests; suite 242 green | +| 5 | Obsidian community scorecard lint fixes (pre-existing) | done | 2ec09d2 | build + 242 tests green | +| 5b | Strip wasm-bindgen `eslint-disable` from generated `wasm/pkg/*.d.ts` | superseded by 5c | 7e6b043 | over-stripped — exposed full ruleset | +| 5c | Scope generated `.d.ts` eslint-disable via `scripts/wasm-postbuild.mjs` | done | fd554e1 | build clean | + +## Notes per item + +### 1a — Debounce view setting write-backs +Three view components saved settings to disk on every local `$state` change. Swapped +`void plugin.saveSettings()` → `plugin.saveSettingsDebounced()` (existing 600ms +debouncer, `main.ts:51`) in the writeback `$effect` of: +- `src/components/side_views/TreeView.svelte` +- `src/components/side_views/Matrix.svelte` +- `src/components/page_views/TrailView.svelte` (also dropped the now-needless `untrack` + wrapper — the debouncer reads nothing reactive) + +No behavior change beyond coalescing rapid disk writes. Verify: `bun run build`. + +### 1b — Index date_note period lookups +`add_period_edges` in `src/graph/builders/explicit/date_note.ts` did +`notes.find(n => n.basename === target_basename)` inside two nested loops +(daily→period and finer→coarser), i.e. O(n·m) string scans per rebuild. Built a +`basename → PeriodNote` Map per period kind once (alongside `period_notes`, keeping the +first occurrence to match prior `.find()` semantics) and replaced both `.find()` calls +with `.get()`. `period_notes` arrays kept for the sequential-next loop. Identical edges. + +### 1c — Debounce opt-in layout-change rebuild +When `commands.rebuild_graph.trigger.layout_change` is enabled, the `layout-change` +handler in `src/main.ts` called `void this.rebuildGraph()` directly — a full rebuild on +every CM6 cursor move/scroll. Swapped to `this.rebuildGraphDebounced()` (existing 1500ms +`_rebuild_debouncer`), matching the `else` branch. Default for the trigger is `false`, +so only opt-in users were affected. + +### 2a — Remove dead code +- Deleted `src/utils/markmap.ts` (only `export const Markmap = {}`, never imported — the + `Markmap` used in CodeblockMarkmap.svelte comes from the `markmap-view` package). +- Removed commented-out `// import { Traverse }` in `src/api/index.ts`. Left the + `TODO(RUST)` commented method stubs below it (tracked future work). +- Removed commented-out `EdgeToAdd` type + its doc comment in `src/interfaces/graph.ts`. +Zero refs confirmed by grep before deleting. + +### 2b — Tighten `any` casts +- `src/external/dataview/index.ts`: replaced the two `(app as any).plugins...` accesses + with a single `PluginRegistry` interface and `app as unknown as PluginRegistry`. Also + switched bracket `["dataview"]` to dot notation (eslint). Dropped the two + `no-explicit-any` eslint-disable comments. +- `src/main.ts` `getMetdataPropertyType`: replaced `(metadataTypeManager as any)` with a + cast to `{ getAssignedWidget(field: string): string }`, dropping the three + unsafe-call/any eslint-disable comments. Still guarded by the existing `in` check. + +### 2c — Remove dead all_files.dataview file-source +`get_all_files` has hardcoded `dataview: null` since the Dataview-page file source was +retired, so every `all_files.dataview?.forEach/map` branch in the builders was dead. +Removed the `dataview` field from the `AllFiles` interface + `get_all_files` (`files.ts`) +and the matching `null` in the test `make_all_files`, then deleted the 10 dead branches +across 6 builders (date_note ×3, regex_note ×2, tag_note, dendron_note, +johnny_decimal_note, folder_note). regex_note's `nodes` fallback simplified to +`all_files.obsidian.map(...)`. Dropped the now-unused `IDataview` import in `files.ts`. + +**Not touched:** the `dataview-from` codeblock feature (separate system in +`src/codeblocks/dataview_from.ts` + schema) and the live Dataview API used by +`dataview_note` (which keeps its own `IDataview` import). Purely internal; no vault- +visible behavior changes. 242 tests still green. + +### 3 — Dedup validate_edge_field +New helper `src/graph/builders/explicit/validate_field.ts` exports `validate_edge_field` +(falsy→`fail(undefined)`, non-string→`invalid_field_value`, not-registered→ +`invalid_edge_field`, else `succ(string)`). Replaced the copy-pasted 3-branch blocks in +9 sites across 7 builders: folder, dendron, johnny_decimal, tag (field + sibling), +list (field + neighbour), regex (field), traverse. Removed now-unused `fail`/ +`graph_build_fail` imports where the block was their only use. + +Behavior notes: +- Error **message** wording was unified to ` is not a valid field` (was a mix of + "valid field" / "valid BC field"). Codes unchanged; tests assert only on `.code`. +- traverse_note: dropped a dead `raw || default_field` fallback (raw is already a truthy + string at that point, so default was never reached). Identical behavior. + +Verify: `bun run build`, `bunx eslint`, `bun run test tests/graph/builders/` (68 green). + +### 4 — date_note builder tests +New `tests/graph/builders/date_note.test.ts` (9 tests). Covers `add_period_edges` (the +1b refactor): period sequential-next edges, finer→coarser `up` via the basename Map +(week→month, month→quarter), absent-target skip, and the daily→period path with the +**week_start** edge case — Sunday 2024-01-07 maps to 2024-W01 under `monday` vs 2024-W02 +under `sunday`. Plus the invalid-`default_field` error and `edge_source` tag. Helper +deep-clones `DEFAULT_SETTINGS.explicit_edge_sources.date_note` so every period kind is +present, then applies per-kind overrides. Full suite: 221 green (was 212). + +Remaining builders (4b) need extra mocks not in `helpers.ts` yet: folder_note wants a +`getAbstractFileByPath` returning a TFolder tree; dataview_note wants a stub Dataview +api; traverse_note wants `metadataCache.resolvedLinks`. + +### 4b — tests for folder/traverse/dataview/list builders +Extended `helpers.ts`: `make_plugin` gained a 4th `app_extra` arg +(`getAbstractFileByPath`, `resolvedLinks`, `cachedRead`, `dataview_pages`), and +`mock_file` gained `listItems` / `links` cache options. Added 4 test files (+21 tests): +- traverse_note (6): DFS over `resolvedLinks`, cycle protection, field errors. +- folder_note (5): folder→sibling edges, recurse on/off, invalid field. +- dataview_note (6): query→page edges, DataArray `{values}` normalization, + missing-plugin + invalid-query errors. +- list_note (4): list-item child edges via `cachedRead` + link resolver, field errors. + +Note: `bun run build` type-checks `tests/` too (tsc), so test files must be type-clean; +the project `lint` script only covers `src/`. Full suite: 242 green (was 221). + +### 5 — Obsidian community scorecard lint fixes +Addressed the obsidianmd branch review (all pre-existing, not from this pass): +- Unnecessary non-null assertions removed: `dataview_from.ts` (`q[pos.i]!` ×3, `split("|")[0]!`) and `typed_link.ts:97` (`match[1]!`). +- Unused catch bindings `catch (_)` → optional `catch {`: `codeblocks/index.ts`, `regex_note.ts`. +- Static inline styles → CSS class: `DateNoteSetupModal` warning div now styled via the existing `.bc-date-note-setup-warning` class in `src/styles.css` (dropped the `style.cssText` assignment). +- Blanket `/* eslint-disable */` in `MDRC.ts` scoped to the single rule that fires (`@typescript-eslint/no-duplicate-type-constituents`). + +Skipped: `wasm/pkg/*.d.ts` (generated + git-ignored). Left out of scope: `obsidianmd/ui/sentence-case` warnings (its suggestions lowercase the ISO/US acronyms) and two pre-existing `import/order` + one `Array` warning. + +### 5b — strip generated eslint-disable in wasm/pkg +The community scorecard flagged the blanket `/* eslint-disable */` that wasm-bindgen +emits at the top of `wasm/pkg/*.d.ts`. The project eslint config already ignores +`**/*.d.ts`, but the scorecard runs its own pass that doesn't honour that. These files +are tracked (committed so builds don't require a Rust toolchain) and regenerated by +`wasm-pack`, so the fix lives in the build: a new `wasm:postbuild` script (now shared by +`postwasm:build`/`dev`/`profile`, which previously duplicated the `.gitignore` printf) +strips the `eslint-disable` line via `perl -i`. Applied to the committed files too. The +`/* tslint:disable */` line is left (tslint is dead; not flagged). + +### 5c — scope the generated .d.ts eslint-disable (revise 5b) +Stripping the disable (5b) exposed the generated bindings to the scorecard's full +ruleset, which then flagged `no-explicit-any`, `no-unsafe-function-type`, and +`no-misused-new` on the wasm-bindgen output. Reverted that approach: instead of removing +the suppression, scope it. Moved the post-wasm logic into `scripts/wasm-postbuild.mjs` +(`wasm:postbuild` now runs it), which writes `wasm/pkg/.gitignore` and rewrites the +generated `.d.ts` header to a disable scoped to exactly those three rules — satisfying +the "specify rule names" meta-rule while keeping generated code unlinted. Idempotent; +handles wasm-pack's fresh blanket `/* eslint-disable */` and re-runs alike. diff --git a/package.json b/package.json index fdd45d4c..80b36c7d 100644 --- a/package.json +++ b/package.json @@ -20,12 +20,13 @@ "fmt": "bun run wasm:fmt && bun run prettier --write ./src", "svelte-check": "svelte-check --tsconfig tsconfig.json --compiler-warnings \"unused-export-let:ignore\"", "lint": "eslint --max-warnings=0 src/ --no-warn-ignored && bun run wasm:lint", + "wasm:postbuild": "bun scripts/wasm-postbuild.mjs", "wasm:build": "cd wasm && wasm-pack build --target web", - "postwasm:build": "printf '# README auto-generated by wasm-pack; not needed in repo\\nREADME.md\\n' > wasm/pkg/.gitignore", + "postwasm:build": "bun run wasm:postbuild", "wasm:dev": "cd wasm && wasm-pack build --dev --target web", - "postwasm:dev": "printf '# README auto-generated by wasm-pack; not needed in repo\\nREADME.md\\n' > wasm/pkg/.gitignore", + "postwasm:dev": "bun run wasm:postbuild", "wasm:profile": "cd wasm && wasm-pack build --profiling --target web", - "postwasm:profile": "printf '# README auto-generated by wasm-pack; not needed in repo\\nREADME.md\\n' > wasm/pkg/.gitignore", + "postwasm:profile": "bun run wasm:postbuild", "wasm:fmt": "cd wasm && cargo fmt", "wasm:lint": "cd wasm && cargo clippy", "wasm:test": "cd wasm && wasm-pack test --node --features test" diff --git a/scripts/wasm-postbuild.mjs b/scripts/wasm-postbuild.mjs new file mode 100644 index 00000000..00d7539f --- /dev/null +++ b/scripts/wasm-postbuild.mjs @@ -0,0 +1,36 @@ +// Post-process wasm-pack output (run by the `wasm:postbuild` npm script). +// +// 1. Write wasm/pkg/.gitignore so the auto-generated README isn't committed. +// 2. Normalize the lint-suppression header in the generated `*.d.ts` files. +// wasm-bindgen emits a blanket `/* eslint-disable */`, which the Obsidian +// community scorecard flags ("specify some rule names"). We replace it with a +// disable scoped to exactly the rules that fire on the generated bindings, so +// the suppression is explicit and the scorecard is satisfied. Idempotent. + +import { readFileSync, writeFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +const PKG_DIR = "wasm/pkg"; + +const SCOPED_DISABLE = + "/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-function-type, @typescript-eslint/no-misused-new */"; + +writeFileSync( + join(PKG_DIR, ".gitignore"), + "# README auto-generated by wasm-pack; not needed in repo\nREADME.md\n", +); + +// `/* tslint:disable */` is always line 1 of a wasm-bindgen .d.ts; the scoped +// eslint-disable goes right after it. Matches an existing (blanket or scoped) +// eslint-disable line and replaces it, or inserts one if absent. +const HEADER_RE = + /(\/\* tslint:disable \*\/\n)(?:\/\* eslint-disable[^\n]*\*\/\n)?/; + +for (const name of readdirSync(PKG_DIR)) { + if (!name.endsWith(".d.ts")) continue; + const path = join(PKG_DIR, name); + const src = readFileSync(path, "utf8"); + if (!HEADER_RE.test(src)) continue; + const next = src.replace(HEADER_RE, `$1${SCOPED_DISABLE}\n`); + if (next !== src) writeFileSync(path, next); +} diff --git a/src/api/index.ts b/src/api/index.ts index e0970dbe..37ade7af 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -3,7 +3,6 @@ import { build_list_index, LIST_INDEX_DEFAULT_OPTIONS, } from "src/commands/list_index"; -// import { Traverse } from "src/graph/traverse"; import { active_file_store } from "src/stores/active_file"; import { get } from "svelte/store"; import type { EdgeList } from "wasm/pkg/breadcrumbs_graph_wasm"; diff --git a/src/codeblocks/MDRC.ts b/src/codeblocks/MDRC.ts index 09493889..2a960e17 100644 --- a/src/codeblocks/MDRC.ts +++ b/src/codeblocks/MDRC.ts @@ -10,12 +10,14 @@ import { Timer } from "src/utils/timer"; import { mount, unmount } from "svelte"; import { Codeblocks } from "."; -/* eslint-disable */ +/* The three components currently share a mount return type, so the union reads + as duplicated; keep it explicit for when they diverge. */ +/* eslint-disable @typescript-eslint/no-duplicate-type-constituents */ type SvelteComponent = | ReturnType | ReturnType | ReturnType; -/* eslint-enable */ +/* eslint-enable @typescript-eslint/no-duplicate-type-constituents */ export class CodeblockMDRC extends MarkdownRenderChild { source: string; diff --git a/src/codeblocks/dataview_from.ts b/src/codeblocks/dataview_from.ts index 662f6f6b..f0475fe6 100644 --- a/src/codeblocks/dataview_from.ts +++ b/src/codeblocks/dataview_from.ts @@ -6,7 +6,7 @@ type FilePredicate = (file: TFile) => boolean; const VALID_EXTENSIONS = new Set(["md", "canvas", "base"]); function skip_ws(q: string, pos: { i: number }) { - while (pos.i < q.length && /\s/.test(q[pos.i]!)) pos.i++; + while (pos.i < q.length && /\s/.test(q[pos.i])) pos.i++; } function consume_keyword( @@ -42,7 +42,7 @@ function parse_atom( // #tag if (q[pos.i] === "#") { const start = pos.i; - while (pos.i < q.length && !/[\s)&|]/.test(q[pos.i]!)) pos.i++; + while (pos.i < q.length && !/[\s)&|]/.test(q[pos.i])) pos.i++; const tag = q.slice(start, pos.i); return (file) => { const cache = app.metadataCache.getFileCache(file); @@ -74,7 +74,7 @@ function parse_atom( pos.i += 2; const start = pos.i; while (pos.i < q.length && q.slice(pos.i, pos.i + 2) !== "]]") pos.i++; - const link_text = q.slice(start, pos.i).split("|")[0]!.trim(); + const link_text = q.slice(start, pos.i).split("|")[0].trim(); if (q.slice(pos.i, pos.i + 2) === "]]") pos.i += 2; const target = app.metadataCache.getFirstLinkpathDest( link_text, @@ -89,7 +89,7 @@ function parse_atom( } // Unknown atom — skip to next whitespace/operator and return false - while (pos.i < q.length && !/[\s)]/.test(q[pos.i]!)) pos.i++; + while (pos.i < q.length && !/[\s)]/.test(q[pos.i])) pos.i++; return () => false; } diff --git a/src/codeblocks/index.ts b/src/codeblocks/index.ts index 49211c6d..b7f3d928 100644 --- a/src/codeblocks/index.ts +++ b/src/codeblocks/index.ts @@ -116,7 +116,7 @@ function postprocess_options( plugin.app, source_path, ); - } catch (_) { + } catch { errors.push({ path: "dataview-from", code: "invalid_field_value", diff --git a/src/components/page_views/TrailView.svelte b/src/components/page_views/TrailView.svelte index 4eb53b07..bde0c3b9 100644 --- a/src/components/page_views/TrailView.svelte +++ b/src/components/page_views/TrailView.svelte @@ -58,7 +58,7 @@ if (is_initial_mount) { is_initial_mount = false; } else { - untrack(() => void plugin.saveSettings()); + plugin.saveSettingsDebounced(); } }); diff --git a/src/components/side_views/Matrix.svelte b/src/components/side_views/Matrix.svelte index 3e448b96..f7da0ef1 100644 --- a/src/components/side_views/Matrix.svelte +++ b/src/components/side_views/Matrix.svelte @@ -53,7 +53,7 @@ const matrix_snapshot = $state.snapshot(settings); untrack(() => { plugin.settings.views.side.matrix = matrix_snapshot; - void plugin.saveSettings(); + plugin.saveSettingsDebounced(); }); if (is_initial_mount) { is_initial_mount = false; diff --git a/src/components/side_views/TreeView.svelte b/src/components/side_views/TreeView.svelte index a5148cb6..5ec52bfb 100644 --- a/src/components/side_views/TreeView.svelte +++ b/src/components/side_views/TreeView.svelte @@ -93,7 +93,7 @@ const tree_snapshot = $state.snapshot(settings); untrack(() => { plugin.settings.views.side.tree = tree_snapshot; - void plugin.saveSettings(); + plugin.saveSettingsDebounced(); }); }); diff --git a/src/external/dataview/index.ts b/src/external/dataview/index.ts index 44f379cc..9ee529c3 100644 --- a/src/external/dataview/index.ts +++ b/src/external/dataview/index.ts @@ -8,16 +8,21 @@ interface DataviewApi { pages: (query?: string, path?: string) => unknown; } +/** Shape of Obsidian's (untyped) community-plugin registry on `app`. */ +interface PluginRegistry { + plugins?: { + plugins?: Record; + }; +} + function get_api(app: App): DataviewApi | undefined { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (app as any).plugins?.plugins?.["dataview"]?.api as - | DataviewApi - | undefined; + return (app as unknown as PluginRegistry).plugins?.plugins?.dataview?.api; } function is_enabled(app: App): boolean { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return Boolean((app as any).plugins?.plugins?.["dataview"]); + return Boolean( + (app as unknown as PluginRegistry).plugins?.plugins?.dataview, + ); } /** `FullIndex.initialized` is set when the vault walk finishes (0.5.x). */ diff --git a/src/graph/builders/explicit/date_note.ts b/src/graph/builders/explicit/date_note.ts index f9e5a82f..77e8414c 100644 --- a/src/graph/builders/explicit/date_note.ts +++ b/src/graph/builders/explicit/date_note.ts @@ -47,19 +47,6 @@ function collect_period_notes( }); }); - all_files.dataview?.forEach(({ file }) => { - if (cfg.folder && file.folder !== cfg.folder) return; - const date = DateTime.fromFormat(file.name, cfg.date_format); - if (!date.isValid) return; - notes.push({ - date, - path: file.path, - basename: file.name, - ext: file.ext, - folder: file.folder, - }); - }); - const seen = new Set(); return notes .filter((n) => { @@ -100,9 +87,18 @@ function add_period_edges( if (results.errors.length > 0) return; const period_notes: Partial> = {}; + // basename → first matching note, for O(1) target lookups below + const period_index: Partial>> = + {}; for (const kind of PERIOD_KINDS) { if (!cfg[kind].enabled) continue; - period_notes[kind] = collect_period_notes(cfg[kind], all_files); + const notes = collect_period_notes(cfg[kind], all_files); + period_notes[kind] = notes; + const index = new Map(); + for (const note of notes) { + if (!index.has(note.basename)) index.set(note.basename, note); + } + period_index[kind] = index; } // Sequential next edges between period notes of the same kind @@ -136,17 +132,6 @@ function add_period_edges( folder: file.parent?.path ?? "", }); }); - all_files.dataview?.forEach(({ file }) => { - const date = DateTime.fromFormat(file.name, cfg.date_format); - if (!date.isValid) return; - daily_notes.push({ - date, - path: file.path, - basename: file.name, - ext: file.ext, - folder: file.folder, - }); - }); // When week starts Sunday, a Sunday should map to the *next* ISO week const week_date = (date: DateTime) => @@ -156,17 +141,15 @@ function add_period_edges( for (const daily of daily_notes) { for (const kind of PERIOD_KINDS) { - const notes = period_notes[kind]; - if (!notes) continue; + const index = period_index[kind]; + if (!index) continue; const period_cfg = cfg[kind]; const lookup_date = kind === "week" ? week_date(daily.date) : daily.date; const target_basename = lookup_date.toFormat( period_cfg.date_format, ); - const target = notes.find( - (n) => n.basename === target_basename, - ); + const target = index.get(target_basename); if (target) { results.edges.push( new GCEdgeData( @@ -188,17 +171,15 @@ function add_period_edges( const finer_cfg = cfg[finer]; for (const coarser of CONTAINMENT[finer]) { - const coarser_notes = period_notes[coarser]; - if (!coarser_notes) continue; + const coarser_index = period_index[coarser]; + if (!coarser_index) continue; const coarser_cfg = cfg[coarser]; for (const note of finer_notes) { const target_basename = note.date.toFormat( coarser_cfg.date_format, ); - const target = coarser_notes.find( - (n) => n.basename === target_basename, - ); + const target = coarser_index.get(target_basename); if (target) { results.edges.push( new GCEdgeData( @@ -275,22 +256,6 @@ export const _add_explicit_edges_date_note: ExplicitEdgeBuilder = ( }); }); - all_files.dataview?.forEach(({ file }) => { - const date = DateTime.fromFormat( - file.name, - date_note_settings.date_format, - ); - if (!date.isValid) return; - - date_notes.push({ - date, - ext: file.ext, - path: file.path, - folder: file.folder, - basename: file.name, - }); - }); - const seen_paths = new Set(); const unique_date_notes = date_notes.filter((n) => { if (seen_paths.has(n.path)) return false; diff --git a/src/graph/builders/explicit/dendron_note.ts b/src/graph/builders/explicit/dendron_note.ts index 88bbed90..d70a1e7f 100644 --- a/src/graph/builders/explicit/dendron_note.ts +++ b/src/graph/builders/explicit/dendron_note.ts @@ -8,8 +8,9 @@ import type { import type BreadcrumbsPlugin from "src/main"; import { implied_pair_close_field } from "src/utils/implied_pair_close_field"; import { Paths } from "src/utils/paths"; -import { fail, graph_build_fail, succ } from "src/utils/result"; +import { succ } from "src/utils/result"; import { GCEdgeData, GCNodeData } from "wasm/pkg/breadcrumbs_graph_wasm"; +import { validate_edge_field } from "./validate_field"; function dendron_edge_key( source: string, @@ -32,29 +33,17 @@ function get_dendron_note_info( // NOTE: Don't return early here. Dendron notes can be valid without any metadata in them // We just have to iterate and check each note // if (!metadata) return fail(undefined); - const field = + const field_res = validate_edge_field( + plugin, metadata?.[META_ALIAS["dendron-note-field"]] ?? - // Which is why we have a default_field on dendron_note - plugin.settings.explicit_edge_sources.dendron_note.default_field; - - if (!field) { - return fail(undefined); - } else if (typeof field !== "string") { - // eslint-disable @typescript-eslint/no-base-to-string - return graph_build_fail({ - path, - code: "invalid_field_value", - message: `dendron-note-field is not a string: '${field}'`, - }); - } else if (!plugin.settings.edge_fields.find((f) => f.label === field)) { - return graph_build_fail({ - path, - code: "invalid_edge_field", - message: `dendron-note-field is not a valid field: '${field}'`, - }); - } + // Which is why we have a default_field on dendron_note + plugin.settings.explicit_edge_sources.dendron_note.default_field, + path, + "dendron-note-field", + ); + if (!field_res.ok) return field_res; - return succ({ field }); + return succ({ field: field_res.data }); } /** Take in the info of a _potential_ dendron note. @@ -302,14 +291,6 @@ export const _add_explicit_edges_dendron_note: ExplicitEdgeBuilder = ( ); }); - all_files.dataview?.forEach((page) => { - paths.push({ - path: page.file.path, - metadata: page, - }); - handle_dendron_note(plugin, results, page.file.path, page, edge_sig); - }); - add_dendron_hub_parent_down_edges(plugin, results, edge_sig, paths); add_dendron_sibling_edges(plugin, results, edge_sig, paths); diff --git a/src/graph/builders/explicit/files.ts b/src/graph/builders/explicit/files.ts index 46aca003..589b4d9a 100644 --- a/src/graph/builders/explicit/files.ts +++ b/src/graph/builders/explicit/files.ts @@ -1,6 +1,5 @@ import type { App, CachedMetadata } from "obsidian"; import { TFile } from "obsidian"; -import type { IDataview } from "src/external/dataview/interfaces"; interface TFileWithCache { file: TFile; @@ -39,20 +38,14 @@ function collect_vault_files(app: App): TFile[] { /** * Files passed to graph rebuild. * - * We always use Obsidian’s vault list plus `metadataCache` for each file. - * Historically, when Dataview was enabled we used `dataview.api.pages().values` - * instead; that list comes from Dataview’s index and API, which changed across - * versions (0.4 → 0.5) and can omit markdown notes the graph still needs, so - * views like the tree could miss edges. Dataview remains used elsewhere (e.g. - * `dataview-from` in codeblocks) via `dataview_plugin.get_api()`. - * - * `dataview` is always `null` here; optional `all_files.dataview?.forEach` in - * builders remains a no-op. The old dual-source shape is not used for rebuild. + * We use Obsidian’s vault list plus `metadataCache` for each file. (Historically a + * Dataview-page list was an alternative source, but it could omit markdown notes + * the graph needs — so the rebuild always uses the vault list now. Dataview is + * still used elsewhere, e.g. `dataview-from` in codeblocks, via + * `dataview_plugin.get_api()`.) */ export interface AllFiles { obsidian: TFileWithCache[]; - /** Not populated during rebuild; optional Dataview page list for legacy branches. */ - dataview: IDataview.Page[] | null; } export const get_all_files = (app: App): AllFiles => ({ @@ -60,5 +53,4 @@ export const get_all_files = (app: App): AllFiles => ({ file, cache: app.metadataCache.getFileCache(file), })), - dataview: null, }); diff --git a/src/graph/builders/explicit/folder_note.ts b/src/graph/builders/explicit/folder_note.ts index 64733d1a..c8c9ad03 100644 --- a/src/graph/builders/explicit/folder_note.ts +++ b/src/graph/builders/explicit/folder_note.ts @@ -8,8 +8,9 @@ import type { } from "src/interfaces/graph"; import type { Result } from "src/interfaces/result"; import type BreadcrumbsPlugin from "src/main"; -import { fail, graph_build_fail, succ } from "src/utils/result"; +import { fail, succ } from "src/utils/result"; import { GCEdgeData } from "wasm/pkg/breadcrumbs_graph_wasm"; +import { validate_edge_field } from "./validate_field"; interface FolderNoteData { field: string; @@ -23,27 +24,18 @@ const get_folder_note_info = ( ): Result => { if (!metadata) return fail(undefined); - const field = metadata[META_ALIAS["folder-note-field"]]; - if (!field) { - return fail(undefined); - } else if (typeof field !== "string") { - return graph_build_fail({ - path, - code: "invalid_field_value", - message: `folder-note-field is not a string: '${field}'`, - }); - } else if (!plugin.settings.edge_fields.find((f) => f.label === field)) { - return graph_build_fail({ - path, - code: "invalid_edge_field", - message: `folder-note-field is not a valid field: '${field}'`, - }); - } + const field_res = validate_edge_field( + plugin, + metadata[META_ALIAS["folder-note-field"]], + path, + "folder-note-field", + ); + if (!field_res.ok) return field_res; const recurse = Boolean(metadata[META_ALIAS["folder-note-recurse"]]); return succ({ - field, + field: field_res.data, recurse, }); }; @@ -120,27 +112,6 @@ export const _add_explicit_edges_folder_note: ExplicitEdgeBuilder = async ( }, ); - all_files.dataview?.forEach((folder_note_page) => { - const folder_note_info = get_folder_note_info( - plugin, - folder_note_page, - folder_note_page.file.path, - ); - if (!folder_note_info.ok) { - if (folder_note_info.error) - results.errors.push(folder_note_info.error); - return; - } - - folder_notes.push({ - data: folder_note_info.data, - file: { - path: folder_note_page.file.path, - folder: folder_note_page.file.folder, - }, - }); - }); - folder_notes.forEach(({ data, file: folder_note }) => iterate_folder_files( plugin, diff --git a/src/graph/builders/explicit/johnny_decimal_note.ts b/src/graph/builders/explicit/johnny_decimal_note.ts index d6624410..6ae23b35 100644 --- a/src/graph/builders/explicit/johnny_decimal_note.ts +++ b/src/graph/builders/explicit/johnny_decimal_note.ts @@ -7,9 +7,10 @@ import type { import type BreadcrumbsPlugin from "src/main"; import { implied_pair_close_field } from "src/utils/implied_pair_close_field"; import { Paths } from "src/utils/paths"; -import { fail, graph_build_fail, succ } from "src/utils/result"; +import { succ } from "src/utils/result"; import { ensure_not_ends_with } from "src/utils/strings"; import { GCEdgeData, GCNodeData } from "wasm/pkg/breadcrumbs_graph_wasm"; +import { validate_edge_field } from "./validate_field"; const get_johnny_decimal_note_info = ( plugin: BreadcrumbsPlugin, @@ -20,28 +21,18 @@ const get_johnny_decimal_note_info = ( // We just have to iterate and check each note // if (!metadata) return fail(undefined); - const field = + const field_res = validate_edge_field( + plugin, metadata?.[META_ALIAS["johnny-decimal-note-field"]] ?? - // Which is why we have a default_field on johnny_decimal_note - plugin.settings.explicit_edge_sources.johnny_decimal_note.default_field; - - if (!field) { - return fail(undefined); - } else if (typeof field !== "string") { - return graph_build_fail({ - path, - code: "invalid_field_value", - message: `johnny-decimal-note-field is not a string: '${field}'`, - }); - } else if (!plugin.settings.edge_fields.find((f) => f.label === field)) { - return graph_build_fail({ - path, - code: "invalid_edge_field", - message: `johnny-decimal-note-field is not a valid BC field: '${field}'`, - }); - } + // Which is why we have a default_field on johnny_decimal_note + plugin.settings.explicit_edge_sources.johnny_decimal_note + .default_field, + path, + "johnny-decimal-note-field", + ); + if (!field_res.ok) return field_res; - return succ({ field }); + return succ({ field: field_res.data }); }; /** Take in the info of a johnny_decimal note. @@ -182,20 +173,6 @@ export const _add_explicit_edges_johnny_decimal_note: ExplicitEdgeBuilder = ( }); }); - all_files.dataview?.forEach((page) => { - const basename = Paths.basename(page.file.path); - - const decimals = basename.match(regex)?.[1]; - if (!decimals) return; - - johnny_decimal_notes.push({ - basename, - metadata: page, - path: page.file.path, - decimals: ensure_not_ends_with(decimals, delimiter), - }); - }); - johnny_decimal_notes.forEach((note) => { handle_johnny_decimal_note(plugin, results, note, johnny_decimal_notes); }); diff --git a/src/graph/builders/explicit/list_note.ts b/src/graph/builders/explicit/list_note.ts index b065151e..8bc07ab5 100644 --- a/src/graph/builders/explicit/list_note.ts +++ b/src/graph/builders/explicit/list_note.ts @@ -8,6 +8,7 @@ import type BreadcrumbsPlugin from "src/main"; import { resolve_relative_target_path } from "src/utils/obsidian"; import { fail, graph_build_fail, succ } from "src/utils/result"; import { GCEdgeData, GCNodeData } from "wasm/pkg/breadcrumbs_graph_wasm"; +import { validate_edge_field } from "./validate_field"; interface NativeListItem { position: { start: { line: number; col: number } }; @@ -72,45 +73,27 @@ const get_list_note_info = ( return fail(undefined); } - const field = metadata[META_ALIAS["list-note-field"]]; - if (!field) { - return fail(undefined); - } else if (typeof field !== "string") { - return graph_build_fail({ - path, - code: "invalid_field_value", - message: `list-note-field is not a string: '${field}'`, - }); - } else if (!plugin.settings.edge_fields.find((f) => f.label === field)) { - return graph_build_fail({ - path, - code: "invalid_edge_field", - message: `list-note-field is not a valid BC field: '${field}'`, - }); - } + const field_res = validate_edge_field( + plugin, + metadata[META_ALIAS["list-note-field"]], + path, + "list-note-field", + ); + if (!field_res.ok) return field_res; + const field = field_res.data; const neighbour_field = metadata[META_ALIAS["list-note-neighbour-field"]] ?? plugin.settings.explicit_edge_sources.list_note.default_neighbour_field; if (neighbour_field) { - if (typeof neighbour_field !== "string") { - return graph_build_fail({ - path, - code: "invalid_field_value", - message: `list-note-neighbour-field is not a string: '${neighbour_field}'`, - }); - } else if ( - !plugin.settings.edge_fields.find( - (f) => f.label === neighbour_field, - ) - ) { - return graph_build_fail({ - path, - code: "invalid_edge_field", - message: `list-note-neighbour-field is not a valid BC field: '${neighbour_field}'`, - }); - } + const neighbour_res = validate_edge_field( + plugin, + neighbour_field, + path, + "list-note-neighbour-field", + ); + if (!neighbour_res.ok) return neighbour_res; } // list-note-exclude-index ignores out-edges, but _only for list-notes_ diff --git a/src/graph/builders/explicit/regex_note.ts b/src/graph/builders/explicit/regex_note.ts index 5e9f5ea5..f5db915c 100644 --- a/src/graph/builders/explicit/regex_note.ts +++ b/src/graph/builders/explicit/regex_note.ts @@ -7,6 +7,7 @@ import { log } from "src/logger"; import type BreadcrumbsPlugin from "src/main"; import { fail, graph_build_fail, succ } from "src/utils/result"; import { GCEdgeData } from "wasm/pkg/breadcrumbs_graph_wasm"; +import { validate_edge_field } from "./validate_field"; function get_regex_note_info( plugin: BreadcrumbsPlugin, @@ -41,7 +42,7 @@ function get_regex_note_info( // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing regex = new RegExp(regex_str, (flags || "") as string); log.debug(`get_regex_note_info > regex:`, regex); - } catch (_) { + } catch { return graph_build_fail({ path, code: "invalid_field_value", @@ -49,28 +50,17 @@ function get_regex_note_info( }); } - const field = + const field_res = validate_edge_field( + plugin, metadata[META_ALIAS["regex-note-field"]] ?? - plugin.settings.explicit_edge_sources.regex_note.default_field; - - if (!field) { - return fail(undefined); - } else if (typeof field !== "string") { - return graph_build_fail({ - path, - code: "invalid_field_value", - message: `${META_ALIAS["regex-note-field"]} is not a string: '${field}'`, - }); - } else if (!plugin.settings.edge_fields.find((f) => f.label === field)) { - return graph_build_fail({ - path, - code: "invalid_edge_field", - message: `${META_ALIAS["regex-note-field"]} is not a valid field: '${field}'`, - }); - } + plugin.settings.explicit_edge_sources.regex_note.default_field, + path, + META_ALIAS["regex-note-field"], + ); + if (!field_res.ok) return field_res; return succ({ - field, + field: field_res.data, regex, }); } @@ -112,24 +102,7 @@ export const _add_explicit_edges_regex_note: ExplicitEdgeBuilder = ( regex_note_files.push({ info: info.data, path: file.path }); }); - all_files.dataview?.forEach((page) => { - const { file } = page; - const info = get_regex_note_info(plugin, page, file.path); - if (!info.ok) { - if (info.error) results.errors.push(info.error); - return; - } - - regex_note_files.push({ info: info.data, path: file.path }); - }); - - // Return early before bringing all nodes into memory - if (!regex_note_files) return results; - - const nodes = - all_files.obsidian?.map((note) => note.file.path) ?? - all_files.dataview?.map((note) => note.file.path) ?? - []; // Won't happen, but makes TS happy + const nodes = all_files.obsidian.map((note) => note.file.path); regex_note_files.forEach((regex_note) => { nodes diff --git a/src/graph/builders/explicit/tag_note.ts b/src/graph/builders/explicit/tag_note.ts index f29a22ea..4e5cda4b 100644 --- a/src/graph/builders/explicit/tag_note.ts +++ b/src/graph/builders/explicit/tag_note.ts @@ -8,6 +8,7 @@ import type BreadcrumbsPlugin from "src/main"; import { fail, graph_build_fail, succ } from "src/utils/result"; import { ensure_starts_with } from "src/utils/strings"; import { GCEdgeData } from "wasm/pkg/breadcrumbs_graph_wasm"; +import { validate_edge_field } from "./validate_field"; const parse_frontmatter_tags = ( frontmatter: Record | undefined, @@ -49,25 +50,15 @@ const get_tag_note_info = ( } const tag = ensure_starts_with(raw_tag, "#"); - const field = + const field_res = validate_edge_field( + plugin, metadata[META_ALIAS["tag-note-field"]] ?? - plugin.settings.explicit_edge_sources.tag_note.default_field; - - if (!field) { - return fail(undefined); - } else if (typeof field !== "string") { - return graph_build_fail({ - path, - code: "invalid_field_value", - message: `tag-note-field is not a string: '${field}'`, - }); - } else if (!plugin.settings.edge_fields.find((f) => f.label === field)) { - return graph_build_fail({ - path, - code: "invalid_edge_field", - message: `tag-note-field is not a valid BC field: '${field}'`, - }); - } + plugin.settings.explicit_edge_sources.tag_note.default_field, + path, + "tag-note-field", + ); + if (!field_res.ok) return field_res; + const field = field_res.data; const exact = Boolean(metadata[META_ALIAS["tag-note-exact"]]); @@ -77,24 +68,14 @@ const get_tag_note_info = ( let sibling_field: string | undefined; if (raw_sibling_field) { - if (typeof raw_sibling_field !== "string") { - return graph_build_fail({ - path, - code: "invalid_field_value", - message: `tag-note-sibling-field is not a string: '${raw_sibling_field}'`, - }); - } else if ( - !plugin.settings.edge_fields.find( - (f) => f.label === raw_sibling_field, - ) - ) { - return graph_build_fail({ - path, - code: "invalid_edge_field", - message: `tag-note-sibling-field is not a valid BC field: '${raw_sibling_field}'`, - }); - } - sibling_field = raw_sibling_field; + const sibling_res = validate_edge_field( + plugin, + raw_sibling_field, + path, + "tag-note-sibling-field", + ); + if (!sibling_res.ok) return sibling_res; + sibling_field = sibling_res.data; } return succ({ tag, field, exact, sibling_field }); @@ -176,36 +157,6 @@ export const _add_explicit_edges_tag_note: ExplicitEdgeBuilder = ( }, ); - all_files.dataview?.forEach((page) => { - const tag_note_file = page.file; - - // NOTE: We make sure to use etags, not tags (which are unwound) - tag_note_file.etags.values.forEach((tag) => { - if (!tag_paths_map.get(tag)?.push(tag_note_file.path)) { - tag_paths_map.set(tag, [tag_note_file.path]); - } - }); - - const tag_note_info = get_tag_note_info( - plugin, - page, - tag_note_file.path, - ); - if (!tag_note_info.ok) { - if (tag_note_info.error) results.errors.push(tag_note_info.error); - return; - } - const { tag, field, exact, sibling_field } = tag_note_info.data; - - tag_notes.push({ - tag, - exact, - field, - source_path: tag_note_file.path, - sibling_field, - }); - }); - const all_tags = [...tag_paths_map.keys()]; tag_notes.forEach((tag_note) => { diff --git a/src/graph/builders/explicit/traverse_note.ts b/src/graph/builders/explicit/traverse_note.ts index 77da962b..de0cbce2 100644 --- a/src/graph/builders/explicit/traverse_note.ts +++ b/src/graph/builders/explicit/traverse_note.ts @@ -3,8 +3,9 @@ import type { EdgeBuilderResults, ExplicitEdgeBuilder, } from "src/interfaces/graph"; -import { fail, graph_build_fail, succ } from "src/utils/result"; +import { fail, succ } from "src/utils/result"; import { GCEdgeData } from "wasm/pkg/breadcrumbs_graph_wasm"; +import { validate_edge_field } from "./validate_field"; /** * Walk the Obsidian vault link graph from a starting note using iterative DFS. @@ -42,30 +43,15 @@ const get_traverse_note_field = ( ) => { if (!metadata) return fail(undefined); - const raw = metadata[META_ALIAS["traverse-note-field"]]; - if (!raw) return fail(undefined); + const field_res = validate_edge_field( + plugin, + metadata[META_ALIAS["traverse-note-field"]], + path, + "traverse-note-field", + ); + if (!field_res.ok) return field_res; - if (typeof raw !== "string") { - return graph_build_fail({ - path, - code: "invalid_field_value", - message: `traverse-note-field is not a string: '${raw}'`, - }); - } - - const field = - raw || - plugin.settings.explicit_edge_sources.traverse_note.default_field; - - if (!plugin.settings.edge_fields.find((f) => f.label === field)) { - return graph_build_fail({ - path, - code: "invalid_edge_field", - message: `traverse-note-field is not a valid BC field: '${field}'`, - }); - } - - return succ(field); + return succ(field_res.data); }; /** diff --git a/src/graph/builders/explicit/typed_link.ts b/src/graph/builders/explicit/typed_link.ts index fee25f00..bef76fbb 100644 --- a/src/graph/builders/explicit/typed_link.ts +++ b/src/graph/builders/explicit/typed_link.ts @@ -94,7 +94,7 @@ export const _add_explicit_edges_typed_link: ExplicitEdgeBuilder = async ( const match = INLINE_FIELD_REGEX.exec(line_text); if (!match) continue; - const field = match[1]!.trim(); + const field = match[1].trim(); if (!field_labels.has(field)) continue; if (fm_covered.has(`${field}\0${link_cache.link}`)) continue; diff --git a/src/graph/builders/explicit/validate_field.ts b/src/graph/builders/explicit/validate_field.ts new file mode 100644 index 00000000..6dc1bc8f --- /dev/null +++ b/src/graph/builders/explicit/validate_field.ts @@ -0,0 +1,42 @@ +import type { BreadcrumbsError } from "src/interfaces/graph"; +import type { Result } from "src/interfaces/result"; +import type BreadcrumbsPlugin from "src/main"; +import { fail, graph_build_fail, succ } from "src/utils/result"; + +/** + * Validate a metadata edge-field value, shared across the explicit builders. + * + * - falsy → `fail(undefined)`: the note doesn't opt in, skip it silently + * - non-string → `invalid_field_value` + * - not a registered Breadcrumbs edge field → `invalid_edge_field` + * + * On success the validated label is returned as a `string`. `field_name` is the + * human-readable key used in error messages (e.g. `"tag-note-field"`). + * + * For *optional* fields, guard the call with `if (raw_field)` so a missing value + * stays optional instead of producing `fail(undefined)`. + */ +export const validate_edge_field = ( + plugin: BreadcrumbsPlugin, + field: unknown, + path: string, + field_name: string, +): Result => { + if (!field) { + return fail(undefined); + } else if (typeof field !== "string") { + return graph_build_fail({ + path, + code: "invalid_field_value", + message: `${field_name} is not a string: '${field}'`, + }); + } else if (!plugin.settings.edge_fields.find((f) => f.label === field)) { + return graph_build_fail({ + path, + code: "invalid_edge_field", + message: `${field_name} is not a valid field: '${field}'`, + }); + } + + return succ(field); +}; diff --git a/src/interfaces/graph.ts b/src/interfaces/graph.ts index 820bb2a1..d757fa7d 100644 --- a/src/interfaces/graph.ts +++ b/src/interfaces/graph.ts @@ -29,13 +29,6 @@ export interface BreadcrumbsError { path: string; } -/** The values passed into safe_add_edge */ -// export type EdgeToAdd = { -// source_id: string; -// target_id: string; -// attr: BCEdgeAttributes; -// }; - /** * What an explicit edge builder must return. * diff --git a/src/main.ts b/src/main.ts index 995aa3b6..b9d2e697 100644 --- a/src/main.ts +++ b/src/main.ts @@ -215,7 +215,7 @@ export default class BreadcrumbsPlugin extends Plugin { this.settings.commands.rebuild_graph.trigger .layout_change ) { - void this.rebuildGraph(); + this.rebuildGraphDebounced(); } else { this._redraw_page_views_debouncer(); } @@ -458,10 +458,12 @@ export default class BreadcrumbsPlugin extends Plugin { private getMetdataPropertyType(field: string): string | null { if ("getAssignedWidget" in this.app.metadataTypeManager) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access - return (this.app.metadataTypeManager as any).getAssignedWidget( - field, - ) as string; + // `getAssignedWidget` exists on newer Obsidian but isn't in the typings + return ( + this.app.metadataTypeManager as unknown as { + getAssignedWidget(field: string): string; + } + ).getAssignedWidget(field); } else { return this.app.metadataTypeManager.getAssignedType(field); } diff --git a/src/modals/DateNoteSetupModal.ts b/src/modals/DateNoteSetupModal.ts index a60a086d..72edf854 100644 --- a/src/modals/DateNoteSetupModal.ts +++ b/src/modals/DateNoteSetupModal.ts @@ -58,8 +58,6 @@ export class DateNoteSetupModal extends Modal { warning.appendText( "Date notes are already configured. This may overwrite your current settings.", ); - warning.style.cssText = - "background:var(--background-modifier-error);color:var(--text-error);padding:8px 12px;border-radius:4px;margin-bottom:12px;"; } contentEl.createEl("h2", { text: "Set up Date Notes" }); diff --git a/src/styles.css b/src/styles.css index 5c64b56e..1f6c63cd 100644 --- a/src/styles.css +++ b/src/styles.css @@ -167,3 +167,11 @@ Outputs to /styles.css, for Obsidian to use. .BC-codeblock-markmap svg foreignObject div { color: var(--text-normal); } + +.bc-date-note-setup-warning { + background: var(--background-modifier-error); + color: var(--text-error); + padding: 8px 12px; + border-radius: 4px; + margin-bottom: 12px; +} diff --git a/src/utils/markmap.ts b/src/utils/markmap.ts deleted file mode 100644 index 7076162d..00000000 --- a/src/utils/markmap.ts +++ /dev/null @@ -1 +0,0 @@ -export const Markmap = {}; diff --git a/tests/graph/builders/dataview_note.test.ts b/tests/graph/builders/dataview_note.test.ts new file mode 100644 index 00000000..c6d4bded --- /dev/null +++ b/tests/graph/builders/dataview_note.test.ts @@ -0,0 +1,83 @@ +import { _add_explicit_edges_dataview_note } from "src/graph/builders/explicit/dataview_note"; +import { describe, test } from "vitest"; +import { make_all_files, make_plugin, mock_file } from "./helpers"; + +function plugin( + dataview_pages?: (query: string, path: string) => unknown, + edge_fields = [{ label: "up" }], +) { + return make_plugin( + { edge_fields, explicit_edge_sources: {} as never }, + [], + undefined, + dataview_pages ? { dataview_pages } : {}, + ); +} + +const QUERY_NOTE = { + "BC-dataview-note-query": '"some/folder"', + "BC-dataview-note-field": "up", +}; + +describe("dataview_note builder", () => { + test("query results → edges to each page", async (t) => { + const r = await _add_explicit_edges_dataview_note( + plugin(() => [{ file: { path: "X.md" } }, { file: { path: "Y.md" } }]), + make_all_files([mock_file("hub.md", { frontmatter: QUERY_NOTE })]), + ); + const pairs = r.edges.map((e) => [e.source, e.target]); + t.expect(pairs).toContainEqual(["hub.md", "X.md"]); + t.expect(pairs).toContainEqual(["hub.md", "Y.md"]); + t.expect(r.edges.every((e) => e.edge_type === "up")).toBe(true); + }); + + test("Dataview DataArray ({ values }) is normalized", async (t) => { + const r = await _add_explicit_edges_dataview_note( + plugin(() => ({ values: [{ file: { path: "X.md" } }] })), + make_all_files([mock_file("hub.md", { frontmatter: QUERY_NOTE })]), + ); + t.expect(r.edges.map((e) => e.target)).toContain("X.md"); + }); + + test("Dataview not installed → missing_other_plugin error", async (t) => { + const r = await _add_explicit_edges_dataview_note( + plugin(undefined), + make_all_files([mock_file("hub.md", { frontmatter: QUERY_NOTE })]), + ); + t.expect(r.errors[0]!.code).toBe("missing_other_plugin"); + }); + + test("query throwing → invalid query error", async (t) => { + const r = await _add_explicit_edges_dataview_note( + plugin(() => { + throw new Error("bad DQL"); + }), + make_all_files([mock_file("hub.md", { frontmatter: QUERY_NOTE })]), + ); + t.expect(r.errors[0]!.code).toBe("invalid_field_value"); + }); + + test("field not in edge_fields → invalid_edge_field", async (t) => { + const r = await _add_explicit_edges_dataview_note( + plugin(() => []), + make_all_files([ + mock_file("hub.md", { + frontmatter: { + "BC-dataview-note-query": '"x"', + "BC-dataview-note-field": "nope", + }, + }), + ]), + ); + t.expect(r.errors[0]!.code).toBe("invalid_edge_field"); + }); + + test("note without a query is skipped", async (t) => { + const r = await _add_explicit_edges_dataview_note( + plugin(() => []), + make_all_files([mock_file("plain.md", { frontmatter: {} })]), + ); + t.expect(r.edges).toHaveLength(0); + t.expect(r.errors).toHaveLength(0); + }); +}); diff --git a/tests/graph/builders/date_note.test.ts b/tests/graph/builders/date_note.test.ts new file mode 100644 index 00000000..99c078e3 --- /dev/null +++ b/tests/graph/builders/date_note.test.ts @@ -0,0 +1,198 @@ +import { DEFAULT_SETTINGS } from "src/const/settings"; +import { _add_explicit_edges_date_note } from "src/graph/builders/explicit/date_note"; +import type { + BreadcrumbsSettings, + PeriodNoteConfig, +} from "src/interfaces/settings"; +import { describe, test } from "vitest"; +import { make_all_files, make_plugin, mock_file } from "./helpers"; + +type DateNoteSettings = + BreadcrumbsSettings["explicit_edge_sources"]["date_note"]; + +type PeriodKind = "week" | "month" | "quarter" | "year"; + +type DateNoteOverrides = Partial< + Omit +> & Partial>>; + +/** + * Build a plugin stub with a fully-populated date_note config. We deep-clone the + * default so every period kind is present, then apply `overrides` on top. + */ +function date_note_plugin( + overrides: DateNoteOverrides = {}, + known_paths: string[] = [], +) { + const date_note = structuredClone( + DEFAULT_SETTINGS.explicit_edge_sources.date_note, + ) as DateNoteSettings; + + const { week, month, quarter, year, ...rest } = overrides; + Object.assign(date_note, rest); + if (week) Object.assign(date_note.week, week); + if (month) Object.assign(date_note.month, month); + if (quarter) Object.assign(date_note.quarter, quarter); + if (year) Object.assign(date_note.year, year); + + return make_plugin( + { + edge_fields: [ + { label: "up" }, + { label: "next" }, + { label: "down" }, + ], + explicit_edge_sources: { date_note } as never, + }, + known_paths, + ); +} + +describe("date_note builder", () => { + // ---- disabled ---- + + test("date_note disabled, no periods enabled → empty", async (t) => { + const files = [mock_file("2024-01-01.md"), mock_file("2024-W01.md")]; + const r = await _add_explicit_edges_date_note( + date_note_plugin({ enabled: false }), + make_all_files(files), + ); + t.expect(r.edges).toHaveLength(0); + t.expect(r.errors).toHaveLength(0); + }); + + // ---- period sequential "next" + finer→coarser "up" (date_note.enabled = false) ---- + + test("sequential next edges between consecutive week notes", async (t) => { + const files = [mock_file("2024-W01.md"), mock_file("2024-W02.md")]; + const r = await _add_explicit_edges_date_note( + date_note_plugin({ enabled: false, week: { enabled: true } }), + make_all_files(files), + ); + const next_edges = r.edges.filter((e) => e.edge_type === "next"); + t.expect(next_edges).toHaveLength(1); + t.expect(next_edges[0]!.source).toBe("2024-W01.md"); + t.expect(next_edges[0]!.target).toBe("2024-W02.md"); + }); + + test("finer→coarser up edge: week note → its month note", async (t) => { + // 2024-W01 starts Mon 2024-01-01 → month 2024-01 + const files = [mock_file("2024-W01.md"), mock_file("2024-01.md")]; + const r = await _add_explicit_edges_date_note( + date_note_plugin({ + enabled: false, + week: { enabled: true }, + month: { enabled: true }, + }), + make_all_files(files), + ); + const up_edges = r.edges.filter((e) => e.edge_type === "up"); + t.expect( + up_edges.some( + (e) => e.source === "2024-W01.md" && e.target === "2024-01.md", + ), + ).toBe(true); + }); + + test("multi-level containment: month note → quarter note", async (t) => { + // 2024-02 → quarter 2024-Q1 + const files = [mock_file("2024-02.md"), mock_file("2024-Q1.md")]; + const r = await _add_explicit_edges_date_note( + date_note_plugin({ + enabled: false, + month: { enabled: true }, + quarter: { enabled: true }, + }), + make_all_files(files), + ); + const up_edges = r.edges.filter((e) => e.edge_type === "up"); + t.expect( + up_edges.some( + (e) => e.source === "2024-02.md" && e.target === "2024-Q1.md", + ), + ).toBe(true); + }); + + test("no up edge when the containing period note is absent", async (t) => { + const files = [mock_file("2024-W01.md")]; // no month note + const r = await _add_explicit_edges_date_note( + date_note_plugin({ + enabled: false, + week: { enabled: true }, + month: { enabled: true }, + }), + make_all_files(files), + ); + t.expect(r.edges.filter((e) => e.edge_type === "up")).toHaveLength(0); + }); + + // ---- daily → period up edges + week_start (date_note.enabled = true) ---- + + test("week_start=monday: Sunday 2024-01-07 maps to 2024-W01", async (t) => { + const files = [mock_file("2024-01-07.md"), mock_file("2024-W01.md")]; + const r = await _add_explicit_edges_date_note( + date_note_plugin({ + enabled: true, + week_start: "monday", + week: { enabled: true }, + }), + make_all_files(files), + ); + const up_edges = r.edges.filter((e) => e.edge_type === "up"); + t.expect( + up_edges.some( + (e) => e.source === "2024-01-07.md" && e.target === "2024-W01.md", + ), + ).toBe(true); + }); + + test("week_start=sunday: Sunday 2024-01-07 maps to next week 2024-W02", async (t) => { + const files = [ + mock_file("2024-01-07.md"), + mock_file("2024-W01.md"), + mock_file("2024-W02.md"), + ]; + const r = await _add_explicit_edges_date_note( + date_note_plugin({ + enabled: true, + week_start: "sunday", + week: { enabled: true }, + }), + make_all_files(files), + ); + const up_edges = r.edges.filter((e) => e.edge_type === "up"); + t.expect( + up_edges.some( + (e) => e.source === "2024-01-07.md" && e.target === "2024-W02.md", + ), + ).toBe(true); + // must NOT also map to the current ISO week + t.expect( + up_edges.some( + (e) => e.source === "2024-01-07.md" && e.target === "2024-W01.md", + ), + ).toBe(false); + }); + + // ---- errors ---- + + test("enabled with invalid default_field → invalid_setting_value", async (t) => { + const r = await _add_explicit_edges_date_note( + date_note_plugin({ enabled: true, default_field: "nonexistent" }), + make_all_files([mock_file("2024-01-01.md")]), + ); + t.expect(r.errors).toHaveLength(1); + t.expect(r.errors[0]!.code).toBe("invalid_setting_value"); + }); + + // ---- edge_source ---- + + test("edges carry edge_source = date_note", async (t) => { + const files = [mock_file("2024-W01.md"), mock_file("2024-W02.md")]; + const r = await _add_explicit_edges_date_note( + date_note_plugin({ enabled: false, week: { enabled: true } }), + make_all_files(files), + ); + t.expect(r.edges[0]!.edge_source).toBe("date_note"); + }); +}); diff --git a/tests/graph/builders/folder_note.test.ts b/tests/graph/builders/folder_note.test.ts new file mode 100644 index 00000000..7ead7b92 --- /dev/null +++ b/tests/graph/builders/folder_note.test.ts @@ -0,0 +1,122 @@ +import { _add_explicit_edges_folder_note } from "src/graph/builders/explicit/folder_note"; +import { TFolder } from "obsidian"; +import { describe, test } from "vitest"; +import { make_all_files, make_plugin, mock_file } from "./helpers"; + +/** Build a TFolder with the given children (TFile or TFolder instances). */ +function tfolder(path: string, children: unknown[]): TFolder { + const folder = new TFolder(); + folder.path = path; + folder.children = children as TFolder["children"]; + return folder; +} + +function plugin( + tree: Record, + edge_fields = [{ label: "up" }, { label: "down" }], +) { + return make_plugin( + { edge_fields, explicit_edge_sources: {} as never }, + [], + undefined, + { getAbstractFileByPath: (p) => tree[p] ?? null }, + ); +} + +describe("folder_note builder", () => { + test("folder note → up edges to sibling files (not itself)", async (t) => { + const index = mock_file("Folder/index.md", { + frontmatter: { "BC-folder-note-field": "up" }, + }); + const a = mock_file("Folder/a.md"); + const b = mock_file("Folder/b.md"); + const tree = { + Folder: tfolder("Folder", [index.file, a.file, b.file]), + }; + + const r = await _add_explicit_edges_folder_note( + plugin(tree), + make_all_files([index, a, b]), + ); + + const targets = r.edges + .filter((e) => e.source === "Folder/index.md") + .map((e) => e.target) + .sort(); + t.expect(targets).toEqual(["Folder/a.md", "Folder/b.md"]); + t.expect(r.edges.every((e) => e.edge_type === "up")).toBe(true); + }); + + test("recurse=true includes sub-folder files", async (t) => { + const index = mock_file("Folder/index.md", { + frontmatter: { + "BC-folder-note-field": "up", + "BC-folder-note-recurse": true, + }, + }); + const a = mock_file("Folder/a.md"); + const c = mock_file("Folder/Sub/c.md"); + const tree = { + Folder: tfolder("Folder", [ + index.file, + a.file, + tfolder("Folder/Sub", [c.file]), + ]), + "Folder/Sub": tfolder("Folder/Sub", [c.file]), + }; + + const r = await _add_explicit_edges_folder_note( + plugin(tree), + make_all_files([index, a, c]), + ); + + const targets = r.edges.map((e) => e.target); + t.expect(targets).toContain("Folder/Sub/c.md"); + }); + + test("recurse=false excludes sub-folder files", async (t) => { + const index = mock_file("Folder/index.md", { + frontmatter: { "BC-folder-note-field": "up" }, + }); + const a = mock_file("Folder/a.md"); + const c = mock_file("Folder/Sub/c.md"); + const tree = { + Folder: tfolder("Folder", [ + index.file, + a.file, + tfolder("Folder/Sub", [c.file]), + ]), + "Folder/Sub": tfolder("Folder/Sub", [c.file]), + }; + + const r = await _add_explicit_edges_folder_note( + plugin(tree), + make_all_files([index, a, c]), + ); + + t.expect(r.edges.map((e) => e.target)).not.toContain("Folder/Sub/c.md"); + }); + + test("invalid field → invalid_edge_field", async (t) => { + const index = mock_file("Folder/index.md", { + frontmatter: { "BC-folder-note-field": "nope" }, + }); + const r = await _add_explicit_edges_folder_note( + plugin({ Folder: tfolder("Folder", [index.file]) }), + make_all_files([index]), + ); + t.expect(r.errors[0]!.code).toBe("invalid_edge_field"); + }); + + test("edge_source is folder_note", async (t) => { + const index = mock_file("Folder/index.md", { + frontmatter: { "BC-folder-note-field": "up" }, + }); + const a = mock_file("Folder/a.md"); + const r = await _add_explicit_edges_folder_note( + plugin({ Folder: tfolder("Folder", [index.file, a.file]) }), + make_all_files([index, a]), + ); + t.expect(r.edges[0]!.edge_source).toBe("folder_note"); + }); +}); diff --git a/tests/graph/builders/helpers.ts b/tests/graph/builders/helpers.ts index 20981b0d..5aa0cc05 100644 --- a/tests/graph/builders/helpers.ts +++ b/tests/graph/builders/helpers.ts @@ -9,7 +9,7 @@ import { DEFAULT_SETTINGS } from "src/const/settings"; import type { AllFiles } from "src/graph/builders/explicit/files"; import type { BreadcrumbsSettings } from "src/interfaces/settings"; import type BreadcrumbsPlugin from "src/main"; -import { TFile } from "obsidian"; +import { TAbstractFile, TFile } from "obsidian"; /** Build a TFile-like object plus its optional cache entry */ export function mock_file( @@ -20,6 +20,10 @@ export function mock_file( tags?: string[]; /** Obsidian-resolved frontmatter links (typed_link builder) */ frontmatterLinks?: { key: string; link: string }[]; + /** Markdown list items (list_note builder): one per line */ + listItems?: { line: number; col: number; parent: number }[]; + /** Body wikilinks keyed by line (list_note builder) */ + links?: { line: number; link: string }[]; } = {}, ) { const slash_path = path.replace(/\\/g, "/"); @@ -37,17 +41,37 @@ export function mock_file( parent: { path: parts.slice(0, -1).join("/") || "" }, }) as TFile & { parent: { path: string } }; - const cache = - opts.frontmatter || opts.tags || opts.frontmatterLinks - ? { - frontmatter: opts.frontmatter ?? {}, - tags: opts.tags?.map((tag) => ({ - tag, - position: { start: { line: 0, col: 0, offset: 0 }, end: { line: 0, col: 0, offset: 0 } }, - })), - frontmatterLinks: opts.frontmatterLinks, - } - : null; + const has_cache = + opts.frontmatter || + opts.tags || + opts.frontmatterLinks || + opts.listItems || + opts.links; + + const cache = has_cache + ? { + frontmatter: opts.frontmatter ?? {}, + tags: opts.tags?.map((tag) => ({ + tag, + position: { start: { line: 0, col: 0, offset: 0 }, end: { line: 0, col: 0, offset: 0 } }, + })), + frontmatterLinks: opts.frontmatterLinks, + listItems: opts.listItems?.map((li) => ({ + position: { + start: { line: li.line, col: li.col, offset: 0 }, + end: { line: li.line, col: li.col, offset: 0 }, + }, + parent: li.parent, + })), + links: opts.links?.map((l) => ({ + link: l.link, + position: { + start: { line: l.line, col: 0, offset: 0 }, + end: { line: l.line, col: 0, offset: 0 }, + }, + })), + } + : null; return { file, cache }; } @@ -58,7 +82,6 @@ export function make_all_files( ): AllFiles { return { obsidian: files as unknown as AllFiles["obsidian"], - dataview: null, }; } @@ -75,6 +98,16 @@ export function make_plugin( settings_override: Partial = {}, known_paths: string[] = [], resolve_link?: (link: string, source_path: string) => TFile | null, + app_extra: { + /** TFolder/TFile tree lookup (folder_note, link resolution) */ + getAbstractFileByPath?: (path: string) => TAbstractFile | null; + /** Vault link graph (traverse_note) */ + resolvedLinks?: Record>; + /** Note body reader (list_note) */ + cachedRead?: (file: TFile) => Promise; + /** Stub Dataview `api.pages()` (dataview_note) */ + dataview_pages?: (query: string, path: string) => unknown; + } = {}, ): BreadcrumbsPlugin { const settings = { ...DEFAULT_SETTINGS, @@ -89,13 +122,25 @@ export function make_plugin( return { settings, app: { + plugins: app_extra.dataview_pages + ? { + plugins: { + dataview: { + api: { pages: app_extra.dataview_pages }, + }, + }, + } + : undefined, vault: { getFileByPath: (path: string) => known_paths.includes(path) ? ({} as TFile) : null, - getAbstractFileByPath: () => null, + getAbstractFileByPath: + app_extra.getAbstractFileByPath ?? (() => null), + cachedRead: app_extra.cachedRead ?? (async () => ""), }, metadataCache: { getFirstLinkpathDest: resolve_link ?? (() => null), + resolvedLinks: app_extra.resolvedLinks ?? {}, }, fileManager: { getNewFileParent: () => ({ path: "" }), diff --git a/tests/graph/builders/list_note.test.ts b/tests/graph/builders/list_note.test.ts new file mode 100644 index 00000000..8f7c5484 --- /dev/null +++ b/tests/graph/builders/list_note.test.ts @@ -0,0 +1,91 @@ +import { _add_explicit_edges_list_note } from "src/graph/builders/explicit/list_note"; +import type { TFile } from "obsidian"; +import { describe, test } from "vitest"; +import { make_all_files, make_plugin, mock_file } from "./helpers"; + +const EDGE_FIELDS = [{ label: "up" }, { label: "down" }]; + +/** Resolve bare link names ("A") to a TFile at "A.md". */ +function link_resolver(names: string[]) { + const files = new Map( + names.map((n) => [n, mock_file(`${n}.md`).file as unknown as TFile]), + ); + return (link: string) => files.get(link) ?? null; +} + +describe("list_note builder", () => { + test("top-level list items become child edges", async (t) => { + const content = "- [[A]]\n- [[B]]"; + const list = mock_file("list.md", { + frontmatter: { "BC-list-note-field": "down" }, + listItems: [ + { line: 0, col: 0, parent: -1 }, + { line: 1, col: 0, parent: -1 }, + ], + links: [ + { line: 0, link: "A" }, + { line: 1, link: "B" }, + ], + }); + + const r = await _add_explicit_edges_list_note( + make_plugin( + { edge_fields: EDGE_FIELDS, explicit_edge_sources: {} as never }, + [], + link_resolver(["A", "B"]), + { cachedRead: async () => content }, + ), + make_all_files([list]), + ); + + const pairs = r.edges.map((e) => [e.source, e.target]); + t.expect(pairs).toContainEqual(["list.md", "A.md"]); + t.expect(pairs).toContainEqual(["list.md", "B.md"]); + t.expect(r.edges.every((e) => e.edge_type === "down")).toBe(true); + t.expect(r.edges[0]!.edge_source).toBe("list_note"); + }); + + test("note without BC-list-note-field is skipped", async (t) => { + const r = await _add_explicit_edges_list_note( + make_plugin( + { edge_fields: EDGE_FIELDS, explicit_edge_sources: {} as never }, + [], + link_resolver(["A"]), + { cachedRead: async () => "- [[A]]" }, + ), + make_all_files([mock_file("plain.md", { frontmatter: {} })]), + ); + t.expect(r.edges).toHaveLength(0); + t.expect(r.errors).toHaveLength(0); + }); + + test("non-string field → invalid_field_value", async (t) => { + const r = await _add_explicit_edges_list_note( + make_plugin({ + edge_fields: EDGE_FIELDS, + explicit_edge_sources: {} as never, + }), + make_all_files([ + mock_file("list.md", { + frontmatter: { "BC-list-note-field": 5 }, + }), + ]), + ); + t.expect(r.errors[0]!.code).toBe("invalid_field_value"); + }); + + test("field not in edge_fields → invalid_edge_field", async (t) => { + const r = await _add_explicit_edges_list_note( + make_plugin({ + edge_fields: EDGE_FIELDS, + explicit_edge_sources: {} as never, + }), + make_all_files([ + mock_file("list.md", { + frontmatter: { "BC-list-note-field": "nope" }, + }), + ]), + ); + t.expect(r.errors[0]!.code).toBe("invalid_edge_field"); + }); +}); diff --git a/tests/graph/builders/traverse_note.test.ts b/tests/graph/builders/traverse_note.test.ts new file mode 100644 index 00000000..e55e541e --- /dev/null +++ b/tests/graph/builders/traverse_note.test.ts @@ -0,0 +1,82 @@ +import { _add_explicit_edges_traverse_note } from "src/graph/builders/explicit/traverse_note"; +import { describe, test } from "vitest"; +import { make_all_files, make_plugin, mock_file } from "./helpers"; + +function plugin( + resolvedLinks: Record> = {}, + edge_fields = [{ label: "up" }], +) { + return make_plugin( + { edge_fields, explicit_edge_sources: {} as never }, + [], + undefined, + { resolvedLinks }, + ); +} + +const FIELD = { "BC-traverse-note-field": "up" }; + +describe("traverse_note builder", () => { + test("note without BC-traverse-note-field → no edges", async (t) => { + const r = await _add_explicit_edges_traverse_note( + plugin({ "A.md": { "B.md": 1 } }), + make_all_files([mock_file("A.md")]), + ); + t.expect(r.edges).toHaveLength(0); + t.expect(r.errors).toHaveLength(0); + }); + + test("DFS edges follow resolvedLinks from the start note", async (t) => { + const r = await _add_explicit_edges_traverse_note( + plugin({ "A.md": { "B.md": 1 }, "B.md": { "C.md": 1 } }), + make_all_files([mock_file("A.md", { frontmatter: FIELD })]), + ); + const pairs = r.edges.map((e) => [e.source, e.target]); + t.expect(pairs).toContainEqual(["A.md", "B.md"]); + t.expect(pairs).toContainEqual(["B.md", "C.md"]); + t.expect(r.edges.every((e) => e.edge_type === "up")).toBe(true); + }); + + test("cycles are not revisited", async (t) => { + const r = await _add_explicit_edges_traverse_note( + plugin({ "A.md": { "B.md": 1 }, "B.md": { "A.md": 1 } }), + make_all_files([mock_file("A.md", { frontmatter: FIELD })]), + ); + // A is the visited start, so B's link back to A is dropped — only A→B remains + const pairs = r.edges.map((e) => [e.source, e.target]); + t.expect(pairs).toContainEqual(["A.md", "B.md"]); + t.expect(r.edges).toHaveLength(1); + }); + + test("non-string field → invalid_field_value", async (t) => { + const r = await _add_explicit_edges_traverse_note( + plugin({ "A.md": { "B.md": 1 } }), + make_all_files([ + mock_file("A.md", { + frontmatter: { "BC-traverse-note-field": 7 }, + }), + ]), + ); + t.expect(r.errors[0]!.code).toBe("invalid_field_value"); + }); + + test("field not in edge_fields → invalid_edge_field", async (t) => { + const r = await _add_explicit_edges_traverse_note( + plugin({ "A.md": { "B.md": 1 } }), + make_all_files([ + mock_file("A.md", { + frontmatter: { "BC-traverse-note-field": "nope" }, + }), + ]), + ); + t.expect(r.errors[0]!.code).toBe("invalid_edge_field"); + }); + + test("edge_source is traverse_note", async (t) => { + const r = await _add_explicit_edges_traverse_note( + plugin({ "A.md": { "B.md": 1 } }), + make_all_files([mock_file("A.md", { frontmatter: FIELD })]), + ); + t.expect(r.edges[0]!.edge_source).toBe("traverse_note"); + }); +}); diff --git a/wasm/pkg/breadcrumbs_graph_wasm.d.ts b/wasm/pkg/breadcrumbs_graph_wasm.d.ts index 85cfb742..9522b5d5 100644 --- a/wasm/pkg/breadcrumbs_graph_wasm.d.ts +++ b/wasm/pkg/breadcrumbs_graph_wasm.d.ts @@ -1,5 +1,5 @@ /* tslint:disable */ -/* eslint-disable */ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-function-type, @typescript-eslint/no-misused-new */ export function create_graph(): NoteGraph; export function create_edge_sorter(field: string, reverse: boolean): EdgeSorter; export function sort_traversal_data(graph: NoteGraph, traversal_data: TraversalData[], sorter: EdgeSorter): TraversalData[]; diff --git a/wasm/pkg/breadcrumbs_graph_wasm_bg.wasm.d.ts b/wasm/pkg/breadcrumbs_graph_wasm_bg.wasm.d.ts index 5f8ab687..05c9a110 100644 --- a/wasm/pkg/breadcrumbs_graph_wasm_bg.wasm.d.ts +++ b/wasm/pkg/breadcrumbs_graph_wasm_bg.wasm.d.ts @@ -1,5 +1,5 @@ /* tslint:disable */ -/* eslint-disable */ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-function-type, @typescript-eslint/no-misused-new */ export const memory: WebAssembly.Memory; export const create_graph: () => number; export const __wbg_get_path_edges: (a: number) => [number, number];