Skip to content

fix: make the file-name preview tell the truth, and the FIELD filter warning readable - #1582

Merged
chhoumann merged 6 commits into
masterfrom
chhoumann/1563-preview-truth
Jul 27, 2026
Merged

fix: make the file-name preview tell the truth, and the FIELD filter warning readable#1582
chhoumann merged 6 commits into
masterfrom
chhoumann/1563-preview-truth

Conversation

@chhoumann

@chhoumann chhoumann commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Closes #1563. Closes #1564.

Two formatter output surfaces that were telling users things that were not true.

#1563 - the file-name preview left {{TEMPLATE:}} literal

CompleteFormatter.formatFileName resolves {{TEMPLATE:...}}: format() runs
replaceTemplateInString, and path prompt scope is deliberately propagated into the child
engine. The preview never called it, so the builder said one thing and the run did another.

Before After
before after

Measured in this worktree's isolated vault (Obsidian 1.13.0). Naming.md contains
{{DATE:YYYY-MM-DD}} {{VALUE:title}}:

preview (before): {{TEMPLATE:Templates/Naming.md}}
preview (after):  2026-07-27 Example Title
run:              created "2026-07-27 My Note.md"

The one-page input form contradicted itself harder still: its preflight scan already walks
into the include for this same field (collectChoiceRequirements, scope noteTitle), so
it prompted for a variable it could only have found inside the template, then previewed the
unresolved token beside the answer. That is why the issue's cheaper alternative - a one-line
note saying "resolves at run time, not previewed" - was declined: this is not a missing
feature, it is two surfaces disagreeing.

What the include is read through

The preview's own pass list, never SingleTemplateEngine. Building one would construct a real
CompleteFormatter and open blocking prompt modals on every keystroke - exactly the bug #1560
fixed on the content field. The inert-preview contract holds: no prompts, no macro engine, no
inline JS, no Notices. Pinned by tests that fail if any of those are reached.

The multi-line question

The issue asks what a multi-line body should look like in a name. The run already has an
answer: normalizeGeneratedFilePath collapses a run of line breaks, with the spaces around
it, to a single space - and every generated name goes through it (TemplateChoiceEngine,
TemplateInsertEngine, templateNoteDiscovery, and the capture target via
captureTargetResolution). So the preview now runs the same normalizer, once, at the end.

Doing the collapse there rather than inside the include matters: preview and run then splice
first and normalize second, in the same order, and agree byte for byte instead of differing by
a space at the seam. Verified:

format "{{TEMPLATE:Templates/Naming.md}}-suffix"
  preview: 2026-07-27 Example Title -suffix
  run:     created "2026-07-27 My Note -suffix.md"

That also closed three older divergences for every token, not just includes: a trailing . or
space is stripped as the run strips it, a backslash is a path separator as Obsidian's own
normalizePath makes it, and a ./.. or empty segment now reports the error the run aborts
with instead of showing a name.

A body that really is many lines gets joined - and says so once, rather than presenting
someone's whole note template as an unexplained run-on name. It stays quiet about the trailing
newline every well-formed one-line naming template has.

multi-line

Three things this needed beyond the reader

  • The template pass runs first, where the run has it (before globals and {{VALUE}}).
    Resolving it later would splice a body in for a token a global snippet produced, which the
    run leaves literal - trading one lie for another.
  • A per-pass include budget, because that ordering makes a global snippet holding a
    {{TEMPLATE:}} token re-arm the include loop with an unwound cycle set. It also caps the
    fan-out of a wide include tree on a field that resolves on every keystroke. There is a test
    that hangs forever without it.
  • An included body resolves with CONTENT token semantics, because at run time it goes
    through the child engine's formatFileContent. The file-name pass list would leave literal
    exactly the tokens people put in templates ({{linkcurrent}}, {{linksection}}).

A missing template is now an error diagnostic rather than a quiet placeholder - the run throws
there and the choice dies, so the row must read "Unresolved:". Fixed in the content preview's
reader too, where not-found was the odd branch out (cycle and max-depth already reported).

And a layout bound

The preview row had no clamp, which was fine while it could only hold what you typed. A 40-line
include put 468px of muted text under a single-line input. Two lines now, full string in
title; the content field is left alone, where a multi-line preview is the point. The one-page
form's row needed the same treatment - it sits above every input and re-renders as you type,
so unclamped it pushed the fields and Submit down under the caret (371px -> 39px).

one-page

#1564 - the 256-character "Unknown FIELD filter" warning

Before After
before after
before: Unknown FIELD filter "fodler" in "{{FIELD:status|fodler:abc}}" was ignored. Supported
        filters: folder, tag, inline, inline-code-blocks, exclude-folder, exclude-tag,
        exclude-file, default, default-from, default-empty, default-always, case-sensitive,
        multi.
