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
13 changes: 13 additions & 0 deletions docs/src/content/docs/docs/Advanced/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,19 @@ obsidian vault=dev quickadd:run-template \
- The picker (interactive command) only lists templates inside your configured template folder(s); `path=` here is explicit, so any vault file resolves.
- Like `quickadd:run`, name collisions on the target note still prompt (the file-exists choice is not a pre-collected input). Under `quickadd:interactive` that prompt is forwarded to you like any other.

### Save a clipboard image: `quickadd:save-clipboard-image` {#quickaddsave-clipboard-image}

Save a 1x1 PNG through the same path QuickAdd uses when you paste an image into a prompt or when `{{CLIPBOARD}}` falls back to an image. Useful for checking attachment naming without driving a modal.

```bash
obsidian vault=dev quickadd:save-clipboard-image \
sourcePath="Meetings/Meeting notes.md" \
nameAfterNoteTitle=true
```

- `sourcePath=` is the note the attachment belongs to (the capture destination). Empty uses vault-root attachment placement and the timestamp name even when title-naming is on.
- `nameAfterNoteTitle=` overrides the **Name pasted images after the note title** setting for this save. Omit it to use the setting.

## Pass variables to a choice {#passing-variables}

QuickAdd's CLI accepts variables three ways:
Expand Down
6 changes: 5 additions & 1 deletion docs/src/content/docs/docs/FormatSyntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,9 @@ screenshot or copied image: QuickAdd saves it using Obsidian's attachment
settings and inserts an embedded link at the cursor. You can mix typed text
and images, and paste more than one. Clipboard text wins over an image when
both are present (copying a file in a file manager usually pastes its path as
text). Prompts for file names, folders, capture targets, and
text). Turn on **Name pasted images after the note title** in QuickAdd
settings to name the file after the destination note when that path is known
(otherwise the file stays `Clipboard image YYYY-MM-DD HH.MM.SS`). Prompts for file names, folders, capture targets, and
insert-after/before targets never accept image paste, since an embed link
would break the path. Pasted attachments are ordinary vault files; cancelling
the prompt afterwards does not delete them.
Expand Down Expand Up @@ -905,6 +907,8 @@ In Capture content, if the clipboard has no text but holds a supported image,
QuickAdd saves the image using Obsidian's attachment settings and inserts an
embedded link. Text wins when both are present. You can also paste an image
straight into a [value prompt](#value) while typing - no placeholder needed.
The **Name pasted images after the note title** setting names those files
after the destination note when QuickAdd already knows that path.

### A template file: `{{TEMPLATE:<path>}}` {#template}

Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/docs/Settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ The choice picker is the list you see when you run **QuickAdd: Run**.
- **Use multi-line input prompt** - get a large text box for text prompts instead of a single line, so you can write several lines at once. Multi-line prompts submit with Ctrl/Cmd+Enter, and plain Enter adds a newline. See [Controlling Prompts](/docs/ControllingPrompts/#submit-keys).
- **Persist input prompt drafts** - don't lose what you typed if you close a prompt by accident. When on, a closed prompt keeps its draft and restores it when you reopen. Drafts last only for the current session.
- **Use editor selection as default Capture value** - let a Capture reuse text you already have highlighted. When on, Capture uses the current editor selection as `{{VALUE}}` and may skip the prompt entirely. When off, Capture always asks for `{{VALUE}}`. Individual Capture choices can override this.
- **Name pasted images after the note title** - name clipboard images after the destination note instead of `Clipboard image YYYY-MM-DD HH.MM.SS`. Applies when you paste an image into a prompt whose answer lands in note content, and when Capture's `{{CLIPBOARD}}` falls back to an image. If the destination is not known yet, QuickAdd keeps the timestamp name. Duplicate names follow Obsidian's attachment folder setting. Off by default.
- **One-page input for choices** - answer all of a choice's questions in one form up front, instead of one prompt after another. Works with Template and Capture choices, and with Macros whose scripts declare inputs. Template and Capture choices can [override this individually](/docs/Advanced/onePageInputs/#per-choice-override). See [One-page Inputs](/docs/Advanced/onePageInputs/) and [Controlling Prompts](/docs/ControllingPrompts/).
- **Date aliases** - set your own shortcodes for natural-language dates, so typing `tm` in a date prompt means `tomorrow`. Write one per line as `alias = phrase`, for example `tm = tomorrow`. **Reset to defaults** restores the built-in aliases.

Expand Down
1 change: 1 addition & 0 deletions src/cli/registerQuickAddCliHandlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ describe("registerQuickAddCliHandlers", () => {
"quickadd:check",
"quickadd:package-preview",
"quickadd:interactive",
"quickadd:save-clipboard-image",
]);
});

