diff --git a/docs/src/content/docs/docs/FormatSyntax.md b/docs/src/content/docs/docs/FormatSyntax.md index 4ab19ba3..7986888f 100644 --- a/docs/src/content/docs/docs/FormatSyntax.md +++ b/docs/src/content/docs/docs/FormatSyntax.md @@ -401,8 +401,10 @@ from where the placeholder appears: - `|format:markdown` writes a vertical Markdown bullet list. Put the placeholder on its own line: `{{VALUE:Alpha,Beta|multi|format:markdown}}` becomes `- Alpha` followed by `- Beta`. -- `|format:inline` always writes the existing comma-separated text form: +- `|format:inline` always writes the compact comma-separated text form: `Alpha,Beta`. +- `|format:spaced` writes the same text with a space after each comma: + `{{VALUE:option a, option b|multi|format:spaced}}` becomes `option a, option b`. - `|format:auto` is the default and preserves the context-sensitive behavior described below. @@ -752,7 +754,8 @@ defaults as single-value FIELD prompts: `{{FIELD:topic|multi|folder:Projects|tag:active|default:Inbox}}`. FIELD multi-selects support the same explicit output formats as VALUE: -`|format:yaml`, `|format:markdown`, `|format:inline`, and `|format:auto`. +`|format:yaml`, `|format:markdown`, `|format:inline`, `|format:spaced`, +and `|format:auto`. For example, `topics: {{FIELD:topic|multi|format:yaml}}` always writes a native YAML list, including in template-backed captures. @@ -890,7 +893,7 @@ Good to know: - In a one-page input form, single and multi FILE pickers appear inline. Search matches the friendly title, file name, and full path. Selected files remain exact path-backed values internally, so commas in file names or labels are safe. FILE multi-selects support `|format:yaml`, `|format:markdown`, -`|format:inline`, and `|format:auto`. The format composes with `|link` and +`|format:inline`, `|format:spaced`, and `|format:auto`. The format composes with `|link` and `|path`, so `{{FILE:People|multi|link|format:yaml}}` writes a native YAML list of links without relying on the capture context. diff --git a/src/engine/CaptureChoiceEngine.ts b/src/engine/CaptureChoiceEngine.ts index 1e070f59..70566ebb 100644 --- a/src/engine/CaptureChoiceEngine.ts +++ b/src/engine/CaptureChoiceEngine.ts @@ -113,7 +113,7 @@ function isCaptureContentEmpty(content: string): boolean { const MULTI_SELECT_TOKEN_REGEX = /\{\{(?:VALUE|FILE|FIELD):[^}]*\|\s*multi\s*(?=[:}|]|$)[^}]*\}\}/gi; const EXPLICIT_MULTI_FORMAT_REGEX = - /\|\s*format\s*:\s*(?:inline|yaml|markdown)\s*(?=\||}})/i; + /\|\s*format\s*:\s*(?:inline|spaced|yaml|markdown)\s*(?=\||}})/i; function hasContextualMultiSelectToken(input: string): boolean { return Array.from(input.matchAll(MULTI_SELECT_TOKEN_REGEX)).some( @@ -509,7 +509,7 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine { hasContextualMultiSelectToken(this.choice?.format?.format ?? "") ) { log.logWarning( - "QuickAdd: {{VALUE:…|multi}}, {{FILE:…|multi}} and {{FIELD:…|multi}} in this capture write comma-separated strings by default. Add |format:yaml, |format:markdown or |format:inline to choose the output explicitly.", + "QuickAdd: {{VALUE:…|multi}}, {{FILE:…|multi}} and {{FIELD:…|multi}} in this capture write comma-separated strings by default. Add |format:yaml, |format:markdown, |format:inline or |format:spaced to choose the output explicitly.", ); } diff --git a/src/formatters/completeFormatter.test.ts b/src/formatters/completeFormatter.test.ts index 19a44384..9d2b457b 100644 --- a/src/formatters/completeFormatter.test.ts +++ b/src/formatters/completeFormatter.test.ts @@ -1819,6 +1819,7 @@ describe("CompleteFormatter - remote prompt provider routing", () => { ["yaml", '["a", "c"]'], ["markdown", "- a\n- c"], ["inline", "a,c"], + ["spaced", "a, c"], ] as const)("renders VALUE multi-selects with |format:%s", async (format, expected) => { const suggesterMulti = vi.fn( async (_display: string[], _actual: string[]) => ["a", "c"], @@ -1830,6 +1831,33 @@ describe("CompleteFormatter - remote prompt provider routing", () => { ).resolves.toBe(expected); }); + it("keeps format:inline compact when the option list has spaces", async () => { + const suggesterMulti = vi.fn( + async (_display: string[], _actual: string[]) => ["a", "b"], + ); + const f = providerFormatter({ suggesterMulti }); + + await expect( + f.formatFileContent("{{VALUE:a, b|multi|format:inline}}"), + ).resolves.toBe("a,b"); + }); + + it("joins format:spaced with a comma and a space, including spaced option names", async () => { + const suggesterMulti = vi.fn( + async (_display: string[], _actual: string[]) => [ + "option a", + "option b", + ], + ); + const f = providerFormatter({ suggesterMulti }); + + await expect( + f.formatFileContent( + "{{VALUE:option a, option b|multi|format:spaced}}", + ), + ).resolves.toBe("option a, option b"); + }); + it("routes anonymous {{VALUE|type:checkbox}} to the provider's suggester, not the Obsidian modal", async () => { mocks.genericSuggesterSuggest.mockResolvedValue("false"); // modal answer (should be unused) const suggester = vi.fn(async () => "true"); diff --git a/src/formatters/formatter-field-title-regression.test.ts b/src/formatters/formatter-field-title-regression.test.ts index 477cb24a..9971df06 100644 --- a/src/formatters/formatter-field-title-regression.test.ts +++ b/src/formatters/formatter-field-title-regression.test.ts @@ -231,6 +231,20 @@ describe("Formatter FIELD and TITLE namespace handling", () => { expect(formatter.getAndClearTemplatePropertyVars().size).toBe(0); }); + it("writes format:spaced as comma-space text inside a collection scope", async () => { + formatter.setMockFieldResponse("topic|multi|format:spaced", [ + "Alpha", + "Beta", + ]); + + await expect( + formatter.runFormatWithPropertyCollection( + "---\ntopics: {{FIELD:topic|multi|format:spaced}}\n---\n", + ), + ).resolves.toBe("---\ntopics: Alpha, Beta\n---\n"); + expect(formatter.getAndClearTemplatePropertyVars().size).toBe(0); + }); + it("collects FIELD multi arrays from YAML list item token positions", async () => { formatter.setMockFieldResponse("topic|multi", ["Alpha", "Beta"]); diff --git a/src/utils/FieldSuggestionParser.test.ts b/src/utils/FieldSuggestionParser.test.ts index e881b75a..1fe4a985 100644 --- a/src/utils/FieldSuggestionParser.test.ts +++ b/src/utils/FieldSuggestionParser.test.ts @@ -121,6 +121,14 @@ describe("FieldSuggestionParser", () => { expect(result.multiFormat).toBe("markdown"); }); + it("parses |format:spaced", () => { + const result = FieldSuggestionParser.parse( + "topics|multi|format:spaced", + ); + expect(result.multiSelect).toBe(true); + expect(result.multiFormat).toBe("spaced"); + }); + it("warns on |format: without |multi, even |format:auto", () => { const warnings: string[] = []; const result = FieldSuggestionParser.parse("topics|format:auto", { diff --git a/src/utils/fileSyntax.test.ts b/src/utils/fileSyntax.test.ts index 6a23d578..76ba8071 100644 --- a/src/utils/fileSyntax.test.ts +++ b/src/utils/fileSyntax.test.ts @@ -52,6 +52,12 @@ describe("parseFileToken", () => { expect(parsed?.multiFormat).toBe("yaml"); }); + it("parses |format:spaced", () => { + const parsed = parseFileToken("People|multi|format:spaced"); + expect(parsed?.multiSelect).toBe(true); + expect(parsed?.multiFormat).toBe("spaced"); + }); + it("warns on |format: without |multi, even |format:auto", () => { const warnings: string[] = []; const parsed = parseFileToken("People|format:auto", { diff --git a/src/utils/multiValueFormat.test.ts b/src/utils/multiValueFormat.test.ts index 3ab7da78..5bf44ab8 100644 --- a/src/utils/multiValueFormat.test.ts +++ b/src/utils/multiValueFormat.test.ts @@ -5,7 +5,7 @@ import { } from "./multiValueFormat"; describe("multi-select output formatting", () => { - it.each(["auto", "inline", "yaml", "markdown"] as const)( + it.each(["auto", "inline", "spaced", "yaml", "markdown"] as const)( "parses %s", (format) => { expect(parseMultiValueFormat(format, "token")).toBe(format); @@ -16,7 +16,7 @@ describe("multi-select output formatting", () => { const warn = vi.fn(); expect(parseMultiValueFormat("table", "token", warn)).toBeUndefined(); expect(warn).toHaveBeenCalledWith( - expect.stringContaining("auto, inline, yaml, markdown"), + expect.stringContaining("auto, inline, spaced, yaml, markdown"), ); }); @@ -31,7 +31,7 @@ describe("multi-select output formatting", () => { ).toBeUndefined(); }); - it("renders inline output with the legacy comma separator", () => { + it("renders inline output with the compact comma separator", () => { expect( renderExplicitMultiValue({ input: "value", @@ -42,6 +42,17 @@ describe("multi-select output formatting", () => { ).toBe("Alpha,Beta"); }); + it("renders spaced output with a comma and a space", () => { + expect( + renderExplicitMultiValue({ + input: "value", + matchStart: 0, + values: ["Alpha", "Beta"], + format: "spaced", + }), + ).toBe("Alpha, Beta"); + }); + it("renders a quoted YAML flow sequence that preserves string values", () => { expect( renderExplicitMultiValue({ diff --git a/src/utils/multiValueFormat.ts b/src/utils/multiValueFormat.ts index 5465deb3..20483a8f 100644 --- a/src/utils/multiValueFormat.ts +++ b/src/utils/multiValueFormat.ts @@ -1,11 +1,12 @@ import { quoteYamlDouble } from "./yamlScalarQuoting"; import type { WarnSink } from "./warnSink"; -export type MultiValueFormat = "auto" | "inline" | "yaml" | "markdown"; +export type MultiValueFormat = "auto" | "inline" | "spaced" | "yaml" | "markdown"; const MULTI_VALUE_FORMATS = new Set([ "auto", "inline", + "spaced", "yaml", "markdown", ]); @@ -21,7 +22,7 @@ export function parseMultiValueFormat( } warn?.( - `QuickAdd: Unsupported multi-select format "${raw}" in "${tokenDisplay}". Supported formats: auto, inline, yaml, markdown.`, + `QuickAdd: Unsupported multi-select format "${raw}" in "${tokenDisplay}". Supported formats: auto, inline, spaced, yaml, markdown.`, ); return undefined; } @@ -46,15 +47,25 @@ export function renderExplicitMultiValue(args: { format: MultiValueFormat; }): string | undefined { const { input, matchStart, values, format } = args; - if (format === "auto") return undefined; - const strings = values.map((value) => String(value)); - if (format === "inline") return strings.join(","); - if (format === "yaml") { - return `[${strings.map(quoteYamlDouble).join(", ")}]`; - } - if (strings.length === 0) return ""; - const indent = currentLineIndent(input, matchStart); - return strings.map(renderMarkdownItem).join(`\n${indent}`); + switch (format) { + case "auto": + return undefined; + case "inline": + return strings.join(","); + case "spaced": + return strings.join(", "); + case "yaml": + return `[${strings.map(quoteYamlDouble).join(", ")}]`; + case "markdown": { + if (strings.length === 0) return ""; + const indent = currentLineIndent(input, matchStart); + return strings.map(renderMarkdownItem).join(`\n${indent}`); + } + default: { + const _exhaustive: never = format; + return _exhaustive; + } + } } diff --git a/src/utils/valueSyntax.test.ts b/src/utils/valueSyntax.test.ts index 9e8bb989..70c37965 100644 --- a/src/utils/valueSyntax.test.ts +++ b/src/utils/valueSyntax.test.ts @@ -249,7 +249,7 @@ describe("parseValueToken", () => { expect(parsed?.multiEmit).toBe("linklist"); }); - it.each(["yaml", "markdown", "inline"] as const)( + it.each(["yaml", "markdown", "inline", "spaced"] as const)( "parses |format:%s separately from |multi item emission", (format) => { const parsed = parseValueToken(