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
12 changes: 8 additions & 4 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ This extension is a thin front end over [QuickAdd](https://github.com/chhoumann/

Obsidian ships a command-line interface (Settings → General → Command line interface). QuickAdd registers handlers on it, so anything you can trigger in the plugin is reachable from a subprocess that returns JSON:

- `quickadd:list [type=...] [commands]` - the flattened choice tree: `id`, `name`, `type`, `path` (`Multi / child`), `command`, `runnable` (a Multi is a folder, not runnable).
- `quickadd:interactive id=<id> [vars=<json>]` - starts a choice and returns at once with the choice (`id`, `name`, `type`) and the address of a local prompt server (`host`, `port`, `sessionId`, `token`). QuickAdd then sends each prompt to that server instead of opening a modal.
- `quickadd:run choice=<name>|id=<id> [vars=<json>] [ui] [verify]` - runs a choice to completion. **Run in Obsidian** passes `ui` so QuickAdd prompts inside the app. Quick Capture, Capture Selection, and Capture Clipboard pass their text through `vars`. `verify` returns the created file path and an honest success or failure for Template and Capture choices.
- `quickadd:list [type=...] [commands]` - the flattened choice tree: `id`, `name`, `type`, `path` (`Multi / child`), `command`, `runnable` (a Multi is a folder, not runnable), and `currentNote` (`none`, `optional`, or `required`: whether the choice reads the current note).
- `quickadd:interactive id=<id> [vars=<json>] [current=<path>|none]` - starts a choice and returns at once with the choice (`id`, `name`, `type`) and the address of a local prompt server (`host`, `port`, `sessionId`, `token`). QuickAdd then sends each prompt to that server instead of opening a modal.
- `quickadd:run choice=<name>|id=<id> [vars=<json>] [current=<path>|none] [ui] [verify]` - runs a choice to completion. **Run in Obsidian** passes `ui` so QuickAdd prompts inside the app. Quick Capture, Capture Selection, and Capture Clipboard pass their text through `vars`. `verify` returns the created file path and an honest success or failure for Template and Capture choices.
- `quickadd:suggest kind=links|tags` - completion items for text fields. `links` returns one item per note, attachment, and alias, each with `text` (what goes inside `[[...]]`) and `path`, plus `alias` on alias items. `tags` returns each tag without its `#` and its `count`, most used first. The CLI does no filtering; Raycast's `List` filters as the user types.

The extension shells out with `execFile` (each argument is a separate argv entry, so no shell quoting is needed for values with spaces or newlines) and parses the JSON envelope.
Expand Down Expand Up @@ -43,11 +43,13 @@ Before it runs anything, `ensureVaultReady` checks that Obsidian serves the vaul
## Per-choice flow: one interactive run

1. `quickadd:list` fills the searchable list, grouped by Multi folder.
2. **Run** calls `quickadd:interactive` for the selected choice.
2. **Run** calls `quickadd:interactive` for the selected choice. When its `currentNote` is `optional` or `required`, `CurrentNotePicker` (`src/completion-pickers.tsx`) asks for the note first and the run gets `current=<path>`, or `current=none` from the **No current note** row that an optional choice offers.
3. The extension long-polls the prompt server's `/poll`. Each event is a `prompt`, `done`, `error`, or an `idle` keepalive. The list stays on screen until the first prompt arrives, so a choice without prompts finishes with just a toast. If nothing arrives within three seconds, the list hands its poll to the run's view.
4. Each prompt renders as a native control, and the answer goes back through `/reply`. QuickAdd collects a choice's declared inputs first, as one `form` prompt. Prompts that a macro script raises later arrive one at a time.
5. The `done` event names the file and its `effect`, so the finish message reads "Created <file>" or "Added to <file>" and offers **Open in Obsidian**. Without a created or changed file it reads "Ran <choice>".

Without `current=`, QuickAdd takes the current note from Obsidian's active tab, which from Raycast is whatever tab happens to be open, so every run sends `current=`: the pick, or `none` when the choice does not ask. A pinned Quicklink asks for the note the same way. `ensureVaultReady` returns the choices it listed, so the list and the Quicklink view decide from a fresh `currentNote`, never a cached one. A `current` in the launch context skips the picker, and the relaunch after opening a closed vault keeps it. **Run in Obsidian** passes no `current=`, because there the active tab is the user's. The capture commands cannot ask: a `required` choice fails with a message that points to **Run QuickAdd Choice**, and an `optional` one runs with `current=none`.

A pinned Quicklink opens the run's view at once instead of waiting on the list. A Quicklink pinned with an argument carries it as `value` in the launch context, and the run starts with `vars={"value": ...}`. Its link puts Raycast's `{argument | json-stringify}` placeholder in the URL-encoded context JSON; Raycast percent-encodes the quoted text, so the context still parses (`src/lib/quicklink.ts`). When the view has waited three seconds with no prompt, it says QuickAdd may be asking something inside Obsidian (a Templater prompt, for example) and offers **Open Obsidian** (⌘O).

**Cancel Run**, or leaving the run's view while the run is live, posts `/abort`. QuickAdd rejects the open prompt and every prompt the run raises later, so a run that is mid-work stops at its next prompt. The extension never aborts a run that has ended. `driveSession` in `src/lib/interactive.ts` holds the run's state (connecting, prompt, working, done, failed, cancelled) and owns these rules, so they are unit tested without Raycast.
Expand Down Expand Up @@ -81,4 +83,6 @@ The `verify` flag that **Run in Obsidian** and the capture commands pass needs *

`[[` and `#` completion needs **QuickAdd >= 2.31**, which added `quickadd:suggest`. Before it, the picker says which version it needs.

Asking for the current note needs **QuickAdd >= 2.32**, which added `current=` and `currentNote`. Older versions send no `currentNote`, so the extension asks for nothing and the run uses the active tab.

In the one-page form, note pickers start empty only with **QuickAdd >= 2.31**, which marks them with `picker: "file"`. Older versions send them as plain suggesters, so they keep the first note picked.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ Type `[[` in any text field and a searchable list of your notes and aliases open

Three commands send text to a capture choice you name in their preferences and never open a view. **Quick Capture** takes the text as an argument in Raycast's root search. **Capture Selection** takes the text selected in the frontmost app. **Capture Clipboard** takes the clipboard. Give each a hotkey and capturing is one keystroke and one line of typing.

### Choose the current note

A choice that links to the current note or reads from it asks which note that is before it runs, from a searchable list of your notes. When the choice can do without one, **No current note** comes first. The run never falls back to whatever tab is open in Obsidian. The capture commands cannot ask, so a capture choice that needs a current note runs only from **Run QuickAdd Choice**.

### Pin a choice, or run it in Obsidian

Every choice has two pin actions. **Pin as Quicklink** makes the choice searchable from Raycast's root, where you can give it a hotkey. **Pin as Quicklink with Argument** makes a Quicklink that takes text inline: type its name, press Tab, type the text, press Enter, and the text runs as the choice's `{{VALUE}}`. **Run in Obsidian** (⌘↵) runs the choice with QuickAdd's own modals when you want them.
Expand Down Expand Up @@ -78,6 +82,7 @@ npm run dev
| Run QuickAdd Choice, with a choice's inputs on one form | QuickAdd 2.17.2 or later |
| Cancel Run that stops the run, "Created" and "Added to" messages | QuickAdd 2.20 or later |
| `[[` and `#` completion, note pickers that start empty | QuickAdd 2.31 or later |
| Asking for the current note | QuickAdd 2.32 or later |

## Commands

Expand Down
114 changes: 114 additions & 0 deletions e2e-vault/.obsidian/plugins/quickadd/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,120 @@
"templater": {
"afterCapture": "none"
}
},
{
"id": "e2e-current",
"name": "Capture to the current note",
"type": "Capture",
"command": false,
"appendLink": false,
"copyLinkToClipboard": false,
"captureTo": "",
"captureToActiveFile": true,
"captureToCanvasNodeId": "",
"activeFileWritePosition": "bottom",
"createFileIfItDoesntExist": {
"enabled": true,
"createWithTemplate": false,
"template": ""
},
"format": {
"enabled": true,
"format": "- current: {{VALUE}}\n"
},
"insertAfter": {
"enabled": false,
"after": "",
"insertAtEnd": false,
"considerSubsections": false,
"createIfNotFound": false,
"createIfNotFoundLocation": "top",
"inline": false,
"replaceExisting": false,
"blankLineAfterMatchMode": "auto",
"promptHeading": false
},
"insertBefore": {
"enabled": false,
"before": "",
"createIfNotFound": false,
"createIfNotFoundLocation": "top"
},
"newLineCapture": {
"enabled": false,
"direction": "below"
},
"prepend": false,
"task": false,
"openFile": false,
"fileOpening": {
"location": "tab",
"direction": "vertical",
"mode": "default",
"focus": true
},
"templater": {
"afterCapture": "none"
}
},
{
"id": "e2e-link",
"name": "Capture with a link",
"type": "Capture",
"command": false,
"appendLink": {
"enabled": true,
"placement": "newLine",
"requireActiveFile": false
},
"copyLinkToClipboard": false,
"captureTo": "Output/Inbox.md",
"captureToActiveFile": false,
"captureToCanvasNodeId": "",
"activeFileWritePosition": "bottom",
"createFileIfItDoesntExist": {
"enabled": true,
"createWithTemplate": false,
"template": ""
},
"format": {
"enabled": true,
"format": "- linked: {{VALUE}} {{LINKCURRENT}}\n"
},
"insertAfter": {
"enabled": false,
"after": "",
"insertAtEnd": false,
"considerSubsections": false,
"createIfNotFound": false,
"createIfNotFoundLocation": "top",
"inline": false,
"replaceExisting": false,
"blankLineAfterMatchMode": "auto",
"promptHeading": false
},
"insertBefore": {
"enabled": false,
"before": "",
"createIfNotFound": false,
"createIfNotFoundLocation": "top"
},
"newLineCapture": {
"enabled": false,
"direction": "below"
},
"prepend": false,
"task": false,
"openFile": false,
"fileOpening": {
"location": "tab",
"direction": "vertical",
"mode": "default",
"focus": true
},
"templater": {
"afterCapture": "none"
}
}
],
"inputPrompt": "single-line",
Expand Down
95 changes: 89 additions & 6 deletions scripts/e2e-protocol.mts
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,13 @@ function reply(prompt: PromptSpec): unknown {
}
}