Expand Down
12 changes: 12 additions & 0 deletions src/cli/registerQuickAddCliHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ import type ITemplateChoice from "../types/choices/ITemplateChoice";
import type ICaptureChoice from "../types/choices/ICaptureChoice";
import type IMacroChoice from "../types/choices/IMacroChoice";
import { applyInvocationDate } from "../utils/resolveDateOrigin";
import {
SAVE_CLIPBOARD_IMAGE_COMMAND,
SAVE_CLIPBOARD_IMAGE_FLAGS,
saveClipboardImageHandler,
} from "./saveClipboardImageCli";
import {
analysePackagePreview,
readQuickAddPackage,
Expand Down Expand Up @@ -196,6 +201,7 @@ const CLI_COMMANDS = {
check: "quickadd:check",
preview: "quickadd:package-preview",
interactive: "quickadd:interactive",
saveClipboardImage: SAVE_CLIPBOARD_IMAGE_COMMAND,
} as const;

const SUPPORTED_LIST_TYPES = new Set(["template", "capture", "macro", "multi"]);
Expand Down Expand Up @@ -1005,6 +1011,12 @@ export function registerQuickAddCliHandlers(plugin: QuickAdd): boolean {
INTERACTIVE_FLAGS,
(params: CliData) => interactiveHandler(plugin, params),
);
register(
CLI_COMMANDS.saveClipboardImage,
"Save a 1x1 PNG as a vault attachment using QuickAdd clipboard-image naming",
SAVE_CLIPBOARD_IMAGE_FLAGS,
(params: CliData) => saveClipboardImageHandler(plugin, params),
);

log.logMessage("Registered QuickAdd CLI handlers.");
return true;
Expand Down
88 changes: 88 additions & 0 deletions src/cli/saveClipboardImageCli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, expect, it, vi } from "vitest";
import type { TFile } from "obsidian";
import type QuickAdd from "../main";
import {
SAVE_CLIPBOARD_IMAGE_COMMAND,
saveClipboardImageHandler,
} from "./saveClipboardImageCli";

vi.mock("../utils/clipboardImageAttachments", () => ({
saveClipboardImageToVault: vi.fn(),
}));

import { saveClipboardImageToVault } from "../utils/clipboardImageAttachments";

const saveMock = vi.mocked(saveClipboardImageToVault);

function pluginWithSetting(nameAfterNoteTitle: boolean): QuickAdd {
return {
app: {},
settings: { namePastedImagesAfterNoteTitle: nameAfterNoteTitle },
} as unknown as QuickAdd;
}

describe("saveClipboardImageHandler", () => {
it("saves a png named after the destination when the flag is true", async () => {
saveMock.mockResolvedValue({
path: "attachments/Meeting notes.png",
name: "Meeting notes.png",
} as TFile);

const payload = JSON.parse(
await saveClipboardImageHandler(pluginWithSetting(false), {
sourcePath: "Meetings/Meeting notes.md",
nameAfterNoteTitle: "true",
}),
);

expect(payload).toMatchObject({
ok: true,
command: SAVE_CLIPBOARD_IMAGE_COMMAND,
path: "attachments/Meeting notes.png",
name: "Meeting notes.png",
sourcePath: "Meetings/Meeting notes.md",
nameAfterNoteTitle: true,
});
expect(saveMock).toHaveBeenCalledWith(
expect.anything(),
expect.any(ArrayBuffer),
"image/png",
"Meetings/Meeting notes.md",
{ nameAfterNoteTitle: true },
);
});

it("omits the override when the flag is absent so the setting applies", async () => {
saveMock.mockResolvedValue({
path: "attachments/Clipboard image 2026-08-29 21.40.00.png",
name: "Clipboard image 2026-08-29 21.40.00.png",
} as TFile);

const payload = JSON.parse(
await saveClipboardImageHandler(pluginWithSetting(false), {
sourcePath: "Meetings/Meeting notes.md",
}),
);

expect(payload.ok).toBe(true);
expect(payload.nameAfterNoteTitle).toBe(false);
expect(saveMock).toHaveBeenCalledWith(
expect.anything(),
expect.any(ArrayBuffer),
"image/png",
"Meetings/Meeting notes.md",
undefined,
);
});

it("rejects an invalid nameAfterNoteTitle value", async () => {
const payload = JSON.parse(
await saveClipboardImageHandler(pluginWithSetting(false), {
nameAfterNoteTitle: "maybe",
}),
);

expect(payload.ok).toBe(false);
expect(payload.error).toMatch(/Invalid nameAfterNoteTitle/);
});
});
89 changes: 89 additions & 0 deletions src/cli/saveClipboardImageCli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { CliData, CliFlags } from "obsidian";
import type QuickAdd from "../main";
import { saveClipboardImageToVault } from "../utils/clipboardImageAttachments";

export const SAVE_CLIPBOARD_IMAGE_COMMAND = "quickadd:save-clipboard-image";

export const SAVE_CLIPBOARD_IMAGE_FLAGS: CliFlags = {
sourcePath: {
value: "<vault-path>",
description:
"Note path the image will live in (capture destination). Empty keeps a vault-root attachment.",
},
nameAfterNoteTitle: {
value: "<true|false>",
description:
"Override the Name pasted images after the note title setting for this save",
},
};

const ONE_PIXEL_PNG = Uint8Array.from(
atob(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
),
(char) => char.charCodeAt(0),
);

function parseOptionalBoolean(value: string | undefined): boolean | undefined {
if (value === undefined || value === "") return undefined;
const normalized = value.toLowerCase();
if (
normalized === "true" ||
normalized === "1" ||
normalized === "yes" ||
normalized === "on"
) {
return true;
}
if (
normalized === "false" ||
normalized === "0" ||
normalized === "no" ||
normalized === "off"
) {
return false;
}
throw new Error(
`Invalid nameAfterNoteTitle: ${value}. Use true or false.`,
);
}

export async function saveClipboardImageHandler(
plugin: QuickAdd,
params: CliData,
): Promise<string> {
try {
const sourcePath =
typeof params.sourcePath === "string" ? params.sourcePath : "";
const nameAfterNoteTitle = parseOptionalBoolean(
typeof params.nameAfterNoteTitle === "string"
? params.nameAfterNoteTitle
: undefined,
);
const file = await saveClipboardImageToVault(
plugin.app,
ONE_PIXEL_PNG.buffer,
"image/png",
sourcePath,
nameAfterNoteTitle === undefined
? undefined
: { nameAfterNoteTitle },
);
return JSON.stringify({
ok: true,
command: SAVE_CLIPBOARD_IMAGE_COMMAND,
path: file.path,
name: file.name,
sourcePath: sourcePath || "",
nameAfterNoteTitle:
nameAfterNoteTitle ??
plugin.settings.namePastedImagesAfterNoteTitle,
});
} catch (error) {
return JSON.stringify({
ok: false,
command: SAVE_CLIPBOARD_IMAGE_COMMAND,
error: error instanceof Error ? error.message : String(error),
});
}
}
21 changes: 21 additions & 0 deletions src/formatters/captureChoiceFormatter-clipboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ vi.mock("../logger/logManager", () => ({
}));

import { CaptureChoiceFormatter } from "./captureChoiceFormatter";
import { settingsStore } from "../settingsStore";

function createTFile(path: string): TFile {
const name = path.split("/").pop() ?? path;
Expand Down Expand Up @@ -240,6 +241,26 @@ describe("CaptureChoiceFormatter clipboard image support", () => {
);
});

it("asks for a destination-title attachment name when the setting is on", async () => {
settingsStore.setState({ namePastedImagesAfterNoteTitle: true });
try {
const { app, getAvailablePathForAttachment } = createMockApp();
const item = createClipboardItem("image/png", [1, 2, 3]);
setClipboard({ items: [item] });
const formatter = createFormatter(app);
formatter.setDestinationSourcePath("Notes/Clip.md");

await formatter.formatContentOnly("{{clipboard}}");

expect(getAvailablePathForAttachment).toHaveBeenCalledWith(
"Clip.png",
"Notes/Clip.md",
);
} finally {
settingsStore.setState({ namePastedImagesAfterNoteTitle: false });
}
});

it("inserts clipboard text literally when it contains the clipboard token", async () => {
const { app } = createMockApp();
setClipboard({ text: "{{clipboard}}" });
Expand Down
23 changes: 23 additions & 0 deletions src/gui/imagePasteHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ vi.mock("../logger/logManager", () => ({
}));

import { Notice } from "obsidian";
import { settingsStore } from "../settingsStore";

function makeApp() {
const created: string[] = [];
Expand Down Expand Up @@ -112,6 +113,28 @@ describe("attachImagePasteHandler", () => {
);
});

it("names a pasted image after the destination note when the setting is on", async () => {
settingsStore.setState({ namePastedImagesAfterNoteTitle: true });
try {
const { app, createBinary } = makeApp();
const input = makeInput();
const handle = attachImagePasteHandler(app, input, {
sourcePath: "Meetings/Meeting notes.md",
});

dispatchPaste(input, makeClipboardData([makeImageFile()]));
await flushSaves(handle);

expect(createBinary).toHaveBeenCalledWith(
"attachments/Meeting notes.png",
expect.any(ArrayBuffer),
);
expect(input.value).toBe("![[attachments/Meeting notes.png]]");
} finally {
settingsStore.setState({ namePastedImagesAfterNoteTitle: false });
}
});

it("fires an input event so component onChange observers update", async () => {
const { app } = makeApp();
const input = makeInput();
Expand Down
1 change: 1 addition & 0 deletions src/quickAddSettingsTab.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ describe("QuickAddSettingsTab declarative bridge", () => {
"inputPrompt",
"persistInputPromptDrafts",
"useSelectionAsCaptureValue",
"namePastedImagesAfterNoteTitle",
"onePageInputEnabled",
"enableTemplatePropertyTypes",
"announceUpdates",
Expand Down
5 changes: 5 additions & 0 deletions src/quickAddSettingsTab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,11 @@ export class QuickAddSettingsTab extends PluginSettingTab {
desc: "When enabled, Capture uses the current editor selection as {{VALUE}} and may skip the prompt. When disabled, Capture always prompts for {{VALUE}}.",
control: { type: "toggle", key: "useSelectionAsCaptureValue" },
},
{
name: "Name pasted images after the note title",
desc: "When on, clipboard images saved by QuickAdd (pasting into a prompt, or {{CLIPBOARD}} with an image and no text) are named after the destination note. When the destination is not yet known, QuickAdd keeps the timestamp name. Duplicate names are handled by Obsidian's attachment folder setting.",
control: { type: "toggle", key: "namePastedImagesAfterNoteTitle" },
},
{
name: "One-page input for choices",
// The trailing sentence used to read "See One-page Inputs in the
Expand Down
11 changes: 9 additions & 2 deletions src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,15 @@ export interface QuickAddSettings {
inputPrompt: "multi-line" | "single-line";
persistInputPromptDrafts: boolean;
/**
* When enabled, Capture uses the current editor selection as the default {{VALUE}}.
*/
* When enabled, Capture uses the current editor selection as the default {{VALUE}}.
*/
useSelectionAsCaptureValue: boolean;
/**
* Name clipboard images (prompt paste and {{CLIPBOARD}} image fallback)
* after the destination note when that path is known. Unknown destination
* keeps the timestamp name. Collisions use Obsidian's attachment-folder API.
*/
namePastedImagesAfterNoteTitle: boolean;
/**
* When enabled, typing in the choice picker also searches choices nested
* inside Multi choices and shows matches with their folder path.
Expand Down Expand Up @@ -111,6 +117,7 @@ export const DEFAULT_SETTINGS: QuickAddSettings = {
inputPrompt: "single-line",
persistInputPromptDrafts: true,
useSelectionAsCaptureValue: true,
namePastedImagesAfterNoteTitle: false,
searchNestedChoices: true,
templateFolderLauncherRow: "bottom",
devMode: false,
Expand Down
Loading
Loading