Skip to content

Commit b8ec56c

Browse files
authored
feat(format): support mapped VALUE suggester display text (#1127)
* feat(format): support mapped VALUE suggester display text * docs(format): use neutral VALUE text mapping example * fix(value): reject text mapping in anonymous VALUE tokens
1 parent 0a1578e commit b8ec56c

13 files changed

+392
-16
lines changed

docs/docs/FormatSyntax.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ title: Format syntax
1111
| `{{VALUE}}` or `{{NAME}}` | Interchangeable. Represents the value given in an input prompt. If text is selected in the current editor, it will be used as the value. For Capture choices, selection-as-value can be disabled globally or per-capture. When using the QuickAdd API, this can be passed programmatically using the reserved variable name 'value'.<br/><br/>**Inline script note:** For `js quickadd` blocks, prefer the QuickAdd API (`this.quickAddApi.inputPrompt(...)`) and `this.variables` for transformation flows. Do not rely on `{{VALUE}}` inside JavaScript string literals. See [Inline scripts](./InlineScripts.md#execution-order-and-value).<br/><br/>**Macro note:** `{{VALUE}}` / `{{NAME}}` are scoped per template step, so each template in a macro prompts independently. Use `{{VALUE:sharedName}}` when you want one prompt reused across the macro. |
1212
| `{{VALUE:<variable name>}}` | You can now use variable names in values. They'll get saved and inserted just like values, but the difference is that you can have as many of them as you want. Use comma separation to get a suggester rather than a prompt.<br/><br/>If the same variable name appears in multiple macro steps, QuickAdd prompts once and reuses the value. |
1313
| `{{VALUE:<variable name>\|label:<helper text>}}` | Adds helper text to the prompt for a single-value input. The helper appears below the header and is useful for reminders or instructions. For multi-value lists, use the same syntax to label the suggester (e.g., `{{VALUE:Red,Green,Blue\|label:Pick a color}}`). |
14+
| `{{VALUE:<items>\|text:<display items>}}` | For option lists, decouples what is shown in the suggester from what is inserted. Example: `{{VALUE:🔼,⏫,🔽,⏬\|text:3-Normal,2-High,4-Low,1-Urgent}}` shows the numbered text, but inserts the matching symbol. `items` and `text` must have the same number of comma-separated entries, and each `text` entry must be unique. If you also use `\|custom`, typed custom text is inserted as-is. |
1415
| `{{VALUE:<variable name>\|<default>}}` | Same as above, but with a default value. For single-value prompts (e.g., `{{VALUE:name\|Anonymous}}`), the default is pre-populated in the input field - press Enter to accept or clear/edit it. For multi-value suggesters without `\|custom`, you must select one of the provided options (no default applies). If you combine keyed options like `\|label:`, `\|default:`, `\|type:`, or `\|case:`, shorthand defaults like `\|Anonymous` are ignored; use `\|default:Anonymous` instead. |
1516
| `{{VALUE:<variable name>\|default:<value>}}` | Option-form default value, required when combining with other options like `\|label:`. Example: `{{VALUE:title\|label:Snake case\|default:My_Title}}`. |
1617
| `{{VALUE\|type:multiline}}` / `{{VALUE:<variable>\|type:multiline}}` | Forces a multi-line input prompt/textarea for that VALUE token. Only supported for single-value prompts (no comma options / `\|custom`). Overrides the global "Use Multi-line Input Prompt" setting. If `\|type:` is present, shorthand defaults like `\|Some value` are ignored; use `\|default:` instead. |
@@ -29,6 +30,8 @@ title: Format syntax
2930
| `{{RANDOM:<length>}}` | Generates a random alphanumeric string of the specified length (1-100). Useful for creating unique identifiers, block references, or temporary codes. Example: `{{RANDOM:6}}` generates something like `3YusT5`. |
3031
| `{{TITLE}}` | The final rendered filename (without extension) of the note being created or captured to. |
3132

33+
`|text:` limitations (current): commas and pipes inside individual `items`/`text` entries are not supported.
34+
3235
### Mixed-mode example
3336

3437
Use single-line for a title and multi-line for a body:

src/constants.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export const VARIABLE_DEFAULT_OPTION_SYNTAX =
99
"{{value:<variable name>|default:<value>}}";
1010
export const VARIABLE_LABEL_SYNTAX =
1111
"{{value:<variable name>|label:<helper text>}}";
12+
export const VARIABLE_TEXT_SYNTAX =
13+
"{{value:<items>|text:<display items>}}";
1214
export const VALUE_CASE_SYNTAX = "{{value|case:kebab}}";
1315
export const VARIABLE_CASE_SYNTAX = "{{value:<variable name>|case:kebab}}";
1416
export const FIELD_VAR_SYNTAX = "{{field:<field name>}}";
@@ -35,6 +37,7 @@ export const FORMAT_SYNTAX: string[] = [
3537
VARIABLE_DEFAULT_SYNTAX,
3638
VARIABLE_DEFAULT_OPTION_SYNTAX,
3739
VARIABLE_LABEL_SYNTAX,
40+
VARIABLE_TEXT_SYNTAX,
3841
FIELD_VAR_SYNTAX,
3942
"{{field:<fieldname>|folder:<path>}}",
4043
"{{field:<fieldname>|tag:<tagname>}}",
@@ -64,6 +67,7 @@ export const FILE_NAME_FORMAT_SYNTAX: string[] = [
6467
VARIABLE_DEFAULT_SYNTAX,
6568
VARIABLE_DEFAULT_OPTION_SYNTAX,
6669
VARIABLE_LABEL_SYNTAX,
70+
VARIABLE_TEXT_SYNTAX,
6771
FIELD_VAR_SYNTAX,
6872
RANDOM_SYNTAX,
6973
];

src/formatters/completeFormatter.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -255,13 +255,18 @@ export class CompleteFormatter extends Formatter {
255255
protected async suggestForValue(
256256
suggestedValues: string[],
257257
allowCustomInput = false,
258-
context?: { placeholder?: string; variableKey?: string },
258+
context?: {
259+
placeholder?: string;
260+
variableKey?: string;
261+
displayValues?: string[];
262+
},
259263
) {
260264
try {
265+
const displayValues = context?.displayValues ?? suggestedValues;
261266
if (allowCustomInput) {
262267
return await InputSuggester.Suggest(
263268
this.app,
264-
suggestedValues,
269+
displayValues,
265270
suggestedValues,
266271
{
267272
...(context?.placeholder
@@ -272,7 +277,7 @@ export class CompleteFormatter extends Formatter {
272277
}
273278
return await GenericSuggester.Suggest(
274279
this.app,
275-
suggestedValues,
280+
displayValues,
276281
suggestedValues,
277282
context?.placeholder,
278283
);

src/formatters/formatDisplayFormatter.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,13 @@ export class FormatDisplayFormatter extends Formatter {
9494
protected suggestForValue(
9595
suggestedValues: string[],
9696
allowCustomInput = false,
97-
_context?: { placeholder?: string; variableKey?: string },
97+
context?: {
98+
placeholder?: string;
99+
variableKey?: string;
100+
displayValues?: string[];
101+
},
98102
) {
99-
return getSuggestionPreview(suggestedValues);
103+
return getSuggestionPreview(context?.displayValues ?? suggestedValues);
100104
}
101105

102106
protected getMacroValue(
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { beforeEach, describe, expect, it } from "vitest";
2+
import type { PromptContext } from "./formatter";
3+
import { Formatter } from "./formatter";
4+
5+
class TextMappingFormatter extends Formatter {
6+
private nextSuggestionResult = "";
7+
private hasExplicitSuggestionResult = false;
8+
private selectedDisplayValue?: string;
9+
public lastSuggestCall:
10+
| {
11+
suggestedValues: string[];
12+
allowCustomInput: boolean;
13+
displayValues?: string[];
14+
}
15+
| undefined;
16+
17+
constructor() {
18+
super();
19+
}
20+
21+
public setSelectedDisplayValue(value: string): void {
22+
this.selectedDisplayValue = value;
23+
}
24+
25+
public setNextSuggestionResult(value: string): void {
26+
this.nextSuggestionResult = value;
27+
this.hasExplicitSuggestionResult = true;
28+
}
29+
30+
public async testFormat(input: string): Promise<string> {
31+
return await this.format(input);
32+
}
33+
34+
protected async format(input: string): Promise<string> {
35+
let output = input;
36+
output = await this.replaceVariableInString(output);
37+
return output;
38+
}
39+
40+
protected promptForValue(): Promise<string> {
41+
return Promise.resolve("");
42+
}
43+
44+
protected getCurrentFileLink(): string | null {
45+
return null;
46+
}
47+
48+
protected getCurrentFileName(): string | null {
49+
return null;
50+
}
51+
52+
protected getVariableValue(variableName: string): string {
53+
const value = this.variables.get(variableName);
54+
return typeof value === "string" ? value : "";
55+
}
56+
57+
protected suggestForValue(
58+
suggestedValues: string[],
59+
allowCustomInput = false,
60+
context?: {
61+
placeholder?: string;
62+
variableKey?: string;
63+
displayValues?: string[];
64+
},
65+
): string {
66+
this.lastSuggestCall = {
67+
suggestedValues,
68+
allowCustomInput,
69+
displayValues: context?.displayValues,
70+
};
71+
72+
if (this.hasExplicitSuggestionResult) {
73+
return this.nextSuggestionResult;
74+
}
75+
76+
if (this.selectedDisplayValue && context?.displayValues) {
77+
const selectedIndex = context.displayValues.indexOf(
78+
this.selectedDisplayValue,
79+
);
80+
if (selectedIndex >= 0) {
81+
return suggestedValues[selectedIndex] ?? "";
82+
}
83+
}
84+
85+
return suggestedValues[0] ?? "";
86+
}
87+
88+
protected suggestForField(_variableName: string): Promise<string> {
89+
return Promise.resolve("");
90+
}
91+
92+
protected promptForMathValue(): Promise<string> {
93+
return Promise.resolve("");
94+
}
95+
96+
protected getMacroValue(_macroName: string): Promise<string> {
97+
return Promise.resolve("");
98+
}
99+
100+
protected promptForVariable(
101+
_variableName: string,
102+
_context?: PromptContext,
103+
): Promise<string> {
104+
return Promise.resolve("");
105+
}
106+
107+
protected getTemplateContent(_templatePath: string): Promise<string> {
108+
return Promise.resolve("");
109+
}
110+
111+
protected getSelectedText(): Promise<string> {
112+
return Promise.resolve("");
113+
}
114+
115+
protected getClipboardContent(): Promise<string> {
116+
return Promise.resolve("");
117+
}
118+
119+
protected isTemplatePropertyTypesEnabled(): boolean {
120+
return false;
121+
}
122+
}
123+
124+
describe("Formatter VALUE text mapping", () => {
125+
let formatter: TextMappingFormatter;
126+
127+
beforeEach(() => {
128+
formatter = new TextMappingFormatter();
129+
});
130+
131+
it("passes display mappings to suggesters and inserts mapped item values", async () => {
132+
formatter.setSelectedDisplayValue("High");
133+
const result = await formatter.testFormat(
134+
"{{VALUE:🔼,⏫|text:Normal,High}}",
135+
);
136+
137+
expect(result).toBe("⏫");
138+
expect(formatter.lastSuggestCall?.suggestedValues).toEqual(["🔼", "⏫"]);
139+
expect(formatter.lastSuggestCall?.displayValues).toEqual([
140+
"Normal",
141+
"High",
142+
]);
143+
});
144+
145+
it("applies default values against inserted item values", async () => {
146+
formatter.setNextSuggestionResult("");
147+
const result = await formatter.testFormat(
148+
"{{VALUE:🔼,⏫|text:Normal,High|default:⏫}}",
149+
);
150+
151+
expect(result).toBe("⏫");
152+
});
153+
154+
it("keeps custom typed values when custom input is enabled", async () => {
155+
formatter.setNextSuggestionResult("urgent!");
156+
const result = await formatter.testFormat(
157+
"{{VALUE:🔼,⏫|text:Normal,High|custom}}",
158+
);
159+
160+
expect(result).toBe("urgent!");
161+
expect(formatter.lastSuggestCall?.allowCustomInput).toBe(true);
162+
});
163+
164+
it("does not remap typed custom values that equal display labels", async () => {
165+
formatter.setNextSuggestionResult("High");
166+
const result = await formatter.testFormat(
167+
"{{VALUE:🔼,⏫|text:Normal,High|custom}}",
168+
);
169+
170+
expect(result).toBe("High");
171+
});
172+
173+
it("throws for duplicate display labels in text mappings", async () => {
174+
await expect(
175+
formatter.testFormat("{{VALUE:a,b|text:Same,Same}}"),
176+
).rejects.toThrow(/duplicate text entries/i);
177+
});
178+
});

src/formatters/formatter.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,7 @@ export abstract class Formatter {
331331
defaultValue,
332332
allowCustomInput,
333333
suggestedValues,
334+
displayValues,
334335
hasOptions,
335336
} = parsed;
336337

@@ -359,7 +360,11 @@ export abstract class Formatter {
359360
variableValue = await this.suggestForValue(
360361
suggestedValues,
361362
allowCustomInput,
362-
{ placeholder: suggesterPlaceholder, variableKey },
363+
{
364+
placeholder: suggesterPlaceholder,
365+
variableKey,
366+
displayValues,
367+
},
363368
);
364369
}
365370

@@ -500,7 +505,11 @@ export abstract class Formatter {
500505
protected abstract suggestForValue(
501506
suggestedValues: string[],
502507
allowCustomInput?: boolean,
503-
context?: { placeholder?: string; variableKey?: string },
508+
context?: {
509+
placeholder?: string;
510+
variableKey?: string;
511+
displayValues?: string[];
512+
},
504513
): Promise<string> | string;
505514

506515
protected abstract suggestForField(variableName: string): Promise<string>;

src/preflight/OnePageInputModal.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
} from "src/utils/dateAliases";
1818
import { settingsStore } from "src/settingsStore";
1919
import type { FieldRequirement } from "./RequirementCollector";
20+
import { mapMappedSuggesterValue } from "./suggesterValueMapping";
2021

2122
type PreviewComputer = (
2223
values: Record<string, string>,
@@ -147,8 +148,12 @@ export class OnePageInputModal extends Modal {
147148
if (req.description) setting.setDesc(req.description);
148149
const dropdown = new DropdownComponent(setting.controlEl);
149150
const options = req.options ?? [];
151+
const displayOptions = req.displayOptions ?? options;
150152
if (options.length > 0) {
151-
options.forEach((opt) => dropdown.addOption(opt, opt));
153+
options.forEach((opt, index) => {
154+
const display = displayOptions[index] ?? opt;
155+
dropdown.addOption(opt, display);
156+
});
152157
dropdown.setValue(starting || options[0] || "");
153158
dropdown.onChange((v) => setValue(req.id, v));
154159
} else {
@@ -346,21 +351,44 @@ export class OnePageInputModal extends Modal {
346351
this.decorateLabel(req),
347352
);
348353
if (req.description) setting.setDesc(req.description);
354+
const options = req.options ?? [];
355+
const displayOptions = req.displayOptions ?? options;
356+
const displayToValue = new Map<string, string>();
357+
const valueToDisplay = new Map<string, string>();
358+
options.forEach((value, index) => {
359+
const display = displayOptions[index] ?? value;
360+
displayToValue.set(display, value);
361+
if (!valueToDisplay.has(value)) {
362+
valueToDisplay.set(value, display);
363+
}
364+
});
365+
const startingDisplay = valueToDisplay.get(starting) ?? starting;
349366
const input = new TextComponent(setting.controlEl);
350367
input
351368
.setPlaceholder(req.placeholder ?? "Type to search...")
352-
.setValue(starting)
369+
.setValue(startingDisplay)
353370
.onChange((v) => setValue(req.id, v));
371+
input.inputEl.addEventListener("input", (event) => {
372+
const fromCompletion = Boolean((event as any).fromCompletion);
373+
const rawInput = input.inputEl.value;
374+
const storedValue = mapMappedSuggesterValue(
375+
rawInput,
376+
displayToValue,
377+
fromCompletion,
378+
);
379+
if (storedValue !== rawInput || fromCompletion) {
380+
setValue(req.id, storedValue);
381+
}
382+
});
354383
// Attach suggester if options are provided
355-
const options = req.options ?? [];
356-
if (options.length > 0) {
384+
if (displayOptions.length > 0) {
357385
try {
358386
const caseSensitive = req.suggesterConfig?.caseSensitive ?? false;
359387
const multiSelect = req.suggesterConfig?.multiSelect ?? false;
360388
new SuggesterInputSuggest(
361389
this.app,
362390
input.inputEl,
363-
options,
391+
displayOptions,
364392
caseSensitive,
365393
multiSelect,
366394
);

src/preflight/RequirementCollector.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,27 @@ describe("RequirementCollector", () => {
5353
expect(byId[variableKey].type).toBe("dropdown");
5454
});
5555

56+
it("collects VALUE text mappings for option lists", async () => {
57+
const app = makeApp();
58+
const plugin = makePlugin();
59+
const rc = new RequirementCollector(app, plugin);
60+
await rc.scanString("{{VALUE:🔼,⏫|text:Normal,High}}");
61+
62+
const requirement = rc.requirements.get("🔼,⏫");
63+
expect(requirement?.options).toEqual(["🔼", "⏫"]);
64+
expect(requirement?.displayOptions).toEqual(["Normal", "High"]);
65+
});
66+
67+
it("throws when VALUE text mappings have mismatched lengths", async () => {
68+
const app = makeApp();
69+
const plugin = makePlugin();
70+
const rc = new RequirementCollector(app, plugin);
71+
72+
await expect(
73+
rc.scanString("{{VALUE:a,b|text:Only One}}"),
74+
).rejects.toThrow(/same number of text entries and item entries/i);
75+
});
76+
5677
it("does not treat case option as a legacy default for named VALUE", async () => {
5778
const app = makeApp();
5879
const plugin = makePlugin();

0 commit comments

Comments
 (0)