Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/docs/Advanced/onePageInputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ This feature is currently in Beta.
- Format variables in filenames, templates, and capture content:
- `{{VALUE}}`, `{{VALUE:name}}`, `{{VDATE:name, YYYY-MM-DD}}`, `{{FIELD:name|...}}`
- Nested `{{TEMPLATE:path}}` are scanned recursively.
- `{{VALUE|type:multiline}}` and `{{VALUE:name|type:multiline}}` render as textareas in the one-page modal.
- Capture target file when capturing to a folder or tag.
- Script-declared inputs (from user scripts inside macros), if provided.

Expand Down
10 changes: 10 additions & 0 deletions docs/docs/FormatSyntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ title: Format syntax
| `{{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}}`). |
| `{{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). |
| `{{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}}`. |
| `{{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. |
| `{{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. |
| `{{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. |
| `{{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. |
Expand All @@ -26,3 +27,12 @@ title: Format syntax
| `{{CLIPBOARD}}` | The current clipboard content. Will be empty if clipboard access fails due to permissions or security restrictions. |
| `{{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`. |
| `{{TITLE}}` | The final rendered filename (without extension) of the note being created or captured to. |

### Mixed-mode example

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

```markdown
- {{VALUE:Title|label:Title}}
{{VALUE:Body|type:multiline|label:Body}}
```
4 changes: 3 additions & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ export const DATE_REGEX_FORMATTED = new RegExp(
);
export const TIME_REGEX = new RegExp(/{{TIME}}/i);
export const TIME_REGEX_FORMATTED = new RegExp(/{{TIME:([^}\n\r+]*)}}/i);
export const NAME_VALUE_REGEX = new RegExp(/{{NAME}}|{{VALUE}}/i);
export const NAME_VALUE_REGEX = new RegExp(
/{{(?:NAME|VALUE)(?!:)(?:\|[^\n\r}]*)?}}/i,
);
export const VARIABLE_REGEX = new RegExp(/{{VALUE:([^\n\r}]*)}}/i);
export const FIELD_VAR_REGEX = new RegExp(/{{FIELD:([^\n\r}]*)}}/i);
export const FIELD_VAR_REGEX_WITH_FILTERS = new RegExp(
Expand Down
34 changes: 21 additions & 13 deletions src/formatters/completeFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,20 +174,28 @@ export class CompleteFormatter extends Formatter {
}
try {
const linkSourcePath = this.getLinkSourcePath();
const promptFactory = new InputPrompt().factory(
this.valuePromptContext?.inputTypeOverride,
);
const defaultValue = this.valuePromptContext?.defaultValue;
const description = this.valuePromptContext?.description;
if (linkSourcePath) {
this.value = await new InputPrompt()
.factory()
.PromptWithContext(
this.app,
this.valueHeader ?? `Enter value`,
undefined,
undefined,
linkSourcePath
);
this.value = await promptFactory.PromptWithContext(
this.app,
this.valueHeader ?? `Enter value`,
undefined,
defaultValue,
linkSourcePath,
description,
);
} else {
this.value = await new InputPrompt()
.factory()
.Prompt(this.app, this.valueHeader ?? `Enter value`);
this.value = await promptFactory.Prompt(
this.app,
this.valueHeader ?? `Enter value`,
undefined,
defaultValue,
description,
);
}
} catch (error) {
if (isCancellationError(error)) {
Expand Down Expand Up @@ -217,7 +225,7 @@ export class CompleteFormatter extends Formatter {
}

// Use default prompt for other variables
return await new InputPrompt().factory().Prompt(
return await new InputPrompt().factory(context?.inputTypeOverride).Prompt(
this.app,
header ?? context?.label ?? "Enter value",
context?.placeholder ??
Expand Down
36 changes: 36 additions & 0 deletions src/formatters/formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@ import { TemplatePropertyCollector } from "../utils/TemplatePropertyCollector";
import { settingsStore } from "../settingsStore";
import { normalizeDateInput } from "../utils/dateAliases";
import {
parseAnonymousValueOptions,
parseValueToken,
resolveExistingVariableKey,
type ValueInputType,
} from "../utils/valueSyntax";
import { parseMacroToken } from "../utils/macroSyntax";

Expand All @@ -42,6 +44,7 @@ export interface PromptContext {
description?: string;
placeholder?: string;
variableKey?: string;
inputTypeOverride?: ValueInputType; // Undefined means use global input prompt setting.
}

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

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

this.valuePromptContext = this.getValuePromptContext(output);

// Preserve programmatic VALUE injection via reserved variable name `value`.
if (this.hasConcreteVariable("value")) {
const existingValue = this.variables.get("value");
Expand All @@ -168,6 +174,35 @@ export abstract class Formatter {
return output;
}

private getValuePromptContext(input: string): PromptContext | undefined {
const regex = new RegExp(NAME_VALUE_REGEX.source, "gi");
let match: RegExpExecArray | null;
let context: PromptContext | undefined;

while ((match = regex.exec(input)) !== null) {
const token = match[0];
const inner = token.slice(2, -2);
const optionsIndex = inner.indexOf("|");
if (optionsIndex === -1) continue;
const rawOptions = inner.slice(optionsIndex);

const parsed = parseAnonymousValueOptions(rawOptions);
if (!context) context = {};

if (!context.description && parsed.label) {
context.description = parsed.label;
}
if (!context.defaultValue && parsed.defaultValue) {
context.defaultValue = parsed.defaultValue;
}
if (parsed.inputTypeOverride === "multiline") {
context.inputTypeOverride = "multiline";
}
}

return context;
}

protected async replaceSelectedInString(input: string): Promise<string> {
let output: string = input;

Expand Down Expand Up @@ -308,6 +343,7 @@ export abstract class Formatter {
variableValue = await this.promptForVariable(variableName, {
defaultValue,
description: helperText,
inputTypeOverride: parsed.inputTypeOverride,
variableKey,
});
} else {
Expand Down
39 changes: 39 additions & 0 deletions src/gui/InputPrompt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("../main", () => ({
__esModule: true,
default: class QuickAddMock {
static instance = { settings: { inputPrompt: "single-line" } };
},
}));

import InputPrompt from "./InputPrompt";
import GenericInputPrompt from "./GenericInputPrompt/GenericInputPrompt";
import GenericWideInputPrompt from "./GenericWideInputPrompt/GenericWideInputPrompt";
import QuickAdd from "../main";

describe("InputPrompt factory", () => {
beforeEach(() => {
QuickAdd.instance = {
settings: { inputPrompt: "single-line" },
} as any;
});

it("prefers multiline override over global single-line", () => {
const prompt = new InputPrompt();
expect(prompt.factory("multiline")).toBe(GenericWideInputPrompt);
});

it("uses global multiline when no override provided", () => {
QuickAdd.instance = {
settings: { inputPrompt: "multi-line" },
} as any;
const prompt = new InputPrompt();
expect(prompt.factory()).toBe(GenericWideInputPrompt);
});

it("uses single-line when no override and global single-line", () => {
const prompt = new InputPrompt();
expect(prompt.factory()).toBe(GenericInputPrompt);
});
});
6 changes: 5 additions & 1 deletion src/gui/InputPrompt.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import GenericWideInputPrompt from "./GenericWideInputPrompt/GenericWideInputPrompt";
import GenericInputPrompt from "./GenericInputPrompt/GenericInputPrompt";
import QuickAdd from "../main";
import type { ValueInputType } from "../utils/valueSyntax";

export default class InputPrompt {
public factory() {
public factory(inputTypeOverride?: ValueInputType) {
if (inputTypeOverride === "multiline") {
return GenericWideInputPrompt;
}
if (QuickAdd.instance.settings.inputPrompt === "multi-line") {
return GenericWideInputPrompt;
} else {
Expand Down
15 changes: 14 additions & 1 deletion src/preflight/OnePageInputModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,11 +404,24 @@ export class OnePageInputModal extends Modal {

private submit() {
const out: Record<string, string> = {};
this.result.forEach((v, k) => (out[k] = v));
const requirementsById = new Map(
this.requirements.map((req) => [req.id, req]),
);
this.result.forEach((v, k) => {
const requirement = requirementsById.get(k);
out[k] =
requirement?.type === "textarea"
? this.escapeBackslashes(v)
: v;
});
this.close();
this.resolvePromise(out);
}

private escapeBackslashes(input: string): string {
return input.replace(/\\/g, "\\\\");
}

private cancel() {
this.close();
this.rejectPromise("cancelled");
Expand Down
40 changes: 39 additions & 1 deletion src/preflight/RequirementCollector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@ const makeApp = () => ({
workspace: { getActiveFile: () => null },
vault: { getAbstractFileByPath: () => null, cachedRead: async () => "" },
} as any);
const makePlugin = () => ({ } as any);
const makePlugin = (overrides: Record<string, unknown> = {}) =>
({
settings: {
inputPrompt: "single-line",
globalVariables: {},
...overrides,
},
} as any);

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

expect(rc.templatesToScan.size === 0 || rc.templatesToScan.has("Templates/Note")).toBe(true);
});

it("uses textarea for VALUE tokens with type:multiline", async () => {
const app = makeApp();
const plugin = makePlugin({ inputPrompt: "single-line" });
const rc = new RequirementCollector(app, plugin);
await rc.scanString("{{VALUE:Body|type:multiline}}" );

const requirement = rc.requirements.get("Body");
expect(requirement?.type).toBe("textarea");
});

it("uses textarea for unnamed VALUE with type:multiline", async () => {
const app = makeApp();
const plugin = makePlugin({ inputPrompt: "single-line" });
const rc = new RequirementCollector(app, plugin);
await rc.scanString("{{VALUE|type:multiline|label:Notes}}" );

const requirement = rc.requirements.get("value");
expect(requirement?.type).toBe("textarea");
expect(requirement?.description).toBe("Notes");
});

it("respects global multiline setting for named VALUE tokens", async () => {
const app = makeApp();
const plugin = makePlugin({ inputPrompt: "multi-line" });
const rc = new RequirementCollector(app, plugin);
await rc.scanString("{{VALUE:title}}" );

const requirement = rc.requirements.get("title");
expect(requirement?.type).toBe("textarea");
});
});
17 changes: 15 additions & 2 deletions src/preflight/RequirementCollector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,14 +156,19 @@ export class RequirementCollector extends Formatter {
const requirementId = variableKey;

if (!this.requirements.has(requirementId)) {
const baseInputType =
parsed.inputTypeOverride === "multiline" ||
this.plugin.settings.inputPrompt === "multi-line"
? "textarea"
: "text";
const req: FieldRequirement = {
id: requirementId,
label: displayLabel,
type: hasOptions
? allowCustomInput
? "suggester"
: "dropdown"
: "text",
: baseInputType,
description,
};
if (hasOptions) {
Expand Down Expand Up @@ -194,9 +199,12 @@ export class RequirementCollector extends Formatter {
id: key,
label: header || "Enter value",
type:
this.valuePromptContext?.inputTypeOverride === "multiline" ||
this.plugin.settings.inputPrompt === "multi-line"
? "textarea"
: "text",
description: this.valuePromptContext?.description,
defaultValue: this.valuePromptContext?.defaultValue,
source: "collected",
});
}
Expand Down Expand Up @@ -230,10 +238,15 @@ export class RequirementCollector extends Formatter {
if (!this.requirements.has(key)) {
// Detect simple comma-separated option lists
const hasOptions = variableName.includes(",");
const baseInputType =
context?.inputTypeOverride === "multiline" ||
this.plugin.settings.inputPrompt === "multi-line"
? "textarea"
: "text";
const req: FieldRequirement = {
id: key,
label: variableName,
type: hasOptions ? "dropdown" : "text",
type: hasOptions ? "dropdown" : baseInputType,
description: context?.description,
source: "collected",
};
Expand Down
Loading