after:  Unknown FIELD filter "fodler" - did you mean "folder"? Ignored in
        {{FIELD:status|fodler:abc}}.

The actionable clause comes before the echoed token, so the three-line clamp on the inline
diagnostic can never cut the fix off, and both user-controlled parts are length-bounded so an
absurd key cannot re-inflate the message. The full vocabulary survives in the one case where it
is still the most useful thing to say: when nothing is close enough to name.

Three matching rules, in order - one edit away (Damerau, so mutli -> multi costs 1), then a
shared prefix (exclude -> the three exclude-*), then two edits but only into a long key.
That last bound is what stops filter being answered with folder, and case-insensitive
gets a sentence rather than a guess, because obeying "did you mean case-sensitive?" would
silently invert the request. FieldSuggestionParser-1564-unknown-filter.test.ts pins the full
answer for a 21-probe table, including everything that must NOT be suggested.

Two supporting fixes in the same parser:

  • FIELD_FILTER_KEYS is authoritative now - membership is tested before the switch dispatches,
    so a case added without an entry stops working instead of drifting away from what the warning
    advertises.
  • A pipe part with no colon was dropped in total silence, so {{FIELD:status|mutli}} quietly
    downgraded a multi-select prompt to single-select with nothing anywhere saying why.

Tradeoffs and known divergences

  • Not the full normalizer for capture-target special forms. vault, filter:,
    property: targets are classified before normalization at run time. They contain no control
    characters or trailing dots, so the preview is unchanged for them.
  • {{title}} inside an included body stays literal. The run resolves it to the empty
    string (the title is derived from the name being built, so it is not known yet). Neither a
    blank nor this formatter's example title is a preview worth showing, so the token is left
    visible. Happy to change it if you disagree - it is one line.
  • Inline js quickadd fences and macros inside an included body stay literal. The run
    executes them; a preview must not. Pre-existing for a fence typed at the top level of the
    field, and unchanged. A {{TEMPLATE:...}} written as a string literal inside a fence is
    now skipped rather than expanded, since the run consumes the fence before its template pass
    (caught in review, caffb4b2).

Validation

  • 4131 unit tests pass (+31 new), tsc, eslint and svelte-check clean.
  • Everything above measured live in this worktree's isolated vault on Obsidian 1.13.0, with the
    run cross-checked against the preview for each case rather than the preview alone.
  • Design reviewed adversarially before coding (it caught a false premise about the capture path
    and the include-loop hazard), and the implementation reviewed again after: that pass found
    three real defects of mine - a prototype-chain leak in the hint table
    ({{FIELD:x|constructor:y}}), the colon-less warning calling a correctly spelled filter
    unknown, and the unclamped one-page row - all fixed with regression tests, plus a fourth
    (the inline-script fence) from the review on this PR.

Filed rather than folded in

…e list

The unknown-filter warning answered every typo by printing all thirteen
supported keys: 256 characters, of which the last 180 were a list you had
to read to the end to discover the answer was `folder` all along. As an
Obsidian Notice it filled the corner of the screen; as the inline preview
diagnostic added in #1560 it is the one message that needs a three-line
clamp to fit.

It now names the mistake:

  Unknown FIELD filter "fodler" - did you mean "folder"? Ignored in
  "{{FIELD:status|fodler:abc}}".

The actionable clause comes before the echoed token so the clamp can never
cut the fix off, and both user-controlled parts are length-bounded so an
absurd key or a long token cannot re-inflate the message. The full
vocabulary survives in the one case where it is still the most useful
thing to say: when nothing is close enough to name.

Three matching rules, in order - one edit away (Damerau, so `mutli` ->
`multi` costs 1), then a shared prefix (`exclude` -> the three
`exclude-*`), then two edits but only into a long key. That last bound is
what stops `filter` being answered with `folder`, and `case-insensitive`
is answered with a sentence rather than a guess, because obeying "did you
mean case-sensitive?" would silently invert the request.

Two supporting fixes in the same parser:

- FIELD_FILTER_KEYS is now authoritative. Membership is tested before the
  switch dispatches, so a case added without an entry stops working
  instead of silently drifting away from what the warning advertises.
- A pipe part with no colon used to be dropped in total silence, so
  `{{FIELD:status|mutli}}` quietly downgraded a multi-select prompt to a
  single-select one with nothing anywhere saying why. It warns now.

