Skip to content

Commit 98fa7db

Browse files
authored
feat: add per-token multiline VALUE inputs
- Parse VALUE type:multiline options and propagate per-token prompt overrides - Apply overrides to sequential prompts and one-page inputs - Preserve scripted VALUE injections and warn on unsupported types Closes #339
1 parent 36d43ba commit 98fa7db

File tree

12 files changed

+323
-21
lines changed

12 files changed

+323
-21
lines changed

docs/docs/Advanced/onePageInputs.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ This feature is currently in Beta.
1818
- Format variables in filenames, templates, and capture content:
1919
- `{{VALUE}}`, `{{VALUE:name}}`, `{{VDATE:name, YYYY-MM-DD}}`, `{{FIELD:name|...}}`
2020
- Nested `{{TEMPLATE:path}}` are scanned recursively.
21+
- `{{VALUE|type:multiline}}` and `{{VALUE:name|type:multiline}}` render as textareas in the one-page modal.
2122
- Capture target file when capturing to a folder or tag.
2223
- Script-declared inputs (from user scripts inside macros), if provided.
2324

docs/docs/FormatSyntax.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ title: Format syntax
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}}`). |
1414
| `{{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 options like `\|label:...`, use `\|default:<value>` instead of the shorthand (mixing option keys with a bare default is not supported). |
1515
| `{{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}}`. |
16+
| `{{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. |
1617
| `{{VALUE:<options>\|custom}}` | Allows you to type custom values in addition to selecting from the provided options. Example: `{{VALUE:Red,Green,Blue\|custom}}` will suggest Red, Green, and Blue, but also allows you to type any other value like "Purple". This is useful when you have common options but want flexibility for edge cases. **Note:** You cannot combine `\|custom` with a shorthand default value - use `\|default:` if you need both. |
1718
| `{{LINKCURRENT}}` | A link to the file from which the template or capture was triggered (`[[link]]` format). When the append-link setting is set to **Enabled (skip if no active file)**, this token resolves to an empty string instead of throwing an error if no note is focused. |
1819
| `{{FILENAMECURRENT}}` | The basename (without extension) of the file from which the template or capture was triggered. Honors the same **required/optional** behavior as `{{LINKCURRENT}}` - when optional and no active file exists, resolves to an empty string. |
@@ -26,3 +27,12 @@ title: Format syntax
2627
| `{{CLIPBOARD}}` | The current clipboard content. Will be empty if clipboard access fails due to permissions or security restrictions. |
2728
| `{{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`. |
2829
| `{{TITLE}}` | The final rendered filename (without extension) of the note being created or captured to. |
30+
31+
### Mixed-mode example
32+
33+
Use single-line for a title and multi-line for a body:
34+
35+
```markdown
36+
- {{VALUE:Title|label:Title}}
37+
{{VALUE:Body|type:multiline|label:Body}}
38+
```

src/constants.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,9 @@ export const DATE_REGEX_FORMATTED = new RegExp(
7777
);
7878
export const TIME_REGEX = new RegExp(/{{TIME}}/i);
7979
export const TIME_REGEX_FORMATTED = new RegExp(/{{TIME:([^}\n\r+]*)}}/i);
80-
export const NAME_VALUE_REGEX = new RegExp(/{{NAME}}|{{VALUE}}/i);
80+
export const NAME_VALUE_REGEX = new RegExp(
81+
/{{(?:NAME|VALUE)(?!:)(?:\|[^\n\r}]*)?}}/i,
82+
);
8183
export const VARIABLE_REGEX = new RegExp(/{{VALUE:([^\n\r}]*)}}/i);
8284
export const FIELD_VAR_REGEX = new RegExp(/{{FIELD:([^\n\r}]*)}}/i);
8385
export const FIELD_VAR_REGEX_WITH_FILTERS = new RegExp(

src/formatters/completeFormatter.ts

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -174,20 +174,28 @@ export class CompleteFormatter extends Formatter {
174174
}
175175
try {
176176
const linkSourcePath = this.getLinkSourcePath();
177+
const promptFactory = new InputPrompt().factory(
178+
this.valuePromptContext?.inputTypeOverride,
179+
);
180+
const defaultValue = this.valuePromptContext?.defaultValue;
181+
const description = this.valuePromptContext?.description;
177182
if (linkSourcePath) {
178-
this.value = await new InputPrompt()
179-
.factory()
180-
.PromptWithContext(
181-
this.app,
182-
this.valueHeader ?? `Enter value`,
183-
undefined,
184-
undefined,
185-
linkSourcePath
186-
);
183+
this.value = await promptFactory.PromptWithContext(
184+
this.app,
185+
this.valueHeader ?? `Enter value`,
186+
undefined,
187+
defaultValue,
188+
linkSourcePath,
189+
description,
190+
);
187191
} else {
188-
this.value = await new InputPrompt()
189-
.factory()
190-
.Prompt(this.app, this.valueHeader ?? `Enter value`);
192+
this.value = await promptFactory.Prompt(
193+
this.app,
194+
this.valueHeader ?? `Enter value`,
195+
undefined,
196+
defaultValue,
197+
description,
198+
);
191199
}
192200
} catch (error) {
193201
if (isCancellationError(error)) {
@@ -217,7 +225,7 @@ export class CompleteFormatter extends Formatter {
217225
}
218226

219227
// Use default prompt for other variables
220-
return await new InputPrompt().factory().Prompt(
228+
return await new InputPrompt().factory(context?.inputTypeOverride).Prompt(
221229
this.app,
222230
header ?? context?.label ?? "Enter value",
223231
context?.placeholder ??

src/formatters/formatter.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,10 @@ import { TemplatePropertyCollector } from "../utils/TemplatePropertyCollector";
2727
import { settingsStore } from "../settingsStore";
2828
import { normalizeDateInput } from "../utils/dateAliases";
2929
import {
30+
parseAnonymousValueOptions,
3031
parseValueToken,
3132
resolveExistingVariableKey,
33+
type ValueInputType,
3234
} from "../utils/valueSyntax";
3335
import { parseMacroToken } from "../utils/macroSyntax";
3436

@@ -42,6 +44,7 @@ export interface PromptContext {
4244
description?: string;
4345
placeholder?: string;
4446
variableKey?: string;
47+
inputTypeOverride?: ValueInputType; // Undefined means use global input prompt setting.
4548
}
4649

4750
export abstract class Formatter {
@@ -50,6 +53,7 @@ export abstract class Formatter {
5053
protected dateParser: IDateParser | undefined;
5154
private linkToCurrentFileBehavior: LinkToCurrentFileBehavior = "required";
5255
private static readonly FIELD_VARIABLE_PREFIX = "FIELD:";
56+
protected valuePromptContext?: PromptContext;
5357

5458
// Tracks variables collected for YAML property post-processing
5559
private readonly propertyCollector: TemplatePropertyCollector;
@@ -149,6 +153,8 @@ export abstract class Formatter {
149153
// Fast path: nothing to do.
150154
if (!NAME_VALUE_REGEX.test(output)) return output;
151155

156+
this.valuePromptContext = this.getValuePromptContext(output);
157+
152158
// Preserve programmatic VALUE injection via reserved variable name `value`.
153159
if (this.hasConcreteVariable("value")) {
154160
const existingValue = this.variables.get("value");
@@ -168,6 +174,35 @@ export abstract class Formatter {
168174
return output;
169175
}
170176

177+
private getValuePromptContext(input: string): PromptContext | undefined {
178+
const regex = new RegExp(NAME_VALUE_REGEX.source, "gi");
179+
let match: RegExpExecArray | null;
180+
let context: PromptContext | undefined;
181+
182+
while ((match = regex.exec(input)) !== null) {
183+
const token = match[0];
184+
const inner = token.slice(2, -2);
185+
const optionsIndex = inner.indexOf("|");
186+
if (optionsIndex === -1) continue;
187+
const rawOptions = inner.slice(optionsIndex);
188+
189+
const parsed = parseAnonymousValueOptions(rawOptions);
190+
if (!context) context = {};
191+
192+
if (!context.description && parsed.label) {
193+
context.description = parsed.label;
194+
}
195+
if (!context.defaultValue && parsed.defaultValue) {
196+
context.defaultValue = parsed.defaultValue;
197+
}
198+
if (parsed.inputTypeOverride === "multiline") {
199+
context.inputTypeOverride = "multiline";
200+
}
201+
}
202+
203+
return context;
204+
}
205+
171206
protected async replaceSelectedInString(input: string): Promise<string> {
172207
let output: string = input;
173208

@@ -308,6 +343,7 @@ export abstract class Formatter {
308343
variableValue = await this.promptForVariable(variableName, {
309344
defaultValue,
310345
description: helperText,
346+
inputTypeOverride: parsed.inputTypeOverride,
311347
variableKey,
312348
});
313349
} else {

src/gui/InputPrompt.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
vi.mock("../main", () => ({
4+
__esModule: true,
5+
default: class QuickAddMock {
6+
static instance = { settings: { inputPrompt: "single-line" } };
7+
},
8+
}));
9+
10+
import InputPrompt from "./InputPrompt";
11+
import GenericInputPrompt from "./GenericInputPrompt/GenericInputPrompt";
12+
import GenericWideInputPrompt from "./GenericWideInputPrompt/GenericWideInputPrompt";
13+
import QuickAdd from "../main";
14+
15+
describe("InputPrompt factory", () => {
16+
beforeEach(() => {
17+
QuickAdd.instance = {
18+
settings: { inputPrompt: "single-line" },
19+
} as any;
20+
});
21+
22+
it("prefers multiline override over global single-line", () => {
23+
const prompt = new InputPrompt();
24+
expect(prompt.factory("multiline")).toBe(GenericWideInputPrompt);
25+
});
26+
27+
it("uses global multiline when no override provided", () => {
28+
QuickAdd.instance = {
29+
settings: { inputPrompt: "multi-line" },
30+
} as any;
31+
const prompt = new InputPrompt();
32+
expect(prompt.factory()).toBe(GenericWideInputPrompt);
33+
});
34+
35+
it("uses single-line when no override and global single-line", () => {
36+
const prompt = new InputPrompt();
37+
expect(prompt.factory()).toBe(GenericInputPrompt);
38+
});
39+
});

src/gui/InputPrompt.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import GenericWideInputPrompt from "./GenericWideInputPrompt/GenericWideInputPrompt";
22
import GenericInputPrompt from "./GenericInputPrompt/GenericInputPrompt";
33
import QuickAdd from "../main";
4+
import type { ValueInputType } from "../utils/valueSyntax";
45

56
export default class InputPrompt {
6-
public factory() {
7+
public factory(inputTypeOverride?: ValueInputType) {
8+
if (inputTypeOverride === "multiline") {
9+
return GenericWideInputPrompt;
10+
}
711
if (QuickAdd.instance.settings.inputPrompt === "multi-line") {
812
return GenericWideInputPrompt;
913
} else {

src/preflight/OnePageInputModal.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -404,11 +404,24 @@ export class OnePageInputModal extends Modal {
404404

405405
private submit() {
406406
const out: Record<string, string> = {};
407-
this.result.forEach((v, k) => (out[k] = v));
407+
const requirementsById = new Map(
408+
this.requirements.map((req) => [req.id, req]),
409+
);
410+
this.result.forEach((v, k) => {
411+
const requirement = requirementsById.get(k);
412+
out[k] =
413+
requirement?.type === "textarea"
414+
? this.escapeBackslashes(v)
415+
: v;
416+
});
408417
this.close();
409418
this.resolvePromise(out);
410419
}
411420

421+
private escapeBackslashes(input: string): string {
422+
return input.replace(/\\/g, "\\\\");
423+
}
424+
412425
private cancel() {
413426
this.close();
414427
this.rejectPromise("cancelled");

src/preflight/RequirementCollector.test.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,14 @@ const makeApp = () => ({
77
workspace: { getActiveFile: () => null },
88
vault: { getAbstractFileByPath: () => null, cachedRead: async () => "" },
99
} as any);
10-
const makePlugin = () => ({ } as any);
10+
const makePlugin = (overrides: Record<string, unknown> = {}) =>
11+
({
12+
settings: {
13+
inputPrompt: "single-line",
14+
globalVariables: {},
15+
...overrides,
16+
},
17+
} as any);
1118

1219
describe("RequirementCollector", () => {
1320
it("collects VALUE with default and options", async () => {
@@ -66,4 +73,35 @@ describe("RequirementCollector", () => {
6673

6774
expect(rc.templatesToScan.size === 0 || rc.templatesToScan.has("Templates/Note")).toBe(true);
6875
});
76+
77+
it("uses textarea for VALUE tokens with type:multiline", async () => {
78+
const app = makeApp();
79+
const plugin = makePlugin({ inputPrompt: "single-line" });
80+
const rc = new RequirementCollector(app, plugin);
81+
await rc.scanString("{{VALUE:Body|type:multiline}}" );
82+
83+
const requirement = rc.requirements.get("Body");
84+
expect(requirement?.type).toBe("textarea");
85+
});
86+
87+
it("uses textarea for unnamed VALUE with type:multiline", async () => {
88+
const app = makeApp();
89+
const plugin = makePlugin({ inputPrompt: "single-line" });
90+
const rc = new RequirementCollector(app, plugin);
91+
await rc.scanString("{{VALUE|type:multiline|label:Notes}}" );
92+
93+
const requirement = rc.requirements.get("value");
94+
expect(requirement?.type).toBe("textarea");
95+
expect(requirement?.description).toBe("Notes");
96+
});
97+
98+
it("respects global multiline setting for named VALUE tokens", async () => {
99+
const app = makeApp();
100+
const plugin = makePlugin({ inputPrompt: "multi-line" });
101+
const rc = new RequirementCollector(app, plugin);
102+
await rc.scanString("{{VALUE:title}}" );
103+
104+
const requirement = rc.requirements.get("title");
105+
expect(requirement?.type).toBe("textarea");
106+
});
69107
});

src/preflight/RequirementCollector.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,14 +156,19 @@ export class RequirementCollector extends Formatter {
156156
const requirementId = variableKey;
157157

158158
if (!this.requirements.has(requirementId)) {
159+
const baseInputType =
160+
parsed.inputTypeOverride === "multiline" ||
161+
this.plugin.settings.inputPrompt === "multi-line"
162+
? "textarea"
163+
: "text";
159164
const req: FieldRequirement = {
160165
id: requirementId,
161166
label: displayLabel,
162167
type: hasOptions
163168
? allowCustomInput
164169
? "suggester"
165170
: "dropdown"
166-
: "text",
171+
: baseInputType,
167172
description,
168173
};
169174
if (hasOptions) {
@@ -194,9 +199,12 @@ export class RequirementCollector extends Formatter {
194199
id: key,
195200
label: header || "Enter value",
196201
type:
202+
this.valuePromptContext?.inputTypeOverride === "multiline" ||
197203
this.plugin.settings.inputPrompt === "multi-line"
198204
? "textarea"
199205
: "text",
206+
description: this.valuePromptContext?.description,
207+
defaultValue: this.valuePromptContext?.defaultValue,
200208
source: "collected",
201209
});
202210
}
@@ -230,10 +238,15 @@ export class RequirementCollector extends Formatter {
230238
if (!this.requirements.has(key)) {
231239
// Detect simple comma-separated option lists
232240
const hasOptions = variableName.includes(",");
241+
const baseInputType =
242+
context?.inputTypeOverride === "multiline" ||
243+
this.plugin.settings.inputPrompt === "multi-line"
244+
? "textarea"
245+
: "text";
233246
const req: FieldRequirement = {
234247
id: key,
235248
label: variableName,
236-
type: hasOptions ? "dropdown" : "text",
249+
type: hasOptions ? "dropdown" : baseInputType,
237250
description: context?.description,
238251
source: "collected",
239252
};

0 commit comments

Comments
 (0)