fix: make the file-name preview tell the truth, and the FIELD filter warning readable - #1582
Conversation
…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.
📝 WalkthroughWalkthroughFilename 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. ChangesFilename preview behavior
FIELD filter diagnostics
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
Possibly related PRs
Suggested labels: Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
Deploying quickadd with
|
| Latest commit: |
caffb4b
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://2934b8c3.quickadd.pages.dev |
| Branch Preview URL: | https://chhoumann-1563-preview-truth.quickadd.pages.dev |
There was a problem hiding this comment.
💡 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".
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.
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/gui/ChoiceBuilder/components/FormatPreviewField.svelte (1)
135-139: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winClamped preview text relies solely on hover-only
titletooltips 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 nativetitleattribute, 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 thetitlewith a focusable/expandable element (e.g., a "show full value" toggle oraria-describedbyregion) so the full text is programmatically discoverable.src/preflight/OnePageInputModal.ts#L721-L730: apply the same accessible fallback to.qa-preview-valalongside the existingtitleattribute.🤖 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
📒 Files selected for processing (13)
src/formatters/fileNameDisplayFormatter-1563-normalize.test.tssrc/formatters/fileNameDisplayFormatter-1563-template.test.tssrc/formatters/fileNameDisplayFormatter.tssrc/formatters/formatDisplayFormatter.tssrc/gui/ChoiceBuilder/components/FormatPreviewField.sveltesrc/preflight/OnePageInputModal.tssrc/preflight/runOnePagePreflight.filenamePreview.test.tssrc/preflight/runOnePagePreflight.tssrc/styles.csssrc/utils/FieldSuggestionParser-1564-unknown-filter.test.tssrc/utils/FieldSuggestionParser.tssrc/utils/generatedFilePath.tssrc/utils/suggestSimilarKeys.ts
`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.
`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.
…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.
Closes #1563. Closes #1564.
Two formatter output surfaces that were telling users things that were not true.
#1563 - the file-name preview left
{{TEMPLATE:}}literalCompleteFormatter.formatFileNameresolves{{TEMPLATE:...}}:format()runsreplaceTemplateInString, and path prompt scope is deliberately propagated into the childengine. The preview never called it, so the builder said one thing and the run did another.
Measured in this worktree's isolated vault (Obsidian 1.13.0).
Naming.mdcontains{{DATE:YYYY-MM-DD}} {{VALUE:title}}:The one-page input form contradicted itself harder still: its preflight scan already walks
into the include for this same field (
collectChoiceRequirements, scopenoteTitle), soit 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 realCompleteFormatterand open blocking prompt modals on every keystroke - exactly the bug #1560fixed 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:
normalizeGeneratedFilePathcollapses a run of line breaks, with the spaces aroundit, to a single space - and every generated name goes through it (
TemplateChoiceEngine,TemplateInsertEngine,templateNoteDiscovery, and the capture target viacaptureTargetResolution). 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:
That also closed three older divergences for every token, not just includes: a trailing
.orspace is stripped as the run strips it, a backslash is a path separator as Obsidian's own
normalizePathmakes it, and a./..or empty segment now reports the error the run abortswith 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.
Three things this needed beyond the reader
{{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.
{{TEMPLATE:}}token re-arm the include loop with an unwound cycle set. It also caps thefan-out of a wide include tree on a field that resolves on every keystroke. There is a test
that hangs forever without it.
through the child engine's
formatFileContent. The file-name pass list would leave literalexactly 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-pageform'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).
#1564 - the 256-character "Unknown FIELD filter" warning
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->multicosts 1), then ashared prefix (
exclude-> the threeexclude-*), then two edits but only into a long key.That last bound is what stops
filterbeing answered withfolder, andcase-insensitivegets a sentence rather than a guess, because obeying "did you mean case-sensitive?" would
silently invert the request.
FieldSuggestionParser-1564-unknown-filter.test.tspins the fullanswer for a 21-probe table, including everything that must NOT be suggested.
Two supporting fixes in the same parser:
FIELD_FILTER_KEYSis 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.
{{FIELD:status|mutli}}quietlydowngraded a multi-select prompt to single-select with nothing anywhere saying why.
Tradeoffs and known divergences
vault,filter:,property:targets are classified before normalization at run time. They contain no controlcharacters or trailing dots, so the preview is unchanged for them.
{{title}}inside an included body stays literal. The run resolves it to the emptystring (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.
js quickaddfences and macros inside an included body stay literal. The runexecutes 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 isnow skipped rather than expanded, since the run consumes the fence before its template pass
(caught in review,
caffb4b2).Validation
tsc,eslintandsvelte-checkclean.run cross-checked against the preview for each case rather than the preview alone.
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 filterunknown, 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
Bad: {{VALUE:title}}); same family,but it affects every token, not includes.
{{FIELD:}}preview placeholder repeats the filter syntax back at you.fileNameDisplayFormatter.test.tstests a mock of itself.