Closes #1564
Every generated name goes through `normalizeGeneratedFilePath` on the way
to the vault - TemplateChoiceEngine, TemplateInsertEngine,
templateNoteDiscovery, and the capture target via captureTargetResolution
all call it - and the preview did not, so it asserted names the run would
never create. `Meeting notes.` previewed with the dot the run strips;
`Notes\2026\Log` previewed as one segment though the run writes it as a
path (Obsidian's own normalizePath rewrites the backslash); a `.` or `..`
segment previewed as an ordinary name though the run aborts the choice on
it.

It is also the answer to what a multi-line `{{TEMPLATE:}}` body should
look like in a name preview (#1563): the normalizer already collapses a
run of line breaks, with the spaces around it, into a single space. Doing
the collapse here rather than inside the include means preview and run
splice first and normalize second, in the same order, so the two agree
byte for byte instead of differing by a space at the seam.

The normalizer is refactored into a core that collects its rejections
rather than throwing on sight. `normalizeGeneratedFilePath` keeps its
exact behaviour by throwing the first one; the new
`previewGeneratedFilePath` returns them, because a preview evaluates
incomplete input on every keystroke and must not throw its way out of a
keystroke (#1558). The rejections become error diagnostics, so a name the
run would refuse now says so instead of looking fine.
`CompleteFormatter.formatFileName` resolves `{{TEMPLATE:...}}` - `format()`
runs `replaceTemplateInString`, and path prompt scope is deliberately
propagated into the child engine - but the file-name preview never called
it. So the builder's "File name format" field said
`{{TEMPLATE:Naming.md}}-My Note` while the run created
`2026-07-27 My Note.md`.

The one-page input form contradicted itself harder still: the preflight
scan behind that modal already walks INTO the include for this same field,
so it prompted for a variable it could only have found inside the
template, then previewed the unresolved token beside the answer.

The include is read through THIS formatter, which is what keeps the
preview inert (#1558): the same substitutions the top level gets, no
prompts, no macro engine, no inline JS. Building a SingleTemplateEngine
here would construct a real CompleteFormatter and open blocking modals on
every keystroke - the bug #1560 fixed on the content field.

Four things this needed beyond the reader itself:

- The template pass runs FIRST, where the run has it (before globals and
  before {{VALUE}}). Resolving it later would have made the preview splice
  a body in for a token that a global snippet produced, which the run
  leaves literal - trading one lie for another.
- That ordering makes a global snippet holding a {{TEMPLATE:}} token
  re-arm the include loop with an unwound cycle set, so a per-pass include
  budget bounds it. It also caps the fan-out of a wide include tree on a
  field that resolves on every keystroke.
- An included body resolves with CONTENT token semantics, because at run
  time it goes through the child engine's `formatFileContent`. Previewing
  it with the file-name pass list would leave literal exactly the tokens
  people put in templates ({{linkcurrent}}, {{linksection}}).
- A missing template is an error diagnostic, not a quiet placeholder: the
  run throws there and the choice dies, so the row must read "Unresolved:"
  rather than presenting a name. Fixed in the content preview's reader
  too, where not-found was the odd branch out - cycle and max-depth
  already reported.

A multi-line body is joined into one line by the normalizer, which is what
the run does; the preview says so once, rather than presenting someone's
whole note template as a run-on name with no explanation. It stays quiet
about the trailing newline every well-formed one-line naming template has.

Closes #1563
.qa-preview-row carried overflow-wrap and no clamp, which was fine while
the row could only hold what you typed. Since #1563 it can hold a whole
included template: a 40-line note previews as 468px of muted text under a
single-line input, re-flowing the builder on every keystroke. Two lines
now, with the full string in `title`, mirroring .qa-preview-issue - which
was clamped for exactly this reason. The content field is left alone: a
multi-line preview is the point there.

Clamped on the row rather than the value span, because -webkit-box is
block-level and would drop the value onto its own line under "Preview:".

Also drops the quotes around the echoed {{FIELD:...}} token in the
unknown-filter warning: the braces already delimit it, and in the builder
the pair wrapped across the line break, leaving a dangling `"` at the end
of the first line.
All three are mine, from the commits above.

1. The FIELD hint table was an object literal indexed by whatever follows a
   pipe, so {{FIELD:x|constructor:y}} answered with a member of
   Object.prototype: "Unknown FIELD filter "constructor" - function Object()
   { [native code] }...". It is a Map now, which has no prototype chain to
   walk into. Reachable from both the inline preview and the run's Notice.

2. The new colon-less warning called a correctly spelled filter unknown.
   {{FIELD:status|folder}} - one keystroke short of `|folder:`, which a live
   preview sees constantly - produced "Unknown FIELD filter "folder" ...
   Supported filters: folder, tag, ...": it contradicted itself AND reprinted
   the 256-character dump #1564 exists to delete. Others got a confidently
   wrong redirect ("|inline" -> did you mean "inline-code-blocks"?), because
   the suggester excludes the exact match from its own pool. A recognised key
   without a value is a different mistake and now gets its own sentence:
   `FIELD filter "folder" needs a value - write "folder:value".`

3. The one-page input form's preview row was left unclamped. Since the
   commit above, a naming template's include resolves there too, so a 39-line
   template rendered 371px of joined text into a block that sits ABOVE every
   field and re-renders on each keystroke - pushing the inputs and Submit
   down and shifting them under the caret. Clamped to two lines with the full
   string in `title`, same rule as the builder row. Measured live: 371px ->
   39px.
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Filename previews now resolve bounded template includes, normalize generated paths with diagnostics, and display long values compactly. FIELD parsing now validates filter keys and reports concise typo or missing-value warnings with suggestions.

Changes

Filename preview behavior

Layer / File(s) Summary
Best-effort generated path normalization
src/utils/generatedFilePath.ts, src/formatters/fileNameDisplayFormatter-1563-normalize.test.ts
Preview normalization returns normalized paths with accumulated validation problems, while strict normalization throws the first problem.
Template-aware filename preview pipeline
src/formatters/fileNameDisplayFormatter.ts, src/formatters/formatDisplayFormatter.ts, src/preflight/..., src/formatters/fileNameDisplayFormatter-1563-template.test.ts
Filename previews resolve template bodies through bounded, inert recursive formatting, report include failures, and align one-page preview behavior with runtime output.
Constrained preview rendering
src/gui/ChoiceBuilder/components/FormatPreviewField.svelte, src/preflight/OnePageInputModal.ts, src/styles.css
Filename and preflight previews add full-text tooltips and two-line clamping for long values.

FIELD filter diagnostics

Layer / File(s) Summary
Filter suggestion and warning contracts
src/utils/suggestSimilarKeys.ts, src/utils/FieldSuggestionParser.ts, src/utils/FieldSuggestionParser-1564-unknown-filter.test.ts
Edit-distance and prefix heuristics support bounded unknown-filter messages with ranked suggestions and filter-specific hints.
FIELD parser warning flow
src/utils/FieldSuggestionParser.ts, src/utils/FieldSuggestionParser-1564-unknown-filter.test.ts
Parsing validates filter membership before dispatch, distinguishes unknown keys from missing values, warns for bare unknown keys, and preserves recognized parsing behavior.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant FileNameDisplayFormatter
  participant TemplateVault
  participant PreviewDiagnostics
  User->>FileNameDisplayFormatter: enter filename format
  FileNameDisplayFormatter->>TemplateVault: read included template
  TemplateVault-->>FileNameDisplayFormatter: return template body
  FileNameDisplayFormatter->>PreviewDiagnostics: record include or path problems
  FileNameDisplayFormatter-->>User: return normalized preview
Loading

Possibly related PRs

Suggested labels: released

Poem

A bunny previews paths in a row,
While templates bloom and diagnostics glow.
Typos hop near the right filter name,
Long names stay tidy inside their frame.
Carrots cheer as the tests pass bright.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 46.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address #1563 and #1564 by resolving file-name template previews and shortening FIELD filter warnings with suggestions.
Out of Scope Changes check ✅ Passed The UI and style updates support the preview and diagnostic goals, and no unrelated code changes are evident from the summary.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly captures the two main changes: truthful file-name previews and more readable FIELD filter warnings.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chhoumann/1563-preview-truth

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 27, 2026

Copy link
Copy Markdown

Deploying quickadd with  Cloudflare Pages  Cloudflare Pages

Latest commit: caffb4b
Status: ✅  Deploy successful!
Preview URL: https://2934b8c3.quickadd.pages.dev
Branch Preview URL: https://chhoumann-1563-preview-truth.quickadd.pages.dev

View logs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e9c82b56e1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/formatters/fileNameDisplayFormatter.ts Outdated
Review catch. A fence is verbatim JavaScript source and the run consumes
it BEFORE its template pass (replaceInlineJavascriptInString is the run's
first pass), so a "{{TEMPLATE:N.md}}" written as a string literal inside a
script is never an include at run time. The preview has no inline-JS pass
at all - by design, it must not execute anything - so it read that path,
spliced the body into the middle of the displayed source, and reported a
"Template not found" ERROR for a format that is fine.

Same helper and the same reason as expandLinebreakEscapesOutsideTokens
(#1467), which protects fences from `\n` expansion two functions away.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/gui/ChoiceBuilder/components/FormatPreviewField.svelte (1)

135-139: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Clamped preview text relies solely on hover-only title tooltips for full content. Both the builder's live preview row and the one-page modal's preview value clamp to two lines and expose the overflow only through the native title attribute, which is not reliably reachable via keyboard and isn't consistently announced by screen readers.

  • src/gui/ChoiceBuilder/components/FormatPreviewField.svelte#L135-L139: consider pairing the title with a focusable/expandable element (e.g., a "show full value" toggle or aria-describedby region) so the full text is programmatically discoverable.
  • src/preflight/OnePageInputModal.ts#L721-L730: apply the same accessible fallback to .qa-preview-val alongside the existing title attribute.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/gui/ChoiceBuilder/components/FormatPreviewField.svelte` around lines 135
- 139, The clamped preview text is only exposed through hover-only title
tooltips, so add an accessible, keyboard-reachable way to discover the full
value. Update the preview row in
src/gui/ChoiceBuilder/components/FormatPreviewField.svelte (lines 135-139) and
the .qa-preview-val implementation in src/preflight/OnePageInputModal.ts (lines
721-730) consistently, using a focusable/expandable control or an associated
aria-describedby region while preserving the existing title behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/gui/ChoiceBuilder/components/FormatPreviewField.svelte`:
- Around line 135-139: The clamped preview text is only exposed through
hover-only title tooltips, so add an accessible, keyboard-reachable way to
discover the full value. Update the preview row in
src/gui/ChoiceBuilder/components/FormatPreviewField.svelte (lines 135-139) and
the .qa-preview-val implementation in src/preflight/OnePageInputModal.ts (lines
721-730) consistently, using a focusable/expandable control or an associated
aria-describedby region while preserving the existing title behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 767703b6-6529-471f-a469-3829569eb9d4

📥 Commits

Reviewing files that changed from the base of the PR and between bf39e2b and e9c82b5.

📒 Files selected for processing (13)
  • src/formatters/fileNameDisplayFormatter-1563-normalize.test.ts
  • src/formatters/fileNameDisplayFormatter-1563-template.test.ts
  • src/formatters/fileNameDisplayFormatter.ts
  • src/formatters/formatDisplayFormatter.ts
  • src/gui/ChoiceBuilder/components/FormatPreviewField.svelte
  • src/preflight/OnePageInputModal.ts
  • src/preflight/runOnePagePreflight.filenamePreview.test.ts
  • src/preflight/runOnePagePreflight.ts
  • src/styles.css
  • src/utils/FieldSuggestionParser-1564-unknown-filter.test.ts
  • src/utils/FieldSuggestionParser.ts
  • src/utils/generatedFilePath.ts
  • src/utils/suggestSimilarKeys.ts

@chhoumann
chhoumann merged commit 08d4a03 into master Jul 27, 2026
14 checks passed
@chhoumann
chhoumann deleted the chhoumann/1563-preview-truth branch July 27, 2026 07:43
chhoumann added a commit that referenced this pull request Jul 27, 2026
`Bad: {{VALUE:title}}` rendered as "Unresolved: Bad: Example Title". Every token
resolved; nothing was unresolved. The label sent the reader hunting for a broken
token when the problem was the finished name.

Two error classes shared one label, and the split already existed: #1582 added
`kind: "path"` for the capture target's hideWhen gate, defined as "the name came
out fine, the vault will just not accept it". Reusing that axis rather than
adding a parallel one keeps a single classification that cannot disagree with
itself. The row now reads Preview: / Won't be created: / Unresolved:, with
unresolved winning when both are present - a missing template beside an empty
path segment is fundamentally a failure to resolve.

The existence probe that suppresses the illegal-character error was shape-blind
in both directions, which put a wrong label on two real cases. Obsidian's path
map holds no trailing slash, so a capture target of `Meetings: 2026/` - naming
a FOLDER to pick inside, which exists - matched neither probe and went red;
while a file-name format of `Meetings: 2026` was excused by a folder of that
name although the run still cannot create `Meetings: 2026.md`. The probe now
requires a TFolder for the trailing-slash shape and a TFile otherwise.

FormatPreviewField.test.ts pins all three labels, including the both-classes
case; the illegal-character test pins all four probe shapes.

Closes #1594.
chhoumann added a commit that referenced this pull request Jul 27, 2026
`Bad: {{VALUE:title}}` rendered as "Unresolved: Bad: Example Title". Every token
resolved; nothing was unresolved. The label sent the reader hunting for a broken
token when the problem was the finished name.

Two error classes shared one label, and the split already existed: #1582 added
`kind: "path"` for the capture target's hideWhen gate, defined as "the name came
out fine, the vault will just not accept it". Reusing that axis rather than
adding a parallel one keeps a single classification that cannot disagree with
itself. The row now reads Preview: / Won't be created: / Unresolved:, with
unresolved winning when both are present - a missing template beside an empty
path segment is fundamentally a failure to resolve.

The existence probe that suppresses the illegal-character error was shape-blind
in both directions, which put a wrong label on two real cases. Obsidian's path
map holds no trailing slash, so a capture target of `Meetings: 2026/` - naming
a FOLDER to pick inside, which exists - matched neither probe and went red;
while a file-name format of `Meetings: 2026` was excused by a folder of that
name although the run still cannot create `Meetings: 2026.md`. The probe now
requires a TFolder for the trailing-slash shape and a TFile otherwise.

FormatPreviewField.test.ts pins all three labels, including the both-classes
case; the illegal-character test pins all four probe shapes.

Closes #1594.
chhoumann added a commit that referenced this pull request Jul 27, 2026
…run stops before the damage (#1618)

* fix(preview): resolve {{MVALUE}}, and stop discarding the collected answer

The file-name and format previews left {{MVALUE}} literal while the run opened
the math modal for it. Both display formatters already overrode
promptForMathValue with a "calculation_result" stand-in that nothing could
reach - a dead override is the tell that the pass was meant to be there.
Positioned where CompleteFormatter.format has it: after {{FILE:}}, before
{{RANDOM:}}.

Also closes the loop the preview exposed. RequirementCollector registers a
requirement keyed "mvalue" for this token, so the one-page input form asks for
a "Math expression" and the CLI prints missingFlags: ["value-mvalue=<value>"] -
and nothing read either answer, so the form asked and then the run opened the
modal on top of it, and passing the CLI's own recommended flag changed nothing.
replaceMathValueInString now reads the collected value first, the way {{VALUE}}
always has. READ, never write: the prompt's answer stays unstored, so
{{MVALUE}} {{MVALUE}} is still two questions.

The non-interactive abort also named "a {{MATH}} expression", a token QuickAdd
has never had (MATH_VALUE_REGEX is /{{MVALUE}}/i) - in the one message whose
job is to say which flag to pass.

Closes #1587. Closes #1607.

* fix(preview): give {{VDATE:}} the run's default format, and its answered value

Two ways the preview disagreed with the run about the same token.

1. A format-less {{VDATE:due}} previewed as the raw token, while the run
supplies YYYY-MM-DD (or YYYY-MM-DD HH:mm under |time) and creates the note.
The bail-out narrows from "no name OR no format" to "no name", which is the
run's own rule.

That token's own text contains a colon, so the illegal-character diagnostic
added in #1595 was firing on it: a working {{VDATE:due}} showed a red "a file
or folder name cannot contain :" under a choice that creates 2026-07-27.md.
Rendering the date deletes the false accusation and, on {{VDATE:due|time}},
lets the true one appear - the run's datetime default really does put HH:mm in
the file name, and Obsidian really does refuse it.

2. Neither preview read an ANSWERED date. The one-page input form seeds the
user's real picks into the formatter before computing the preview, and VDATE
was the only seeded requirement type ignored - so the row showed today beside
the date just chosen. Both formatters now resolve a stored value through
renderStoredDateVariable, extracted from the run's own tail so there is one
implementation and not three. Answered-empty ("") stays empty; an unparseable
seeded string keeps the verbatim back-compat branch; only `undefined` falls
through to the example date.

Both previews also stop throwing on a half-typed |startof: unit, which blanked
the row and reddened it between two keystrokes (#1558's failure mode).

Closes #1589.

* fix(preview): say so when a file-name format uses {{title}}

`{{title}} note` previewed as an ordinary name in ordinary styling, and the run
created nothing: formatFileName rejects the token outright, because the title
IS the file name. The preview now mirrors both of the run's checks - on the raw
format string, and on format()'s output, where an expanded {{GLOBAL_VAR:}}
snippet or a {{VALUE}} resolving to the literal text can smuggle one in.

The token keeps previewing literally rather than as an example title: it is
genuinely unresolved on screen, so the row's "Unresolved:" label is true, and
the new diagnostic is what says it will never resolve.

The output-side check needed one correction first, or it would have turned a
WORKING choice red: a {{title}} inside a spliced-in {{TEMPLATE:}} body is not a
failure. At run time that body goes through the child engine's
formatFileContent, which resolves the token to the stored title (or "") before
formatFileName ever sees the name. The preview now does the same, at the
include seam - not through the shared token resolver, which would have invented
"My Document Title" for an unstored variable.

`/\{\{title\}\}/i` also stopped being copy-pasted: six inlined copies in
CompleteFormatter and the new preview check all read TITLE_REGEX, so the rule
cannot drift between the surface that warns and the code that throws.

Closes #1588.

* fix(preview): stop labelling a resolved name "Unresolved:"

`Bad: {{VALUE:title}}` rendered as "Unresolved: Bad: Example Title". Every token
resolved; nothing was unresolved. The label sent the reader hunting for a broken
token when the problem was the finished name.

Two error classes shared one label, and the split already existed: #1582 added
`kind: "path"` for the capture target's hideWhen gate, defined as "the name came
out fine, the vault will just not accept it". Reusing that axis rather than
adding a parallel one keeps a single classification that cannot disagree with
itself. The row now reads Preview: / Won't be created: / Unresolved:, with
unresolved winning when both are present - a missing template beside an empty
path segment is fundamentally a failure to resolve.

The existence probe that suppresses the illegal-character error was shape-blind
in both directions, which put a wrong label on two real cases. Obsidian's path
map holds no trailing slash, so a capture target of `Meetings: 2026/` - naming
a FOLDER to pick inside, which exists - matched neither probe and went red;
while a file-name format of `Meetings: 2026` was excused by a folder of that
name although the run still cannot create `Meetings: 2026.md`. The probe now
requires a TFolder for the trailing-slash shape and a TFile otherwise.

FormatPreviewField.test.ts pins all three labels, including the both-classes
case; the illegal-character test pins all four probe shapes.

Closes #1594.

* fix(preview): give the one-page form its diagnostics and its target folder

The one-page input form previews the note's file name while you fill it in,
using the same FileNameDisplayFormatter the builder uses - and wired up two
fewer things, so the same format previewed differently in the two places.

Every diagnostic was thrown away. computePreview returned Record<string,string>
and the modal rendered only those strings, so a name Obsidian will refuse was
presented in ordinary styling with nothing said. This is the surface where the
message is worth the most: the user's REAL answers are seeded into the
formatter, so the row shows the exact name that is about to fail. It is now an
ordered list of rows carrying label, text and diagnostics, rendered with the
builder's own .qa-preview-issue styling and its three-state label vocabulary.

setTargetFolderPath was never called, so `Notes/{{FOLDER}}/x` previewed
`Notes//x` plus an empty-segment error nobody could see. It now resolves
against the choice's folder when the run has already decided it - a single
configured folder, no chooser - and falls back to the builder's neutral
placeholder otherwise. Deliberately NOT the raw configured folder in every
case: the run formats it first (formatFolderPath) while setTargetFolderPath
does not, so a folder of `Journal/{{DATE:YYYY-MM}}` would splice the literal
token into the name and, since every argument-bearing token carries a colon,
raise the illegal-character error against a choice that works. The builder's
row takes the same folder, so the two surfaces agree.

Three things found while wiring it up:

- The row's key was the raw record key, so it read `fileName:`.
- The first pass ran before renderField populated the value map, flashing a
  stand-in over prefilled answers.
- updatePreviews had no generation guard; the 150ms debounce orders the STARTS
  of these passes, not their completions, and one can read up to 25 templates.
  Text and problems now commit under one monotonic token, as in the builder.

The empty tinted box reading "Preview" is gone too: it was rendered for every
Capture, Macro and Multi choice, and every Template using the default
note-title prompt, none of which produce a row.

Closes #1590.

* fix: refuse an impossible file name before running the template body

A Template choice whose name resolves to something Obsidian refuses did not
fail when the name was computed. It failed at vault.create - by which point
QuickAdd had already created the target folder and formatted the ENTIRE
template body: real {{VALUE}} prompts, macros, and inline `js quickadd` fences
with whatever side effects those scripts have. The user answered the whole
prompt chain, their scripts ran, and then the choice died.

Measured in this worktree's isolated vault (Obsidian 1.13.0), a Template choice
named `Bad: {{VALUE:title}}` whose template contains an inline script:

  before: ok:false "Choice execution failed; no file was created."
          folderExists: true, inlineScriptRuns: 1
  after:  ok:false "Cannot create \"Repro1591Folder/Bad: My Note.md\": a file
          or folder name cannot contain \":\". ..."
          folderExists: false, inlineScriptRuns: 0

The rule is "reject only if the file does not already exist": ":" is legal on
macOS/Linux at the filesystem level, so a note made outside Obsidian really can
carry one, and appending to it works today because nothing ever asks Obsidian
to accept the name. Each guard therefore sits on a branch where non-existence
is already established, so it needs no probe of its own:

- TemplateEngine.createFileWithTemplate, first statement. Its two production
  callers are TemplateChoiceEngine's `else` branch of vault.adapter.exists and
  its freshly incremented collision name.
- CaptureChoiceEngine, gated character-for-character on the dispatch condition
  that routes to onCreateFileIfItDoesntExist - placed above the heading picker,
  so the user is not asked to choose a heading for a note that cannot be
  created - and re-asserted at that sink so the invariant is local to it.
- The "move note to match choice settings?" offer, which creates the target
  folder and then fails at renameFile. It declines quietly: the offer is an
  optional convenience, and the choice's own preview explains the problem.

Deliberately NOT inside normalizeGeneratedFilePath: CaptureChoiceEngine calls
that as an existence PROBE inside try/catch, where a new throw would silently
answer "does not exist".

The character set and the {{TIME}} hint come from generatedFilePath.ts, which
the preview also reads, so the row that warns and the code that stops cannot
drift. Only the tense differs.

Closes #1591.

* fix(preview): fold in the adversarial review of this branch

Five things the review caught, each measured.

The one-page form previewed an untouched required {{VDATE:}} as answered-empty.
submit() deliberately OMITS a required blank date so the sequential date prompt
still fires, while updatePreviews copied this.result wholesale - so the row
showed a name the run would never create. Both now go through one
collectAnswers(), which is where the rule already lived (it needs
dateParseErrors, which only the modal has).

parseVDateOptionsForPreview swallowed the error as well as the throw. Master's
body preview reported `Unknown date unit "we"` for a permanent typo; this
branch rendered a finished date and said nothing, for a format the run aborts
on - while {{DATE:...|startof:we}} in the same pass still reported it, so the
row contradicted itself. The options and the error are now separate channels:
the text stays stable while the unit is half-typed, the complaint goes on the
diagnostics channel the builder already holds back for 500ms.

The example date now applies |startof:/|endof: too. #1595 left snap out on the
grounds that snapping only the file-name row would split it from the body row;
both rows do it now, so that reason is gone - and the alternative was an
asymmetry ten lines wide, because the answered branch beside it snaps and
{{DATE:...|startof:month}} in the same pass always has. Only a token that asks
for a snap touches moment, so a dateless preview keeps its old path.

existsInVault's bare-name branch is no longer TFile-only. That tightening
reddened a working capture target: captureTargetResolution resolves a bare
extension-less name to a folder SCOPE when a folder of that name exists with no
note beside it, and the picker then writes to a file inside it. The probe
mirrors that rule instead of approximating it, and the trailing-slash case it
was contradicting stays fixed.

Three of the new tests were vacuous, proved by mutation:
- |startof:w does not throw (w is a real alias for week), so neither
  parseVDateOptionsForPreview test entered the catch. Now |startof:we, plus a
  case pinning that a valid short alias stays quiet.
- The capture guard's test passed with the guard deleted. Its placement is what
  matters, so it now asserts the heading picker never opened - that fails when
  the guard moves to the sink.
- The qa-hidden assertion only checked the true half, which the constructor
  already satisfies.
Adds the missing coverage the review named: the body preview's {{MVALUE}} pass
(deleting it left the suite green), and the first-pass ordering in the one-page
modal.

* test(preview): anchor the seeded VDATE instant to local time

The two "renders an ANSWERED date" cases seeded @Date:2026-08-15T00:00:00.000Z
and asserted "2026-08-15". The stored value is an INSTANT and moment formats it
in local time, so anywhere west of UTC it renders as the 14th - green in CET,
red on the UTC runners. Built from a local Date instead, which round-trips in
every zone (verified UTC-8 through UTC+14). The sibling illegal-chars test file
already carried this warning in its header.

* fix(preview): treat an inline-script folder as unknown, and pin two guards

Codex spotted a real hole in likelyTargetFolderPath: it refuses a configured
folder containing `{{`, but an inline `js quickadd` fence contains none, and
the folder validator lets backticks through (INVALID_FOLDER_CHARS_REGEX is
\ / : * ? " < > |). formatFolderPath EXECUTES that fence - it is format()'s
first pass - so the previews would have spliced the raw script source into
{{FOLDER}}, which is exactly what an inert preview must never do (#1558), and
its punctuation would then have been read as illegal characters. It falls back
to the neutral placeholder now, with the rule taken from INLINE_JAVASCRIPT_REGEX
rather than approximated.

Two coverage gaps CodeRabbit named, both of which were vacuous when written and
are mutation-verified now:

- The move offer's guard had no test at all. Driving it needs the INTERACTIVE
  path (a `templatePath` argument makes the run non-interactive and skips
  reconciliation entirely, so the first attempt passed with the guard deleted).
  Both halves are pinned: an impossible target asks nothing and renames nothing,
  a legal one still offers the move.
- The capture guard's test now asserts the capture's own {{VALUE}} prompt never
  opened, which is the whole point of #1591 - the user must not answer a prompt
  chain for a note that cannot be created.

* test(preview): pin that a slow preview pass is stale, never mixed

CodeRabbit asked whether the previous value's diagnostics can label the new one
while an async pass is in flight past the 500ms idle gate. They cannot, and the
regression test they asked for is what shows why: `preview` and `diagnostics`
commit together under `previewToken`, so the row always holds one pass's text
under that same pass's label and complaint. It can lag; it cannot mix. A stale
pass landing after a newer one is discarded, which the second case pins.

The comment records why the obvious fix is a downgrade: clearing `diagnostics`
on a value change would leave the previous, known-bad name on screen under a
plain "Preview:" label for the length of the pass - the exact assertion this row
exists to withdraw.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant