feat: template substitution for note bodies, filenames, and folders#824
Open
Chusca wants to merge 51 commits into
Open
feat: template substitution for note bodies, filenames, and folders#824Chusca wants to merge 51 commits into
Chusca wants to merge 51 commits into
Conversation
…ameter count
- Replace if/else chain with TOKEN_RESOLVERS lookup map in templateSubstitution.ts
(cyclomatic complexity 12 → ~2, Codacy threshold 8)
- Remove now from resolveNewNote destructuring: 8 params (Codacy threshold 8)
- Fix timezone-brittle tests: toISOString() → toLocaleDateString('en-CA')
Map.get() avoids prototype-chain traversal, eliminating Codacy's 'non-static data function retrieval' security warning. Mock @radix-ui/react-focus-scope in test setup to prevent unhandled dispatchEvent errors after component unmount in jsdom.
Type documents can declare `_subfolder_path` (e.g.
`journals/{{date:yyyy}}/{{date:MM}}`) to file new notes of that type into
nested folders at creation time. Mirrors the existing `_filename_template`
plumbing end-to-end (TS + Rust + dev harness). Resolved at creation time
only; explicit folder context wins and existing notes are never moved.
Plumbing:
- `_subfolder_path` parsed in Rust (frontmatter -> VaultEntry.subfolderPath
over IPC), registered in the known-frontmatter-key table
- TS `VaultEntry.subfolderPath` + frontmatter alias
Resolution (new `src/utils/typeSubfolder.ts`):
- Pure `resolveTypeSubfolder`, `sanitizeSubfolderPath`,
`effectiveImmediateFolder` helpers
- Strips parent-directory escapes + illegal folder chars; null when empty
Creation paths:
- `createNoteImmediate` routes through `effectiveImmediateFolder`
(explicit folder > type subfolder > root); same-day dedup becomes
folder-scoped
- `createNamedNote` threads `folderPath` through `NewNoteParams`
- `note_created` PostHog event gains `used_subfolder_path`
Dev harness:
- `vite.config.ts` browser-mode parser maps `_subfolder_path` (Playwright)
Tests:
- 16 helper unit tests + integration tests on both creation paths + Rust
parse/serialize tests + extended journal smoke test
test_case_rename_no_duplicates asserts the cache collapses case-only renames (Note.md -> note.md), which only applies on case-insensitive filesystems (macOS APFS default, Windows NTFS). On a case-sensitive filesystem (Linux ext4/btrfs) the two names are legitimately distinct files, so the premise is invalid and the assertion failed. Because the panic happened while holding the process-global ENV_LOCK, it poisoned the mutex and cascaded PoisonError into ~20 unrelated cache tests (the full vault::cache::tests suite). Probe the actual filesystem and skip when case-sensitive, with a comment flagging that cache behaviour for case-divergent paths on a case-sensitive FS remains unasserted.
- typeSubfolder.ts: replace regex consts with a Set + char-filter helper (satisfies both ESLint no-control-regex and Codacy no-dynamic-regex; behavior unchanged) - useNoteCreation.ts: resolveNewNote now takes (params: NewNoteParams) with the destructure in the body (1 formal param; under the 8-param limit)
_filename_template and _subfolder_path were added to VaultEntry while CACHE_VERSION stayed at 14. Stale on-disk caches therefore loaded Type entries with those fields null, silently disabling templated note creation (filename, subfolder, body) on the first note of a type until a later rescan repopulated them. Bump to 15 forces a one-time full rescan so every Type's templates are parsed before the first creation. Adds a regression test asserting a stale v14 cache is invalidated and the fields are re-parsed from disk.
Two JS frontmatter bugs corrupted the editor-derived type entry and broke first-of-type note creation (empty body, note at vault root, leaked frontmatter properties): - frontmatter.ts parseFrontmatter dropped `template: |` / `>` block-scalar values and parsed their indented body lines as bogus top-level keys. Now captures block-scalar content via joinBlockScalar + block-state tracking. - frontmatterOps.ts knownFrontmatterUpdates was missing subfolderPath, so `_subfolder_path` leaked into generic properties. Now mapped.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a small template engine so a Type can control how its notes are written, named, and filed at creation time. Three frontmatter fields share one resolver:
template- note body, expanded with{{date}},{{time}},{{type}}_filename_template- token-based filename (overrides the default slug)_subfolder_path- token-based, vault-relative folder for new notesTokens accept an optional
date-fnsformat specifier, e.g.{{date:yyyy-MM-dd}},{{date:EEEE}},{{time:HH:mm}}. Unknown or malformed tokens are left verbatim so typos stay visible and predictable.Why
Date-aware notes (daily journals, meeting logs) should work out of the box - the same affordance I'm used to from apps like Logseq. Token-based filing (
journals/{{date:yyyy}}/{{date:MM}}) keeps a time-series organized without per-note effort.What's in this PR
Template engine (
src/utils/templateSubstitution.ts) - puresubstituteTemplate(template, ctx)backed by aTOKEN_RESOLVERSmap fordate/time/type. Shared by body, filename, and folder substitution.Body templates -
template:is expanded inbuildNoteContentduring note creation._filename_template- feedsresolveTypeFilename, overriding the slug inresolveNewNote. If the resolved path already exists, creation returns anexistingplan and opens the note instead of blocking._subfolder_path(src/utils/typeSubfolder.ts, new) - Type-scoped folder for new notes:resolveTypeSubfolder,sanitizeSubfolderPath,effectiveImmediateFolder< > : " | ? * \+ control chars), drops../.segments so the path cannot escape the vault, trims edges, and returns null when nothing usable remains_filename_templateplumbing end-to-end:subfolderPathis parsed in Rust (VaultEntry.subfolderPathover IPC) and registered in the known-frontmatter-key table_filename_templateand_subfolder_pathare underscore-prefixed system properties (per ADR-0008), configurable via raw frontmatter only - no UI field in the Type customize modal.Example
A daily-journal Type using all three fields:
Creating a new journal on 2026-06-07 files it at
journals/2026/06/2026-06-07.mdwith body:Analytics
note_createdis augmented withused_filename_templateandused_subfolder_pathnote_opened_existingfires when a filename-template collision opens an existing noteTest plan
pnpm exec vitest run src/utils/templateSubstitution.test.ts- tokens, format specifiers, edge casespnpm exec vitest run src/utils/typeSubfolder.test.ts- 16 tests (resolution, sanitization, precedence)pnpm exec vitest run src/hooks/useNoteCreation.test.ts src/hooks/useNoteCreation.extra.test.ts- both creation paths, filename resolution, folder routing, collision/open-existingcargo test --manifest-path src-tauri/Cargo.toml-subfolderPathparse/serialize + frontmatter-key registrationpnpm lint·npx tsc --noEmit·pnpm test- cleanNotes for reviewers
Two commits fix problems surfaced while dogfooding the feature end-to-end (both ship with regression tests):
52a40b6) -CACHE_VERSIONgoes 14 → 15 so a stale on-disk cache from before theseVaultEntryfields shipped is invalidated once; every Type's templates are then parsed on the first launch after upgrade. Without it, the first note of each type would render with an empty body and land at the vault root until a later rescan repopulated the fields.4b04aa) - the JS frontmatter parser now correctly captures YAML literal/folded block scalars (template: |/>), the natural way to author a multi-line template, and_subfolder_pathis mapped through the same frontmatter-key table as_filename_template.