fix: the file-name preview stops promising names Obsidian will refuse - #1595
Conversation
fileNameDisplayFormatter.test.ts defined its own TestFileNameDisplayFormatter -
a dozen hand-written regex replaces - and asserted that those regexes did what
they said. Eleven green tests that never imported FileNameDisplayFormatter and
could not fail for any change to it.
They had also drifted into asserting behaviour the plugin cannot produce:
{{TEMPLATE:daily-note}} was pinned to a fabricated
'[daily-note template content...]' placeholder that #1560 deleted and #1563
replaced with a real inert read.
Replaced with one case per token against the real class. Two of them pin
CURRENT behaviour that the mock claimed otherwise for, each with the issue it
is filed as: {{MATH:}} is left literal by the preview while the run prompts
for it (#1587), and {{title}} previews as a name although formatFileName
throws on it (#1588).
…1579) Both preview formatters built the placeholder out of the token's whole inner text, filters included, so the more precisely you filtered the less the preview looked like a value: {{FIELD:status|folder:Work}} previewed 'status|folder:Work_field_value'. The field is status; at run time the token resolves to one of that property's values. replaceFieldVarInString already parsed the specifier one line above the call, so the parsed field name is handed to suggestForField. The variable KEY stays keyed on the whole specifier - two {{FIELD:status}} tokens with different filters are different prompts. Also fixes the fallback beside it: getVariableValue was called with the bare specifier instead of the FIELD-prefixed key, so on the one path where a suggester resolves undefined (a remote prompt provider can) it both missed the value that had been stored and cross-read the {{VALUE}} namespace - a {{VALUE:status}} answer could be served to a {{FIELD:status}} token.
…1578) 'Bad: {{VALUE:title}}' previewed 'Bad: Example Title' in the ordinary 'Preview:' styling, and running the choice created nothing - the Notice was Obsidian's own: 'File name cannot contain any of the following characters: \\ / :'. #1563 made this row mirror the run's name normalizer; a character Obsidian refuses is the same class of truth and the last one missing. Measured against vault.create/createFolder on Obsidian 1.13.0 (macOS), one candidate character per name: ':' throws Obsidian's own guard for files AND folder segments; '* ? " < > | ^ [ ] #' and tab all create successfully, so copying the stricter set from TemplateEngine.validateFolderSegment would reject names Obsidian makes without complaint; '\\' and '/' are separators and QuickAdd creates the parent folder. The rule is ':' and only ':'. The check reads the FINISHED name rather than the format string. That is the only place all the sources meet: a colon the author typed, one {{TIME}} produced (it is HH:mm, and the token autocomplete offers it in this field), one a global snippet or an included template body carried in, and one left behind by a token that never matched - {{TEMPLATE:Naming}} without the extension is not a token, so the literal text goes to the vault, and a mask over {{...}}-shaped spans would be blind to exactly that, the most likely {{TEMPLATE:}} typo. Reading the finished name only works if the preview stops writing text that is not part of the name, so two stand-ins were corrected first: - the VDATE '(default: X)' / '(optional)' hints are gone from the file-name preview. The run splices in the formatted date and nothing else, so '2026-07-27 (default: tomorrow)' was already a name that could not exist - and the colon in it would have been blamed on the author. The hints stay on the body preview and in the run's own prompt placeholder. - an inline {{VALUE:a,b}} option list previews the option, without the body preview's ' (N options)' count. Three guards keep it from crying wolf: a pass that already reported an error says nothing more (all four [QuickAdd: ...] placeholders carry a colon and each already named its real problem); an unterminated '{{' means the author is mid-token with the format suggester open; and inline 'js quickadd' fences are excluded, since the run replaces a fence with what the script RETURNS while the preview must leave the source verbatim. Stand-ins that echo a token's own argument - a {{VALUE:}} prompt header, a macro name, a field name - degrade to a neutral placeholder when they would otherwise invent one of these characters. A stand-in is fiction either way; fiction that could not be a real file name is worse fiction. The run is deliberately unchanged. Failing fast at the create sinks would help - the folder is created and the template body formatted (prompts, macros, script fences) before Obsidian rejects the name - but a hard throw would also break appending to a colon-named file that already exists, which is legal on macOS/Linux. Filed separately. The formatter reports the colon for capture-target syntax like 'property:status=done' too, because it previews FILE NAMES and the same class previews a Template choice's file name, where that literal IS a path. CaptureTargetSetting renders no preview row for recognised picker syntax, and that gate is now pinned by a component test instead of a comment.
Three lenses attacked the shipped diff and a verifier reproduced each finding.
- The capture target's picker-syntax gate read the RAW field, but the run
resolves the target's format tokens BEFORE parsing it. So a target written as
{{GLOBAL_VAR:inbox}} expanding to 'property:type=draft' passed the gate, was
previewed as a path, and got a red 'cannot contain ":"' for a capture that
runs perfectly well. FormatPreviewField takes a hideWhen predicate over the
RESOLVED text, and CaptureTargetSetting asks the same parser again.
- The two message variants are one. Splitting on 'is the colon anywhere in the
format string' sounds right and is not: every argument-bearing token carries
a colon in its own syntax, so {{DATE:YYYY-MM-DD}} {{TIME}} - the shape where
the hint is needed most - was told the author could see it, and only a format
whose tokens take no argument at all reached the other variant. One sentence
names both sources.
- A half-typed 'js quickadd' fence now suppresses the diagnostic the way a
half-typed {{ does. Until the closing backticks exist there is no span to
strip, so a script holding {a: 1} or "HH:mm" turned the row red on every
pause. hasUnterminatedInlineScriptFence lives beside findInlineScriptSpans
and shares its opener rules.
- The {{MATH:}} pin from the #1580 commit was itself the fiction #1580 exists
to delete: {{MATH:...}} is not a token (MATH_VALUE_REGEX is /{{MVALUE}}/i),
so preview and run agree on it. The case now pins {{MVALUE}}, which is the
real divergence, and {{MATH:1+1}} is kept as plain text with the reason.
Issue #1587 was corrected to match.
- The picker-preview component test asserted an absence that could not fail
(diagnostics wait for 500ms of stillness). It now advances the clock and
carries a positive control, plus the token-expansion case above.
- Added the {{TIME}}, {{FILE:}}, {{FILENAMECURRENT}} and {{GLOBAL_VAR:}} cases
the rewritten test file's docstring claimed, and gave its formatter the
target folder every real caller sets.
Accepted and documented rather than fixed: a fence carried in by a
{{GLOBAL_VAR:}} snippet is stripped from the scan although the run would keep
it as literal text - mapping spans back through the passes is not worth it for
a script inside a global variable inside a file name, and the failure is
silence rather than a wrong accusation.
📝 WalkthroughWalkthroughThe preview pipeline now classifies illegal filename characters, handles incomplete syntax, uses safer stand-ins, scopes FIELD placeholders to parsed names, exercises the real filename formatter, and hides picker-target previews. ChangesPreview behavior
Estimated code review effort: 3 (Moderate) | ~25 minutes Possibly related issues
Possibly related PRs
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: |
7d79bd2
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://b396b78f.quickadd.pages.dev |
| Branch Preview URL: | https://chhoumann-1578-preview-follo.quickadd.pages.dev |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8b51133d9c
ℹ️ 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".
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/formatters/fileNameDisplayFormatter.test.ts (1)
27-81: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winExtract shared
FileNameDisplayFormattertest scaffolding into a common helper module. Five spec files independently redefine near-identicalAppstubs, amakeFormatter()factory that constructsFileNameDisplayFormatterand callssetTargetFolderPath("Folder/Name"), and apreview()wrapper returning{ text/out, diagnostics/problems }— one root cause (no shared test-support module for this formatter) repeated five times.
src/formatters/fileNameDisplayFormatter.test.ts#L27-L81: the most complete instance (fullmakeApp/plugin/makeFormatter/preview) — move this into a sharedfileNameDisplayFormatter.test-helpers.ts(or similar) and import it here.src/formatters/fileNameDisplayFormatter-1578-illegal-chars.test.ts#L61-L102: replace the localmakeApp/makeFormatter/previewwith the shared helper, parameterizing the small App-stub differences (e.g. active-file shape) if needed.src/formatters/fileNameDisplayFormatter-1563-normalize.test.ts#L22-L30: replace localmakeFormatter/previewwith the shared helper.src/formatters/fileNameDisplayFormatter-1563-template.test.ts#L88-L90: replace localmakeFormatterwith the shared helper.src/formatters/fileNameDisplayFormatter.audit-cleanup.test.ts#L44-L52: replace localmockApp/makeFormatterwith the shared helper.🤖 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/formatters/fileNameDisplayFormatter.test.ts` around lines 27 - 81, Extract the shared App stub, plugin fixture, makeFormatter, and preview scaffolding from fileNameDisplayFormatter.test.ts into a common fileNameDisplayFormatter test-helpers module, parameterizing minor App differences as needed. Update src/formatters/fileNameDisplayFormatter.test.ts#L27-L81, src/formatters/fileNameDisplayFormatter-1578-illegal-chars.test.ts#L61-L102, src/formatters/fileNameDisplayFormatter-1563-normalize.test.ts#L22-L30, src/formatters/fileNameDisplayFormatter-1563-template.test.ts#L88-L90, and src/formatters/fileNameDisplayFormatter.audit-cleanup.test.ts#L44-L52 to import and use the shared helpers, including replacing audit-cleanup’s mockApp/makeFormatter.
🤖 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/formatters/fileNameDisplayFormatter.test.ts`:
- Around line 27-81: Extract the shared App stub, plugin fixture, makeFormatter,
and preview scaffolding from fileNameDisplayFormatter.test.ts into a common
fileNameDisplayFormatter test-helpers module, parameterizing minor App
differences as needed. Update
src/formatters/fileNameDisplayFormatter.test.ts#L27-L81,
src/formatters/fileNameDisplayFormatter-1578-illegal-chars.test.ts#L61-L102,
src/formatters/fileNameDisplayFormatter-1563-normalize.test.ts#L22-L30,
src/formatters/fileNameDisplayFormatter-1563-template.test.ts#L88-L90, and
src/formatters/fileNameDisplayFormatter.audit-cleanup.test.ts#L44-L52 to import
and use the shared helpers, including replacing audit-cleanup’s
mockApp/makeFormatter.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 3c77bce6-ee4b-40f7-ba3f-9c122d33b37f
📒 Files selected for processing (14)
src/formatters/displayFormatters-1579-field-placeholder.test.tssrc/formatters/fileNameDisplayFormatter-1563-normalize.test.tssrc/formatters/fileNameDisplayFormatter-1563-template.test.tssrc/formatters/fileNameDisplayFormatter-1578-illegal-chars.test.tssrc/formatters/fileNameDisplayFormatter.audit-cleanup.test.tssrc/formatters/fileNameDisplayFormatter.test.tssrc/formatters/fileNameDisplayFormatter.tssrc/formatters/formatDisplayFormatter.tssrc/formatters/formatter.tssrc/formatters/helpers/previewHelpers.tssrc/gui/ChoiceBuilder/components/CaptureTargetSetting-1578-picker-preview.test.tssrc/gui/ChoiceBuilder/components/CaptureTargetSetting.sveltesrc/gui/ChoiceBuilder/components/FormatPreviewField.sveltesrc/utils/generatedFilePath.ts
- An existing file is never created, so Obsidian is never asked to accept its
name. ':' is legal on macOS/Linux at the filesystem level, so a note made
outside Obsidian really can carry one, and a capture pointed at it appends
(CaptureChoiceEngine takes the fileExists branch and never reaches
vault.create) - the diagnostic was marking a supported configuration broken.
The vault lookup is defensive because it runs outside format()'s try/catch.
- The capture target's hideWhen gate swallowed unrelated errors. A pass can
fail and still leave text that parses as picker syntax:
{{GLOBAL_VAR:inbox}}{{TEMPLATE:missing.md}} resolves to 'property:type=draft'
followed by a not-found placeholder, and the run aborts on the missing
template - hiding the row took the one message that explained it. Preview
diagnostics now carry an optional kind, and 'path' marks the problems that
say the RESULT is not a usable path (the normalizer's and this one). Those
are the only ones a host that knows the field may not be a path at all is
entitled to discard. #1594 wants the same split for the 'Unresolved:' label.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/formatters/fileNameDisplayFormatter-1563-normalize.test.ts (1)
92-118: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winRetain coverage for unrelated diagnostics in the special-form loop.
The loop now checks only preview output, so unexpected non-path formatter errors for other capture-target forms would pass silently. Keep the explicit
property:status=doneassertion, but also assert that each case has no diagnostics or onlykind: "path"diagnostics.As per coding guidelines, bug fixes must add or adjust unit regression tests.
🤖 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/formatters/fileNameDisplayFormatter-1563-normalize.test.ts` around lines 92 - 118, Update the special-form test loop around the preview assertions to validate diagnostics as well as output: for every capture-target case, require that problems are absent or that every diagnostic has kind "path". Retain the explicit property:status=done assertion unchanged, and add or adjust the unit regression coverage without changing formatter behavior.Source: Coding guidelines
🤖 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.
Outside diff comments:
In `@src/formatters/fileNameDisplayFormatter-1563-normalize.test.ts`:
- Around line 92-118: Update the special-form test loop around the preview
assertions to validate diagnostics as well as output: for every capture-target
case, require that problems are absent or that every diagnostic has kind "path".
Retain the explicit property:status=done assertion unchanged, and add or adjust
the unit regression coverage without changing formatter behavior.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 9742b01a-270c-467c-9f39-2b682301ecda
📒 Files selected for processing (7)
src/formatters/fileNameDisplayFormatter-1563-normalize.test.tssrc/formatters/fileNameDisplayFormatter-1563-template.test.tssrc/formatters/fileNameDisplayFormatter-1578-illegal-chars.test.tssrc/formatters/fileNameDisplayFormatter.tssrc/formatters/previewDiagnostics.tssrc/gui/ChoiceBuilder/components/CaptureTargetSetting-1578-picker-preview.test.tssrc/gui/ChoiceBuilder/components/FormatPreviewField.svelte
🚧 Files skipped from review as they are similar to previous changes (5)
- src/formatters/fileNameDisplayFormatter-1563-template.test.ts
- src/gui/ChoiceBuilder/components/FormatPreviewField.svelte
- src/gui/ChoiceBuilder/components/CaptureTargetSetting-1578-picker-preview.test.ts
- src/formatters/fileNameDisplayFormatter-1578-illegal-chars.test.ts
- src/formatters/fileNameDisplayFormatter.ts
…red 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.
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.
…red 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.
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.
…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 #1578, closes #1579, closes #1580.
The third round of the preview-truth cluster, after #1582 (#1563/#1564). One
sentence: the file-name preview row shows the name that will be created, and
says so when Obsidian will refuse it.
The problems
#1578 - the preview showed names Obsidian refuses. Set a Template choice's
"File name format" to
Bad: {{VALUE:title}}and the builder previewedBad: Example Titlein ordinary styling. Running the choice created nothing.#1579 - the
{{FIELD:}}placeholder repeated the filter syntax back at you.{{FIELD:status|folder:Work}}previewedstatus|folder:Work_field_value. Theplaceholder was built from the token's whole inner text, so the more precisely
you filtered, the less the preview looked like a value.
#1580 -
fileNameDisplayFormatter.test.tstested a mock of itself. It defineda
TestFileNameDisplayFormatterwith a hand-writtenformat()of a dozen regexreplaces and asserted that those regexes did what they said. Eleven green tests
that never imported the real class.
What ships
1. The test file is pinned against the real formatter (#1580)
Done first, because it protects the other two. One case per token against the
real class. It immediately caught two lies the mock had been hiding, filed as
#1587 (
{{MVALUE}}previews literally while the run opens the math modal)and #1588 (
{{title}}previews as a name althoughformatFileNamethrows onit), both pinned here as current behaviour with their issue number.
2.
{{FIELD:}}names the field (#1579)replaceFieldVarInStringalready parsed the specifier one line above the call,so the parsed name is handed to
suggestForField. The variable KEY stays keyedon the whole specifier - two
{{FIELD:status}}tokens with different filters aredifferent prompts, and a test pins that.
Also fixes the fallback beside it:
getVariableValuewas called with the barespecifier instead of the
FIELD:-prefixed key, so on the one path where asuggester resolves
undefined(a remote prompt provider can) it both missed thestored value and cross-read the
{{VALUE}}namespace - a{{VALUE:status}}answer could be served to a
{{FIELD:status}}token.Declined: showing a real vault value the way
{{FILE:}}does. FIELDprompts at run time, so a concrete value asserts a pick that has not happened,
it changes as the filter narrows the candidate set, and it costs a vault scan per
keystroke.
status_field_valueis what the issue says the preview means to say.3. An error diagnostic for a name Obsidian will refuse (#1578)
Same channel as the
./..diagnostic from #1563, so the row flips to"Unresolved:".
The character set is
:and only:, measured againstvault.create/vault.createFolderon Obsidian 1.13.0 (macOS), one candidate per name::\/*?"<>|^[]#, tabSo copying the stricter set from
TemplateEngine.validateFolderSegment(
\ / : * ? " < > |) would have rejected names Obsidian makes withoutcomplaint.
The check reads the finished name, not the format string. That is the only
place all the sources meet: a colon the author typed, one
{{TIME}}produced (itis
HH:mm, and QuickAdd's own autocomplete offers it in this field), one a{{GLOBAL_VAR:}}snippet or an included{{TEMPLATE:}}body carried in, and oneleft behind by a token that never matched -
{{TEMPLATE:Naming}}without theextension is not a token, so the literal text goes to the vault. An earlier draft
scanned the format string with
{{...}}-shaped spans masked; a mask is blind toexactly that last case, which is the most likely
{{TEMPLATE:}}typo.Reading the finished name only works if the preview stops writing text that is
not part of the name, so two stand-ins were corrected first:
(default: X)/(optional)hints are gone from the file-namepreview. The run splices in the formatted date and nothing else, so
2026-07-27 (default: tomorrow)was already a name that could not exist - andits colon would have been blamed on the author. The hints stay on the body
preview and in the run's own prompt placeholder.
{{VALUE:a,b}}option list previews the option, without the bodypreview's
(N options)count.Four guards keep it from crying wolf, each with a test:
[QuickAdd: ...]placeholders carry a colon and each already named its realproblem;
{{means the author is mid-token with the format suggesteropen;
```js quickaddfence means they are mid-script - ascript holding
{a: 1}or"HH:mm"would otherwise turn the row red on everypause;
{{VALUE:}}prompt header, amacro name, a field name) degrade to a neutral placeholder rather than
inventing a character the run never produces. A stand-in is fiction either
way; fiction that could not be a real file name is worse fiction.
Before / after
Bad: {{VALUE:title}}in a Template choice's "File name format":{{DATE:YYYY-MM-DD}} {{TIME}}- the author typed no colon at all, and{{TIME}}is offered by QuickAdd's own token autocomplete in this very field:
{{FIELD:status|folder:Work}}(#1579):Tradeoffs and things deliberately not done
The run is unchanged. The issue asks whether it should refuse earlier, and it
should:
createFileWithInputcreates the target folder andcreateFileWithTemplateformats the entire template body - real prompts, macros, and
js quickaddfenceswith their side effects - before Obsidian rejects the name. But a hard throw
would break a working setup, because
:is legal on macOS/Linux at thefilesystem level: a note created outside Obsidian can have one, and capturing to
it works today. The guard has to be "reject only if the file does not already
exist". Filed as #1591 with that design, and the rule lives in
src/utils/generatedFilePath.tsso the run can share it without drift.Capture targets.
FileNameDisplayFormatterpreviews FILE NAMES, so it reportsthe colon in
property:status=donetoo - correctly, because the same classpreviews a Template choice's file name, where that literal IS a path. Teaching a
file-name formatter capture semantics would be the wrong layer, so the SURFACE
gates it:
CaptureTargetSettingrenders no preview row for picker syntax. Thatgate had no test; it has one now, including the case an adversarial review found -
a target written as
{{GLOBAL_VAR:inbox}}that expands toproperty:type=draftpassed the raw-text gate and got a red error for a capture that runs fine.
FormatPreviewFieldnow takes ahideWhenpredicate over the resolved text.No Windows portability warning.
* ? " < > |fail on Windows at thefilesystem level but work everywhere else, so flagging them would fire on every
?in a title for the mac/linux majority - the per-keystroke noise #1558removed.
Platform.isWinwould make it truthful per platform; not attempted froman untestable platform.
"Unresolved:" is not the right label for a name that resolved and is merely
invalid. It is pre-existing (#1563's
./..errors have the same shape) and thefix is a copy decision across three preview surfaces, so it is filed as #1594
rather than folded in.
An existing file is exempt. A note created outside Obsidian on macOS/Linux
can carry a
:, and a capture pointed at it appends without ever callingvault.create. The diagnostic returns early when a file is already at that path(tolerant of the missing extension, since a file-name format produces the name
and the engine appends
.md).Known limitation, documented in the code: a fence carried in by a
{{GLOBAL_VAR:}}snippet is stripped from the scan although the run would keep itas literal text. Mapping spans back through the passes is not worth it for a
script inside a global variable inside a file name, and the failure is silence
rather than a wrong accusation.
Adjacent defects filed rather than folded in
#1587, #1588, #1589 (a
{{VDATE:}}with no format previews the raw token whilethe run defaults to
YYYY-MM-DD, orYYYY-MM-DD HH:mmunder|time- which isanother colon), #1590 (the one-page modal's file-name preview discards every
diagnostic and never sets the target folder), #1591, #1594.
Validation
Design reviewed by four adversarial lenses before any code was written; the
resolved-string approach here replaced my original literal-text design because
three of them independently showed it was blind to
{{TIME}}. The shipped diffthen went through a second adversarial pass with per-finding verification - the
{{GLOBAL_VAR:}}capture-target false positive, the mid-script false positive,the collapsed message variants and the wrong
{{MATH:}}pin all come from it.pnpm test4349 passed, 37 skippedpnpm lint,pnpm run check(0 errors),pnpm run buildcleanisolated Obsidian 1.13.0 vault via
pnpm run obsidian:e2eCaptureTargetSetting.svelte,the
hideWhenwiring, the FIELD stand-in and the{{RANDOM:}}pass each failthe tests that claim to cover them
7d79bd2, each with a test and a live check: the existing-file exemptionabove, and
hideWhenswallowing an unrelated error. The latter is why previewdiagnostics now carry an optional
kind, where"path"marks the problemsthat say the RESULT is not a usable path - the only ones a host that knows the
field may not be a path at all should discard. [BUG] The preview row says "Unresolved:" for a name that resolved perfectly well #1594 wants the same split.