Skip to content

feat: template substitution for note bodies, filenames, and folders#824

Open
Chusca wants to merge 51 commits into
refactoringhq:mainfrom
Chusca:main
Open

feat: template substitution for note bodies, filenames, and folders#824
Chusca wants to merge 51 commits into
refactoringhq:mainfrom
Chusca:main

Conversation

@Chusca

@Chusca Chusca commented Jun 6, 2026

Copy link
Copy Markdown

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 notes

Tokens accept an optional date-fns format 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) - pure substituteTemplate(template, ctx) backed by a TOKEN_RESOLVERS map for date / time / type. Shared by body, filename, and folder substitution.

Body templates - template: is expanded in buildNoteContent during note creation.

_filename_template - feeds resolveTypeFilename, overriding the slug in resolveNewNote. If the resolved path already exists, creation returns an existing plan and opens the note instead of blocking.

_subfolder_path (src/utils/typeSubfolder.ts, new) - Type-scoped folder for new notes:

  • Pure helpers: resolveTypeSubfolder, sanitizeSubfolderPath, effectiveImmediateFolder
  • Sanitization strips illegal cross-platform chars (< > : " | ? * \ + control chars), drops .. / . segments so the path cannot escape the vault, trims edges, and returns null when nothing usable remains
  • Folder precedence: explicit user-chosen folder > type subfolder > vault root (an explicit empty string is treated as an intentional root override and preserved)
  • Resolved at creation time only - existing notes are never moved; same-day dedup becomes folder-scoped
  • Mirrors the _filename_template plumbing end-to-end: subfolderPath is parsed in Rust (VaultEntry.subfolderPath over IPC) and registered in the known-frontmatter-key table

_filename_template and _subfolder_path are 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:

---
type: Type
title: Journal
_filename_template: "{{date}}"
_subfolder_path: "journals/{{date:yyyy}}/{{date:MM}}"
template: |
  # {{date:EEEE, MMMM do, yyyy}}
  ## What happened today?
---

Creating a new journal on 2026-06-07 files it at journals/2026/06/2026-06-07.md with body:

# Sunday, June 7th, 2026
## What happened today?

Analytics

  • note_created is augmented with used_filename_template and used_subfolder_path
  • note_opened_existing fires when a filename-template collision opens an existing note

Test plan

  • pnpm exec vitest run src/utils/templateSubstitution.test.ts - tokens, format specifiers, edge cases
  • pnpm 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-existing
  • cargo test --manifest-path src-tauri/Cargo.toml - subfolderPath parse/serialize + frontmatter-key registration
  • Playwright journal smoke (extended) covers end-to-end filing
  • pnpm lint · npx tsc --noEmit · pnpm test - clean
  • Coverage gates: frontend ≥70%, Rust ≥85%

Notes for reviewers

Two commits fix problems surfaced while dogfooding the feature end-to-end (both ship with regression tests):

  • Cache version bump (52a40b6) - CACHE_VERSION goes 14 → 15 so a stale on-disk cache from before these VaultEntry fields 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.
  • Block-scalar templates (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_path is mapped through the same frontmatter-key table as _filename_template.

Chusca and others added 16 commits June 6, 2026 23:25
…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.
@Chusca Chusca changed the title feat: template substitution for note bodies and filenames feat: template substitution for note bodies, filenames, and folders Jun 7, 2026
github-actions Bot and others added 7 commits June 7, 2026 16:28
- 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.
@Chusca Chusca marked this pull request as ready for review June 7, 2026 18:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant