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 @@ -32,6 +32,7 @@ This feature is currently in Beta.
## Skipping the modal
- If all required inputs already have values (e.g., prefilled by an earlier macro step), the modal will not open.
- Empty string is considered an intentional value and will not prompt again.
- For Capture choices, a non-empty editor selection will prefill `{{VALUE}}` during preflight when selection-as-value is enabled.

Note: For date fields with a default, leaving the input blank will apply the default automatically at submit time.

Expand Down
3 changes: 2 additions & 1 deletion docs/docs/Choices/CaptureChoice.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ If you have a tag called `#people`, and you type `#people` in the _Capture To_ f

- _Create file if it doesn't exist_ will do as the name implies - you can also create the file from a template, if you specify the template (the input box will appear below the setting).
- _Task_ will format your captured text as a task.
- _Use editor selection as default value_ controls whether the current editor selection is used as `{{VALUE}}`. Choose **Follow global setting**, **Use selection**, or **Ignore selection** (global default lives in Settings > Input). This does not affect `{{SELECTED}}`.
- _Write to bottom of file_ will put whatever you enter at the bottom of the file.
- _Append link_ will append a link to the file you have open in the file you're capturing to. You can choose between three modes:
- **Enabled (requires active file)** – keeps the legacy behavior and throws an error if no note is focused
Expand Down Expand Up @@ -119,7 +120,7 @@ Content
Capture format lets you specify the exact format that you want what you're capturing to be inserted as.
You can do practically anything here. Think of it as a mini template.

If you do not enable this, QuickAdd will default to `{{VALUE}}`, which will just insert whatever you enter in the prompt that appears when activating the Capture.
If you do not enable this, QuickAdd will default to `{{VALUE}}`, which will insert whatever you enter in the prompt, or (if selection-as-value is enabled) the current editor selection.

You can use [format syntax](/FormatSyntax.md) here, which allows you to use dynamic values in your capture format.

Expand Down
2 changes: 1 addition & 1 deletion docs/docs/FormatSyntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ title: Format syntax
| `{{DATE:<DATEFORMAT>}}` | Replace `<DATEFORMAT>` with a [Moment.js date format](https://momentjs.com/docs/#/displaying/format/). You could write `{{DATE<DATEFORMAT>+3}}` to offset the date with 3 days. |
| `{{VDATE:<variable name>, <date format>}}` | You'll get prompted to enter a date and it'll be parsed to the given date format. You could write 'today' or 'in two weeks' and it'll give you the date for that. Short aliases like `t` (today), `tm` (tomorrow), and `yd` (yesterday) are also supported and configurable in settings. Works like variables, so you can use the date in multiple places with different formats - enter once, format many times! Example: `{{VDATE:date,YYYY}}/{{VDATE:date,MM}}/{{VDATE:date,DD}}` |
| `{{VDATE:<variable name>, <date format>\|<default>}}` | Same as above, but with a default value. If you leave the prompt empty, the default value will be used instead. Example: `{{VDATE:date,YYYY-MM-DD\|today}}` will use "today" if no input is provided. Default values can be any natural language date like "tomorrow", "next monday", "+7 days", etc. Short aliases like `t`, `tm`, and `yd` work here too. **Note:** If your date format contains pipe characters (`|`), you'll need to escape them as `\|` or use square brackets like `[|]` to avoid conflicts with the default value separator. |
| `{{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. When using the QuickAdd API, this can be passed programmatically using the reserved variable name 'value'. |
| `{{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'. |
| `{{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. |
| `{{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). |
Expand Down
2 changes: 2 additions & 0 deletions src/engine/CaptureChoiceEngine.notice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ vi.mock("../quickAddSettingsTab", () => {
inputPrompt: "single-line",
devMode: false,
templateFolderPath: "",
useSelectionAsCaptureValue: true,
announceUpdates: "major",
version: "0.0.0",
globalVariables: {},
Expand Down Expand Up @@ -47,6 +48,7 @@ vi.mock("../formatters/captureChoiceFormatter", () => {
setTitle() {}
setDestinationFile() {}
setDestinationSourcePath() {}
setUseSelectionAsCaptureValue() {}
async formatContentOnly(content: string) {
return content;
}
Expand Down
171 changes: 171 additions & 0 deletions src/engine/CaptureChoiceEngine.selection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { App } from "obsidian";
import { CaptureChoiceEngine } from "./CaptureChoiceEngine";
import type ICaptureChoice from "../types/choices/ICaptureChoice";
import type { IChoiceExecutor } from "../IChoiceExecutor";

const { setUseSelectionAsCaptureValueMock } = vi.hoisted(() => ({
setUseSelectionAsCaptureValueMock: vi.fn(),
}));

vi.mock("../formatters/captureChoiceFormatter", () => ({
CaptureChoiceFormatter: class {
setLinkToCurrentFileBehavior() {}
setUseSelectionAsCaptureValue(value: boolean) {
setUseSelectionAsCaptureValueMock(value);
}
setTitle() {}
setDestinationFile() {}
setDestinationSourcePath() {}
async formatContentOnly(content: string) {
return content;
}
async formatContentWithFile() {
return "";
}
async formatFileName(name: string) {
return name;
}
getAndClearTemplatePropertyVars() {
return new Map();
}
},
setUseSelectionAsCaptureValueMock,
}));

vi.mock("../utilityObsidian", () => ({
appendToCurrentLine: vi.fn(),
getMarkdownFilesInFolder: vi.fn(() => []),
getMarkdownFilesWithTag: vi.fn(() => []),
insertFileLinkToActiveView: vi.fn(),
insertOnNewLineAbove: vi.fn(),
insertOnNewLineBelow: vi.fn(),
isFolder: vi.fn(() => false),
isTemplaterTriggerOnCreateEnabled: vi.fn(() => false),
jumpToNextTemplaterCursorIfPossible: vi.fn(),
openExistingFileTab: vi.fn(() => null),
openFile: vi.fn(),
overwriteTemplaterOnce: vi.fn(),
templaterParseTemplate: vi.fn(async (_app, content) => content),
waitForTemplaterTriggerOnCreateToComplete: vi.fn(),
}));

vi.mock("three-way-merge", () => ({
default: vi.fn(() => ({})),
__esModule: true,
}));

vi.mock("src/gui/InputSuggester/inputSuggester", () => ({
default: class {},
}));

vi.mock("obsidian-dataview", () => ({
getAPI: vi.fn(),
}));

vi.mock("../main", () => ({
default: class QuickAddMock {},
}));

const createApp = () =>
({
vault: {
adapter: {
exists: vi.fn(async () => false),
},
},
workspace: {
getActiveFile: vi.fn(() => null),
},
fileManager: {
getNewFileParent: vi.fn(() => ({ path: "" })),
},
} as unknown as App);

const createChoice = (overrides: Partial<ICaptureChoice> = {}): ICaptureChoice => ({
id: "capture-choice-id",
name: "Capture Choice",
type: "Capture",
command: false,
captureTo: "Inbox.md",
captureToActiveFile: false,
createFileIfItDoesntExist: {
enabled: false,
createWithTemplate: false,
template: "",
},
format: { enabled: false, format: "" },
prepend: false,
appendLink: false,
task: false,
insertAfter: {
enabled: false,
after: "",
insertAtEnd: false,
considerSubsections: false,
createIfNotFound: false,
createIfNotFoundLocation: "",
},
newLineCapture: {
enabled: false,
direction: "below",
},
openFile: false,
fileOpening: {
location: "tab",
direction: "vertical",
mode: "default",
focus: true,
},
...overrides,
});

const createExecutor = (): IChoiceExecutor => ({
execute: vi.fn(),
variables: new Map<string, unknown>(),
});

describe("CaptureChoiceEngine selection-as-value resolution", () => {
beforeEach(() => {
setUseSelectionAsCaptureValueMock.mockClear();
});

it("uses global setting when no override is set", async () => {
const engine = new CaptureChoiceEngine(
createApp(),
{ settings: { useSelectionAsCaptureValue: false } } as any,
createChoice(),
createExecutor(),
);

await engine.run();

expect(setUseSelectionAsCaptureValueMock).toHaveBeenCalledWith(false);
});

it("uses per-choice override when provided", async () => {
const engine = new CaptureChoiceEngine(
createApp(),
{ settings: { useSelectionAsCaptureValue: true } } as any,
createChoice({ useSelectionAsCaptureValue: false }),
createExecutor(),
);

await engine.run();

expect(setUseSelectionAsCaptureValueMock).toHaveBeenCalledWith(false);
});

it("allows per-choice override to enable selection", async () => {
const engine = new CaptureChoiceEngine(
createApp(),
{ settings: { useSelectionAsCaptureValue: false } } as any,
createChoice({ useSelectionAsCaptureValue: true }),
createExecutor(),
);

await engine.run();

expect(setUseSelectionAsCaptureValueMock).toHaveBeenCalledWith(true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ vi.mock("../quickAddSettingsTab", () => {
inputPrompt: "single-line",
devMode: false,
templateFolderPath: "",
useSelectionAsCaptureValue: true,
announceUpdates: "major",
version: "0.0.0",
globalVariables: {},
Expand Down
8 changes: 8 additions & 0 deletions src/engine/CaptureChoiceEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,14 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine {
? "optional"
: "required",
);
const selectionOverride = this.choice.useSelectionAsCaptureValue;
const globalSelectionAsValue =
this.plugin.settings.useSelectionAsCaptureValue ?? true;
const useSelectionAsCaptureValue =
typeof selectionOverride === "boolean"
? selectionOverride
: globalSelectionAsValue;
this.formatter.setUseSelectionAsCaptureValue(useSelectionAsCaptureValue);

const filePath = await this.getFormattedPathToCaptureTo(
this.choice.captureToActiveFile,
Expand Down
1 change: 1 addition & 0 deletions src/engine/MacroChoiceEngine.notice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ vi.mock("../quickAddSettingsTab", () => {
inputPrompt: "single-line",
devMode: false,
templateFolderPath: "",
useSelectionAsCaptureValue: true,
announceUpdates: "major",
version: "0.0.0",
globalVariables: {},
Expand Down
1 change: 1 addition & 0 deletions src/engine/TemplateChoiceEngine.notice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ vi.mock("../quickAddSettingsTab", () => {
inputPrompt: "single-line",
devMode: false,
templateFolderPath: "",
useSelectionAsCaptureValue: true,
announceUpdates: "major",
version: "0.0.0",
globalVariables: {},
Expand Down
99 changes: 99 additions & 0 deletions src/formatters/captureChoiceFormatter-selection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { App } from "obsidian";
import { CaptureChoiceFormatter } from "./captureChoiceFormatter";

const { promptMock } = vi.hoisted(() => ({
promptMock: vi.fn().mockResolvedValue("prompted"),
}));

vi.mock("../gui/InputPrompt", () => ({
__esModule: true,
default: class {
factory() {
return {
Prompt: promptMock,
PromptWithContext: promptMock,
} as any;
}
},
}));

vi.mock("../quickAddSettingsTab", () => ({
QuickAddSettingsTab: class {},
}));

vi.mock("../main", () => ({
__esModule: true,
default: class QuickAddMock {},
}));

vi.mock("obsidian-dataview", () => ({
__esModule: true,
getAPI: vi.fn().mockReturnValue(null),
}));

const createFormatter = (selection: string | null) => {
const app = {
workspace: {
getActiveViewOfType: vi.fn().mockReturnValue(
selection === null
? null
: {
editor: {
getSelection: () => selection,
},
},
),
getActiveFile: vi.fn().mockReturnValue(null),
},
} as unknown as App;

const plugin = {
settings: {
inputPrompt: "single-line",
enableTemplatePropertyTypes: false,
globalVariables: {},
useSelectionAsCaptureValue: true,
},
} as any;

return new CaptureChoiceFormatter(app, plugin);
};

describe("CaptureChoiceFormatter selection-as-value behavior", () => {
beforeEach(() => {
promptMock.mockClear();
});

it("uses selection for {{VALUE}} when enabled", async () => {
const formatter = createFormatter("Selected text");
formatter.setUseSelectionAsCaptureValue(true);

const result = await formatter.formatContentOnly("{{VALUE}}");

expect(result).toBe("Selected text");
expect(promptMock).not.toHaveBeenCalled();
});

it("prompts for {{VALUE}} when selection use is disabled", async () => {
const formatter = createFormatter("Selected text");
formatter.setUseSelectionAsCaptureValue(false);

const result = await formatter.formatContentOnly(
"Value: {{VALUE}} / Selected: {{SELECTED}}",
);

expect(result).toBe("Value: prompted / Selected: Selected text");
expect(promptMock).toHaveBeenCalledTimes(1);
});

it("treats whitespace-only selection as empty when enabled", async () => {
const formatter = createFormatter(" \n\t ");
formatter.setUseSelectionAsCaptureValue(true);

const result = await formatter.formatContentOnly("{{VALUE}}");

expect(result).toBe("prompted");
expect(promptMock).toHaveBeenCalledTimes(1);
});
});
14 changes: 14 additions & 0 deletions src/formatters/captureChoiceFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export class CaptureChoiceFormatter extends CompleteFormatter {
private file: TFile | null = null;
private fileContent = "";
private sourcePath: string | null = null;
private useSelectionAsCaptureValue = true;
/**
* Tracks whether the current formatter instance has already run Templater on the
* capture payload. This prevents the same content from being parsed twice in
Expand All @@ -36,6 +37,19 @@ export class CaptureChoiceFormatter extends CompleteFormatter {
this.file = null;
}

public setUseSelectionAsCaptureValue(value: boolean): void {
this.useSelectionAsCaptureValue = value;
}

protected shouldUseSelectionForValue(): boolean {
return this.useSelectionAsCaptureValue;
}

protected async getSelectedTextForValue(): Promise<string> {
const selectedText = await this.getSelectedText();
return selectedText.trim().length > 0 ? selectedText : "";
}

protected getLinkSourcePath(): string | null {
return this.sourcePath ?? this.file?.path ?? null;
}
Expand Down
Loading