async function start(choiceId: string, vars?: object) {
async function start(choiceId: string, vars?: object, current?: string) {
const started = JSON.parse(
obsidian(
"quickadd:interactive",
`id=${choiceId}`,
...(vars ? [`vars=${JSON.stringify(vars)}`] : []),
...(current ? [`current=${current}`] : []),
),
);
if (!started.ok) throw new Error(started.error);
Expand All @@ -95,8 +96,8 @@ async function nextEvent(at: (path: string) => string): Promise<SessionEvent> {
}
}

async function run(choiceId: string, vars?: object) {
const at = await start(choiceId, vars);
async function run(choiceId: string, vars?: object, current?: string) {
const at = await start(choiceId, vars, current);
for (;;) {
const event = await nextEvent(at);
if (event.kind === "error") throw new Error(event.error);
Expand All @@ -117,6 +118,10 @@ function note(path: string): string {
return readFileSync(new URL(path, VAULT_DIR), "utf8");
}

function hasLine(path: string, line: string): boolean {
return note(path).split("\n").includes(line);
}

// What a Quicklink argument carries into the run as `value`.
const ARGUMENT = `idea & plan #tag [[Note]] "quoted" back\\slash æøå 💡`;

Expand Down Expand Up @@ -168,6 +173,9 @@ obsidian(
if (out) await app.vault.delete(out, true);
await app.vault.createFolder("Output");
await app.vault.create("Output/Picked.md", "# Picked\\n");
await app.vault.create("Output/Current.md", "# Current\\n");
const decoy = await app.vault.create("Output/Decoy.md", "# Decoy\\n");
await app.workspace.getLeaf().openFile(decoy);
})()`,
);
await sleep(1500);
Expand All @@ -191,7 +199,82 @@ for (const [id, path, expected, vars] of cases) {
console.log(` FAIL ${error instanceof Error ? error.message : error}`);
}
}
const aborts: Array<[name: string, check: () => Promise<void>]> = [
const checks: Array<[name: string, check: () => Promise<void>]> = [
[
"list which choices use the current note",
async () => {
const { choices } = JSON.parse(obsidian("quickadd:list")) as {
choices: Array<{ id: string; currentNote?: string }>;
};
const use = (id: string) => choices.find((c) => c.id === id)?.currentNote;
const got = [use("e2e-current"), use("e2e-link"), use("e2e-text")];
if (got.join() !== "required,optional,none") {
throw new Error(`currentNote is ${JSON.stringify(got)}`);
}
},
],
[
"capture into the named current note, not the open tab",
async () => {
await run("e2e-current", undefined, "Output/Current.md");
await sleep(300);
if (!hasLine("Output/Current.md", "- current: Text to capture answer")) {
throw new Error(`Output/Current.md: ${note("Output/Current.md")}`);
}
},
],
[
"refuse a capture to the current note with current=none",
async () => {
const before = note("Output/Current.md");
const failure = await run("e2e-current", undefined, "none").then(
() => "",
(error: Error) => error.message,
);
if (!failure.includes("no active file")) {
throw new Error(`expected the no-active-file error, got: ${failure}`);
}
if (note("Output/Current.md") !== before) {
throw new Error("Output/Current.md changed");
}
},
],
[
"link the named current note",
async () => {
await run("e2e-link", undefined, "Output/Current.md");
await sleep(300);
if (
!hasLine("Output/Inbox.md", "- linked: Enter value answer [[Current]]")
) {
throw new Error(`Output/Inbox.md: ${note("Output/Inbox.md")}`);
}
if (!note("Output/Current.md").includes("[[Inbox]]")) {
throw new Error(`no link in Output/Current.md`);
}
},
],
[
"leave the link empty with current=none",
async () => {
const before = note("Output/Current.md");
await run("e2e-link", undefined, "none");
await sleep(300);
if (!hasLine("Output/Inbox.md", "- linked: Enter value answer ")) {
throw new Error(`Output/Inbox.md: ${note("Output/Inbox.md")}`);
}
if (note("Output/Current.md") !== before) {
throw new Error("Output/Current.md changed");
}
},
],
[
"never touch the open tab",
async () => {
const decoy = note("Output/Decoy.md");
if (decoy !== "# Decoy\n") throw new Error(`Output/Decoy.md: ${decoy}`);
},
],
[
"abort while the run is mid-work",
async () => {
Expand Down Expand Up @@ -229,7 +312,7 @@ const aborts: Array<[name: string, check: () => Promise<void>]> = [
},
],
];
for (const [name, check] of aborts) {
for (const [name, check] of checks) {
console.log(name);
try {
await check();
Expand All @@ -240,6 +323,6 @@ for (const [name, check] of aborts) {
}
}

const total = cases.length + aborts.length;
const total = cases.length + checks.length;
console.log(failed ? `${failed} of ${total} failed` : `all ${total} passed`);
process.exit(failed ? 1 : 0);
Loading
Loading