diff --git a/.agents/capture/README.md b/.agents/capture/README.md index 0699d1472..4a4e5fe5e 100644 --- a/.agents/capture/README.md +++ b/.agents/capture/README.md @@ -15,7 +15,9 @@ a test suite or required workflow: copy a script, change it, run it. # A throwaway capture vault with the plugin build linked and the demo fixture. pnpm exec obsidian-e2e provision --root /tmp/qa-capture --vault Notes \ --data .agents/capture/demo-data.json -rm /tmp/qa-capture/Notes/.obsidian/core-plugins.json # [] disables the command palette +# provision writes core-plugins.json as [], which turns the command palette off; +# removing the file brings Obsidian's defaults back, palette included. +rm /tmp/qa-capture/Notes/.obsidian/core-plugins.json printf '# Inbox\n\n- Buy oat milk\n' > /tmp/qa-capture/Notes/Inbox.md # A dedicated instance (never the E2E test instance) on CDP port 9333, DPR 2. diff --git a/.agents/capture/ai-demo-data.json b/.agents/capture/ai-demo-data.json new file mode 100644 index 000000000..45db15bbd --- /dev/null +++ b/.agents/capture/ai-demo-data.json @@ -0,0 +1,49 @@ +{ + "disableOnlineFeatures": false, + "ai": { + "defaultModel": "demo", + "defaultSystemPrompt": "As an AI assistant within Obsidian, your primary goal is to help users manage their ideas and knowledge more effectively. Format your responses using Markdown syntax. Please use the [[Obsidian]] link format. You can write aliases for the links by writing [[Obsidian|the alias after the pipe symbol]]. To use mathematical notation, use LaTeX syntax. LaTeX syntax for larger equations should be on separate lines, surrounded with double dollar signs ($$). You can also inline math expressions by wrapping it in $ symbols. For example, use $$w_{ij}^{\\text{new}}:=w_{ij}^{\\text{current}}+\\eta\\cdot\\delta_j\\cdot x_{ij}$$ on a separate line, but you can write \"($\\eta$ = learning rate, $\\delta_j$ = error term, $x_{ij}$ = input)\" inline.", + "promptTemplatesFolderPath": "AI prompts", + "showAssistant": true, + "providers": [ + { + "id": "local-demo", + "name": "Local demo", + "endpoint": "http://127.0.0.1:18431/v1", + "kind": "openai", + "apiKey": "", + "models": [ + { + "name": "demo", + "maxTokens": 1050000, + "maxOutputTokens": 128000, + "supportsTemperature": false + } + ], + "autoSyncModels": false, + "modelSource": "modelsDev", + "apiKeyRef": "local-demo-api-key" + } + ], + "confirmToolCalls": "destructive" + }, + "actions": [], + "migrations": { + "useQuickAddTemplateFolder": true, + "incrementFileNameSettingMoveToDefaultBehavior": true, + "consolidateFileExistsBehavior": true, + "repairTemplateFileExistsBehavior": true, + "mutualExclusionInsertAfterAndWriteToBottomOfFile": true, + "setVersionAfterUpdateModalRelease": true, + "addDefaultAIProviders": true, + "removeMacroIndirection": true, + "migrateFileOpeningSettings": true, + "backfillFileOpeningDefaults": true, + "setProviderModelDiscoveryMode": true, + "migrateProviderApiKeysToSecretStorage": true, + "migrateToMultipleTemplateFolders": true, + "refreshStaleDefaultModelSeeds": true, + "pinAiModelRefs": true, + "migrateToV3Actions": true + } +} diff --git a/.agents/capture/ai-demo-stub.mjs b/.agents/capture/ai-demo-stub.mjs new file mode 100644 index 000000000..471b5c5b3 --- /dev/null +++ b/.agents/capture/ai-demo-stub.mjs @@ -0,0 +1,19 @@ +// A local OpenAI-compatible server with one canned reply, for the +// AI_Assistant_Macro.gif recording (see record-ai-summarize-macro.sh). +import http from "node:http"; +const reply = "The team will freeze scope on Friday, run a bug bash on Tuesday, and ship the release candidate to beta users on Thursday, with Priya on the launch checklist and Tom on the announcement post."; +http.createServer((req, res) => { + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => { + console.log(req.method, req.url); + res.setHeader("Content-Type", "application/json"); + if (req.url.endsWith("/models")) return res.end(JSON.stringify({ data: [{ id: "demo", object: "model" }] })); + const model = (() => { try { return JSON.parse(body).model; } catch { return "demo"; } })(); + setTimeout(() => res.end(JSON.stringify({ + id: "chatcmpl-demo", object: "chat.completion", created: Math.floor(Date.now() / 1000), model, + choices: [{ index: 0, message: { role: "assistant", content: reply }, finish_reason: "stop" }], + usage: { prompt_tokens: 120, completion_tokens: 30, total_tokens: 150 }, + })), 900); + }); +}).listen(18431, "127.0.0.1", () => console.log("stub on 18431")); diff --git a/.agents/capture/journal-data.json b/.agents/capture/journal-data.json new file mode 100644 index 000000000..779a9f4b1 --- /dev/null +++ b/.agents/capture/journal-data.json @@ -0,0 +1,186 @@ +{ + "actions": [ + { + "kind": "action", + "id": "06363c82-8a27-49b9-a6dd-a9ca296c5dbd", + "name": "Add to journal", + "icon": "pencil", + "steps": [ + { + "id": "06363c82-8a27-49b9-a6dd-a9ca296c5dbd", + "type": "addToNote", + "captureTo": "Journal/{{DATE}}.md", + "captureToActiveFile": false, + "captureToCanvasNodeId": "", + "position": "bottom", + "format": { + "enabled": true, + "format": "- {{DATE:HH:mm}} {{VALUE}}" + }, + "insertAfter": { + "after": "", + "insertAtEnd": true, + "considerSubsections": false, + "createIfNotFound": true, + "createIfNotFoundLocation": "top", + "inline": false, + "replaceExisting": false, + "blankLineAfterMatchMode": "auto", + "promptHeading": false + }, + "insertBefore": { + "before": "", + "createIfNotFound": false, + "createIfNotFoundLocation": "top" + }, + "createFileIfItDoesntExist": { + "enabled": true, + "createWithTemplate": false, + "template": "" + }, + "task": false + } + ], + "show": { + "command": false + } + }, + { + "kind": "action", + "id": "b65527c1-cf1e-4d91-ab15-04805cd8ea2b", + "name": "Task", + "icon": "check-square", + "steps": [ + { + "id": "b65527c1-cf1e-4d91-ab15-04805cd8ea2b", + "type": "addToNote", + "captureTo": "{{DAILY}}", + "captureToActiveFile": false, + "captureToCanvasNodeId": "", + "position": "after", + "format": { + "enabled": false, + "format": "" + }, + "insertAfter": { + "after": "## Tasks", + "insertAtEnd": true, + "considerSubsections": false, + "createIfNotFound": true, + "createIfNotFoundLocation": "bottom", + "inline": false, + "replaceExisting": false, + "blankLineAfterMatchMode": "auto", + "promptHeading": false + }, + "insertBefore": { + "before": "", + "createIfNotFound": false, + "createIfNotFoundLocation": "top" + }, + "createFileIfItDoesntExist": { + "enabled": true, + "createWithTemplate": false, + "template": "" + }, + "task": true + } + ], + "show": { + "command": false + } + }, + { + "kind": "action", + "id": "8e90f6ee-ed9a-401f-a217-daf0f293e926", + "name": "New note", + "icon": "file-plus", + "steps": [ + { + "id": "8e90f6ee-ed9a-401f-a217-daf0f293e926", + "type": "createNote", + "templatePath": "", + "fileNameFormat": { + "enabled": false, + "format": "" + }, + "location": { + "mode": "default", + "folders": [], + "includeSubfolders": false + }, + "fileExistsBehavior": { + "kind": "prompt" + }, + "discoverExistingNotesBeforeCreate": false + } + ], + "show": { + "command": false + } + }, + { + "kind": "action", + "id": "54659787-b136-46d2-962c-6cf5cbf0bdfc", + "name": "Log", + "icon": "clock", + "steps": [ + { + "id": "54659787-b136-46d2-962c-6cf5cbf0bdfc", + "type": "addToNote", + "captureTo": "{{DAILY}}", + "captureToActiveFile": false, + "captureToCanvasNodeId": "", + "position": "after", + "format": { + "enabled": true, + "format": "- {{TIME}} {{VALUE}}" + }, + "insertAfter": { + "after": "## Log", + "insertAtEnd": true, + "considerSubsections": false, + "createIfNotFound": true, + "createIfNotFoundLocation": "bottom", + "inline": false, + "replaceExisting": false, + "blankLineAfterMatchMode": "auto", + "promptHeading": false + }, + "insertBefore": { + "before": "", + "createIfNotFound": false, + "createIfNotFoundLocation": "top" + }, + "createFileIfItDoesntExist": { + "enabled": true, + "createWithTemplate": false, + "template": "" + }, + "task": false + } + ], + "show": { + "command": false + } + } + ], + "migrations": { + "useQuickAddTemplateFolder": true, + "incrementFileNameSettingMoveToDefaultBehavior": true, + "consolidateFileExistsBehavior": true, + "repairTemplateFileExistsBehavior": true, + "mutualExclusionInsertAfterAndWriteToBottomOfFile": true, + "setVersionAfterUpdateModalRelease": true, + "addDefaultAIProviders": true, + "removeMacroIndirection": true, + "migrateFileOpeningSettings": true, + "backfillFileOpeningDefaults": true, + "setProviderModelDiscoveryMode": true, + "migrateProviderApiKeysToSecretStorage": true, + "migrateToMultipleTemplateFolders": true, + "refreshStaleDefaultModelSeeds": true, + "pinAiModelRefs": true, + "migrateToV3Actions": true + } +} diff --git a/.agents/capture/record-add-to-journal.sh b/.agents/capture/record-add-to-journal.sh new file mode 100755 index 000000000..2837be318 --- /dev/null +++ b/.agents/capture/record-add-to-journal.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Drive the Getting Started "Add to journal" Capture choice the way a user would, +# for docs/src/content/docs/docs/Images/getting-started-add-to-journal.gif. +# journal-data.json holds the choice as the page's steps make it from the +# "Add to a note" preset, next to the Task, New note and Log presets. +# +# pnpm exec obsidian-e2e provision --root /tmp/qa-capture --vault Notes \ +# --data .agents/capture/journal-data.json +# rm /tmp/qa-capture/Notes/.obsidian/core-plugins.json # the defaults, command palette included +# mkdir -p /tmp/qa-capture/Notes/{Areas,Journal,Meetings,People,Projects,Templates} +# printf -- '- 08:10 Morning run along the harbour, 5 km\n- 08:45 Coffee and weekly planning\n' \ +# > "/tmp/qa-capture/Notes/Journal/$(date +%F).md" +# # launch and prepare as in README.md, open the file explorer, then: +# obsidian-e2e capture record journal.webm --cursor -- .agents/capture/record-add-to-journal.sh +set -euo pipefail + +: "${OBSIDIAN_E2E_CDP_PORT:?run under obsidian-e2e capture record}" +: "${OBSIDIAN_E2E_CAPTURE_VAULT_PATH:?export the capture launch --print-env output}" +export AGENT_BROWSER_SESSION="${AGENT_BROWSER_SESSION:-quickadd-capture}" +ab() { agent-browser --cdp "$OBSIDIAN_E2E_CDP_PORT" "$@" >/dev/null; } +cap() { obsidian-e2e capture "$@"; } +text="Standup moved to Wednesday" +journal="$OBSIDIAN_E2E_CAPTURE_VAULT_PATH/Journal/$(date +%F).md" +[[ -f "$journal" ]] || { echo "Seed $journal with a few entries first" >&2; exit 1; } +before=$(grep -c "" "$journal") + +ab eval "app.workspace.openLinkText('Journal/$(date +%F)', '', false).then(() => app.workspace.activeEditor?.editor?.blur?.())" +ab mouse move 1180 740 +sleep 1.5 +ab press Control+p +sleep 0.5 +cap type "QuickAdd: Run" --delay 60 --selector ".prompt-input" +sleep 0.5 +ab press Enter +sleep 0.9 +cap type "jour" --delay 90 --selector ".prompt-input" +sleep 0.5 +ab press Enter +sleep 0.8 +cap type "$text" --delay 45 +sleep 0.6 +ab press Enter + +# Verify the product behaviour, not just the pixels: the entry is a new last +# line of today's journal note, as the Getting Started page says. +entry="^- [0-9]{2}:[0-9]{2} $text\$" +for _ in $(seq 1 30); do + (( $(grep -c "" "$journal") > before )) && tail -n 1 "$journal" | grep -Eq "$entry" && break + sleep 0.2 +done +tail -n 1 "$journal" | grep -Eq "$entry" && (( $(grep -c "" "$journal") > before )) || { + echo "QuickAdd did not add a new last line to $journal" >&2 + exit 1 +} +sleep 2.5 diff --git a/.agents/capture/record-ai-summarize-macro.sh b/.agents/capture/record-ai-summarize-macro.sh new file mode 100755 index 000000000..13e12f4cc --- /dev/null +++ b/.agents/capture/record-ai-summarize-macro.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# Build the AIAssistant.md "Summarize selection" macro from the "Run a sequence +# of steps" preset, then run it on a selected paragraph, for +# docs/src/content/docs/docs/Images/AI_Assistant_Macro.gif. +# +# Vault: the AI Assistant set up as in AIAssistant.md's Setup (prompt template +# folder "AI prompts" holding Summarize.md with {{SELECTED}}, a default model, a +# linked key) and an open note whose first line is the paragraph to summarize. +# ai-demo-data.json does that against ai-demo-stub.mjs, a local +# OpenAI-compatible server with a canned reply, so no API key is needed: +# +# node .agents/capture/ai-demo-stub.mjs & +# pnpm exec obsidian-e2e provision --root /tmp/qa-capture --vault Notes \ +# --data .agents/capture/ai-demo-data.json +# rm /tmp/qa-capture/Notes/.obsidian/core-plugins.json # the defaults, command palette included +# # add AI prompts/Summarize.md and the note, launch and prepare as in +# # README.md, store any value as the "local-demo-api-key" secret, then: +# obsidian-e2e capture record ai.webm --cursor -- .agents/capture/record-ai-summarize-macro.sh +set -euo pipefail + +: "${OBSIDIAN_E2E_CDP_PORT:?run under obsidian-e2e capture record}" +: "${OBSIDIAN_E2E_CAPTURE_VAULT_PATH:?export the capture launch --print-env output}" +export AGENT_BROWSER_SESSION="${AGENT_BROWSER_SESSION:-quickadd-capture}" +ab() { agent-browser --cdp "$OBSIDIAN_E2E_CDP_PORT" "$@" >/dev/null; } +cap() { obsidian-e2e capture "$@"; } +# Move the pointer to the element a JS expression returns and click it. +click() { + local xy x y + xy=$(agent-browser --cdp "$OBSIDIAN_E2E_CDP_PORT" eval "(() => { const el = ($1); if (!el) return 'NONE'; el.scrollIntoView({ block: 'nearest' }); const r = el.getBoundingClientRect(); return Math.round(r.x + r.width / 2) + ' ' + Math.round(r.y + r.height / 2); })()" | tail -n 1 | tr -d '"') + [[ "$xy" != NONE ]] || { echo "Nothing to click: $1" >&2; exit 1; } + read -r x y <<<"$xy" + ab mouse move "$x" "$y" + sleep 0.35 + ab mouse down + ab mouse up +} +hover() { + local xy x y + xy=$(agent-browser --cdp "$OBSIDIAN_E2E_CDP_PORT" eval "(() => { const r = ($1).getBoundingClientRect(); return Math.round(r.x + r.width / 2) + ' ' + Math.round(r.y + r.height / 2); })()" | tail -n 1 | tr -d '"') + read -r x y <<<"$xy" + ab mouse move "$x" "$y" + sleep 0.5 +} +pane='document.querySelector(".vertical-tab-content")' +row() { echo "[...document.querySelectorAll('.modal-container .setting-item')].reverse().find((s) => s.querySelector('.setting-item-name')?.textContent.trim() === '$1')"; } +note="$OBSIDIAN_E2E_CAPTURE_VAULT_PATH/Projects/Launch sync.md" +before=$(grep -c "" "$note") + +ab eval 'app.workspace.openLinkText("Projects/Launch sync", "", false).then(() => app.workspace.activeEditor?.editor?.blur?.())' +ab mouse move 1180 740 +sleep 1.5 +ab eval 'app.setting.open(); app.setting.openTabById("quickadd"); 0' +sleep 1.2 +click "[...$pane.querySelectorAll('button')].find((b) => b.textContent.trim() === 'New choice')" +sleep 0.8 +click "[...document.querySelectorAll('.menu .menu-item')].find((i) => i.textContent.startsWith('Run a sequence of steps'))" +sleep 1.2 +click "$(row Name).querySelector('input')" +ab press Control+a +cap type "Summarize selection" --delay 45 +sleep 0.6 + +click "$pane.querySelector('[aria-label=\"Add AI Assistant command\"]')" +sleep 0.8 +click "$pane.querySelector('[aria-label=\"Configure AI Assistant\"]')" +sleep 1 +click "$(row 'Prompt template').querySelector('.checkbox-container')" +sleep 0.4 +click "$(row 'Prompt template').querySelector('input[type=text], input:not([type])')" +cap type "Summ" --delay 90 +sleep 0.8 +click "[...document.querySelectorAll('.suggestion-container .suggestion-item')].find((i) => i.textContent.includes('Summarize'))" +sleep 0.6 +click "$(row 'Output variable name').querySelector('input')" +ab press Control+a +cap type "summary" --delay 70 +sleep 0.8 +click "[...[...document.querySelectorAll('.modal-container')].pop().querySelectorAll('button')].find((b) => b.textContent.trim() === 'Save')" +sleep 0.8 + +click "$pane.querySelector('[aria-label=\"Add Capture choice\"]')" +sleep 0.8 +click "$pane.querySelector('[aria-label=\"Configure Untitled Capture Choice\"]')" +sleep 1 +click "$(row Name).querySelector('input')" +ab press Control+a +cap type "Append summary" --delay 45 +sleep 0.4 +click "$(row 'Capture to active file').querySelector('.checkbox-container')" +sleep 0.6 +hover "$(row 'Write position').querySelector('select')" +ab select ".vertical-tab-content select:has(option[value=activeTop])" bottom +sleep 0.8 +click "$(row 'Capture format').querySelector('textarea')" +cap type "{{VALUE:summary}}" --delay 70 +sleep 0.8 +click "$(row 'Capture format').querySelector('.setting-item-name')" +sleep 0.6 +click "$pane.querySelector('.setting-page-back-button')" +sleep 1.2 +click "$pane.querySelector('.setting-page-back-button')" +sleep 1.2 +click "document.querySelector('.modal.mod-settings .modal-header-button, .modal.mod-settings .modal-close-button')" +sleep 1 + +# Select the paragraph and run the macro on it. +ab eval '(() => { const e = app.workspace.activeEditor.editor; e.focus(); e.setSelection({ line: 0, ch: 0 }, { line: 0, ch: e.getLine(0).length }); })()' +sleep 1.2 +ab press Control+p +sleep 0.5 +cap type "QuickAdd: Run" --delay 60 --selector ".prompt-input" +sleep 0.5 +ab press Enter +sleep 0.9 +cap type "summ" --delay 90 --selector ".prompt-input" +sleep 0.5 +ab press Enter + +# Verify the product behaviour: the model's reply is a new last line. +for _ in $(seq 1 50); do + (( $(grep -c "" "$note") > before )) && break + sleep 0.2 +done +(( $(grep -c "" "$note") > before )) && [[ -n "$(tail -n 1 "$note")" ]] || { + echo "The macro did not append a summary to $note" >&2 + exit 1 +} +ab eval 'app.workspace.activeEditor?.editor?.blur?.()' +sleep 3 diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index ab76b4f32..fa9cba9b0 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -115,10 +115,12 @@ export default defineConfig({ { label: "Core Concepts", items: [ + { label: "Starting from a preset", slug: "docs/Choices/Presets" }, { label: "Template Choices", slug: "docs/Choices/TemplateChoice" }, { label: "Capture Choices", slug: "docs/Choices/CaptureChoice" }, { label: "Macro Choices", slug: "docs/Choices/MacroChoice" }, { label: "Multi Choices", slug: "docs/Choices/MultiChoice" }, + { label: "Buttons in notes", slug: "docs/Choices/NoteButtons" }, { label: "Share QuickAdd Packages", slug: "docs/Choices/Packages" }, ], }, diff --git a/docs/packages/README.md b/docs/packages/README.md index 44eba9861..440e06eb3 100644 --- a/docs/packages/README.md +++ b/docs/packages/README.md @@ -9,6 +9,7 @@ docs/packages//package.json manifest (hand-authored, committed) docs/packages//files/*.md templates the package bundles (optional) docs/public/scripts/*.js user scripts the package bundles docs/public/packages/.quickadd.json built package (generated, committed) +src/gui/recipes/catalog.generated.json every package, as the plugin's Recipes gallery (generated, committed) ``` A page offers its package by setting `package: ` in its frontmatter. The @@ -53,9 +54,11 @@ script's `settings` object and tell the reader where to paste it in manifest: `../../public/scripts/.js` for scripts, `files/.md` for templates. 4. Add the `install` block and set `package: ` on the docs page. -5. Run `pnpm run packages:build` and commit the generated file. +5. Run `pnpm run packages:build` and commit the generated files: the package + and the recipe catalogue, which takes each recipe's title, description and + slug from the page's frontmatter. `pnpm run test` (the root Vitest suite, run on every PR) fails when a built -package is stale, when a choice's shape drifts from what the plugin stores, +package or the recipe catalogue is stale, when a choice's shape drifts from what the plugin stores, when a package references a file it does not bundle, when a secret value is present, or when a page and a manifest do not match one to one. diff --git a/docs/packages/build.mjs b/docs/packages/build.mjs index a9b2fdf93..6ad12fe52 100644 --- a/docs/packages/build.mjs +++ b/docs/packages/build.mjs @@ -11,6 +11,8 @@ * * pnpm run packages:build # write every package, drop outputs with no manifest * pnpm run packages:build ... # write only the named packages + * + * Writing also regenerates the plugin's recipe catalogue (scripts/build-recipes.mjs). * pnpm run packages:build --check # exit 1 when a committed package is stale * * `tests/examplePackages.test.ts` runs the same check in CI and additionally @@ -19,6 +21,7 @@ import { readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { writeRecipeCatalog } from "../../scripts/build-recipes.mjs"; const here = path.dirname(fileURLToPath(import.meta.url)); export const PACKAGES_DIR = here; @@ -223,11 +226,14 @@ function main() { writeFileSync(outputPath(id), buildPackageJson(id)); console.log(`wrote ${path.relative(process.cwd(), outputPath(id))}`); } - if (requested.length > 0) return; - for (const id of orphanOutputIds()) { - rmSync(outputPath(id)); - console.log(`removed ${path.relative(process.cwd(), outputPath(id))}`); + if (requested.length === 0) { + for (const id of orphanOutputIds()) { + rmSync(outputPath(id)); + console.log(`removed ${path.relative(process.cwd(), outputPath(id))}`); + } } + // The plugin bundles the packages as its recipe catalogue. + writeRecipeCatalog(); } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { diff --git a/docs/packages/change-daily-property/package.json b/docs/packages/change-daily-property/package.json index 307687263..549f574e9 100644 --- a/docs/packages/change-daily-property/package.json +++ b/docs/packages/change-daily-property/package.json @@ -42,7 +42,7 @@ ], "install": { "requires": [ - "either the **Daily notes** core plugin turned on or a custom **Daily note path** (see below)" + "either the **Daily notes** core plugin turned on or a custom **Daily note path** on its script step" ], "afterImport": [ "Create today's daily note and give it the properties you want to change; the menu only lists properties that already exist, and only text, number and checkbox ones.", diff --git a/docs/packages/log-book/package.json b/docs/packages/log-book/package.json index fa3ad8424..6c2b05755 100644 --- a/docs/packages/log-book/package.json +++ b/docs/packages/log-book/package.json @@ -42,7 +42,7 @@ ], "install": { "requires": [ - "either the **Daily notes** core plugin turned on or a custom **Daily note path** (see below)" + "either the **Daily notes** core plugin turned on or a custom **Daily note path** on its script step" ], "afterImport": [ "Create today's daily note if you have not yet; the macro does not create one.", diff --git a/docs/src/content/docs/docs/Advanced/CLI.md b/docs/src/content/docs/docs/Advanced/CLI.md index c7b559a60..3bd1e10af 100644 --- a/docs/src/content/docs/docs/Advanced/CLI.md +++ b/docs/src/content/docs/docs/Advanced/CLI.md @@ -60,11 +60,11 @@ choices apart without opening them: | Capture key | Meaning | | --- | --- | -| `target` | The **Capture to** value, or `` | -| `position` | `top`, `bottom`, `after`, `before`, `cursor`, `newLineAbove`, `newLineBelow`, or `property`, matching **Write position** | +| `target` | The **Where** value, or `` | +| `position` | `top`, `bottom`, `after`, `before`, `cursor`, `newLineAbove`, `newLineBelow`, or `property`, matching **Position** | | `line` | The line for `after` or `before` | | `property` | The property a `property` capture writes | -| `format` | The text written: the **Capture format**, or `{{VALUE}}` when it is empty (before QuickAdd 2.30.0: when its toggle is off) | +| `format` | The text written: the **What** field, or `{{VALUE}}` when it is empty (before QuickAdd 2.30.0: when its toggle is off) | | `task` | `true` when the capture is written as a task | | `eachLine` | `true` when [**One entry per line**](/docs/Choices/CaptureChoice/#one-entry-per-line) is on: each line of `{{VALUE}}` becomes its own entry (QuickAdd 2.30.0 or later) | | `createWithTemplate` | The template for a target file that doesn't exist yet | diff --git a/docs/src/content/docs/docs/Advanced/ObsidianUri.md b/docs/src/content/docs/docs/Advanced/ObsidianUri.md index 6034b5087..bdc886276 100644 --- a/docs/src/content/docs/docs/Advanced/ObsidianUri.md +++ b/docs/src/content/docs/docs/Advanced/ObsidianUri.md @@ -170,7 +170,7 @@ overwrites the synced version when it finally arrives. - **Open Obsidian first**: always open Obsidian and wait for sync before using URIs. - **Use device-specific names**: configure different filename formats per device (for example `{{DATE}}-mobile`). -- **Capture to active file**: use an already-open note to avoid creating a file at all. +- **Capture to active note**: use an already-open note to avoid creating a file at all. - **Include timestamps**: add `{{TIME}}` to filenames so each one is unique. This is a fundamental limitation of file-based sync services and cannot be diff --git a/docs/src/content/docs/docs/Advanced/onePageInputs.md b/docs/src/content/docs/docs/Advanced/onePageInputs.md index c998db15f..491680e64 100644 --- a/docs/src/content/docs/docs/Advanced/onePageInputs.md +++ b/docs/src/content/docs/docs/Advanced/onePageInputs.md @@ -17,7 +17,10 @@ For a task-oriented overview of prompts in general, see ## Turn it on {#enable} -Go to **Settings → QuickAdd** and toggle **One-page input for choices**. +A choice made from a [preset](/docs/Choices/Presets/) or by +[Your first choices](/docs/Choices/Presets/#first-run) already uses the form: +its **One-page input override** starts at **Always**. For every other choice, +go to **Settings → QuickAdd** and toggle **One-page input for choices**. It works with Template, Capture, and Macro choices. @@ -27,7 +30,8 @@ form. Fields appear in the order the choice uses them: the day first when [Which day](/docs/Choices/TemplateChoice/#date-origin) is **Ask each time**, then a Capture's note picker (when it captures to a folder or a tag), a -Template's template path, folder and file name before the note content, a +Template's template path, folder and file name (the note title, when it sets +no **File name**) before the note content, a Macro's steps in turn, and within each, the order they have in the format. Step-by-step prompts group one text's fields by kind instead; see [The order prompts appear in](/docs/ControllingPrompts/#prompt-order). @@ -51,8 +55,8 @@ to **Never**. Template, Capture, and Macro choice builders have a **One-page input override** dropdown that overrides the global setting for that one choice: -- **Follow global setting** - inherit the enclosing Macro's override, or use the global toggle (default). -- **Always** - force the one-page form for this choice even when it is off globally. +- **Follow global setting** - inherit the enclosing Macro's override, or use the global toggle. Choices made before QuickAdd 3 start here. +- **Always** - force the one-page form for this choice even when it is off globally. New choices start here. - **Never** - use step-by-step prompts for this choice even when it is on globally. ### Macro overrides @@ -76,6 +80,28 @@ If that Template's override is **Never**, QuickAdd shows its note picker first. After the Template finishes, remaining eligible Capture inputs appear together in one form. Scripts and conditional steps retain their execution boundaries. +## Where the run lands {#preview} + +Above the fields, the form says what the run will do with your answers, and +updates as you type: + +- **Creates** - for a Template, the full path of the new note, for example + `Meetings/2026-10-06 Launch review.md`. When the run asks for the folder, + the path starts with `{folder}`. A name the run would refuse is flagged + under the path. +- **Adds to** - for a Capture, the note it writes to and the heading it writes + under, for example `Journal/2026-10-06.md under ## Log`. `{{DAILY}}` names + today's daily note. A Capture that asks which note says *a note you pick*, + and one that captures to the active file says *the current note*. +- One row per date field you have filled, named after the field, with the date + as the run will write it. Text that is not a date reads **Not a date**. + +When you answer prompts one at a time instead, the last prompt of a Capture +names the same note and heading under its title. + +**Enter** in a one-line field submits the form, as it does in a single prompt. +**Ctrl/Cmd+Enter** submits from any field. + ## What ends up in the form {#what-gets-collected} QuickAdd scans the choice for placeholders and turns each one into a field: @@ -84,7 +110,7 @@ QuickAdd scans the choice for placeholders and turns each one into a field: - Nested `{{TEMPLATE:path}}` includes are scanned recursively, so their prompts show up too. - `{{VALUE|type:multiline}}` and `{{VALUE:name|type:multiline}}` become textareas. - `{{VALUE:name|type:number|min:1|max:10}}` becomes a bounded numeric input, and `{{VALUE:name|type:slider|min:0|max:100|step:5}}` becomes a slider plus numeric input. -- The capture target file, when you are capturing to a folder or a tag. It is a searchable picker like a [FILE input](#file-ux), and it also finds notes by their aliases. With **Create file if it doesn't exist**, typing a new name offers **Create new note: name**, as the run's picker does, and the capture creates that note. A note's name or alias picks the note instead. The picker starts empty, and the form waits for a note before it submits (QuickAdd 2.30.0 or later; earlier versions picked the first note for you). +- The capture target file, when you are capturing to a folder or a tag. It is a searchable picker like a [FILE input](#file-ux), and it also finds notes by their aliases. With **Create note if it doesn't exist**, typing a new name offers **Create new note: name**, as the run's picker does, and the capture creates that note. A note's name or alias picks the note instead. The picker starts empty, and the form waits for a note before it submits (QuickAdd 2.30.0 or later; earlier versions picked the first note for you). - Inputs declared by a user script inside a macro, if the script provides them. For [property captures](/docs/Choices/CaptureChoice/#property), a plain `VALUE` @@ -141,6 +167,7 @@ The form only opens when it has something to ask: - If every required input already has a value (for example, prefilled by an earlier macro step), the form does not open. - An empty string counts as an intentional value and will not prompt again. This applies to `{{VDATE}}` too: a script-set `""` renders empty instead of re-prompting. - For Capture choices, a non-empty editor selection prefills `{{VALUE}}` during preflight when selection-as-value is enabled. +- For a Template choice with no **File name**, a non-empty editor selection is the note title, so the form does not ask for it. :::note[Required date fields] A **required** date field with a default applies the default automatically when diff --git a/docs/src/content/docs/docs/Advanced/scriptsWithSettings.md b/docs/src/content/docs/docs/Advanced/scriptsWithSettings.md index 9ffe178b4..e5d07f7c4 100644 --- a/docs/src/content/docs/docs/Advanced/scriptsWithSettings.md +++ b/docs/src/content/docs/docs/Advanced/scriptsWithSettings.md @@ -9,8 +9,9 @@ anyone can set it up - an API key, a folder path, an on/off toggle - without editing the JavaScript. You write the script once and expose the parts that should change; everyone else fills in a form. -Any script with settings gets a gear (⚙️) button next to its name in a macro. -Click it to open that script's settings menu. For a real-world example, see the +A script step in a macro has a gear (⚙️) button once its file is found. +Click it to open the script's settings: **Script file** first, then the fields +the script defines. For a real-world example, see the [Movies](/docs/Examples/Macro_MovieAndSeriesScript/) macro. ## Add settings to a script {#creating-a-script-with-settings} diff --git a/docs/src/content/docs/docs/Choices/CaptureChoice.md b/docs/src/content/docs/docs/Choices/CaptureChoice.md index 319e9064b..4baefa1a8 100644 --- a/docs/src/content/docs/docs/Choices/CaptureChoice.md +++ b/docs/src/content/docs/docs/Choices/CaptureChoice.md @@ -12,22 +12,22 @@ stay right where you are. Use it to: - Log work under the right heading of a project note - Save interesting links for later reading -![The QuickAdd Capture builder page, showing the Name field and the Location, Position, and Linking sections](../Images/choices/capture-builder.png) +![The QuickAdd Capture builder page: the Name field, the line that says what the capture does, and the Where, Position, and What settings](../Images/choices/capture-builder.png) ## Set up your first capture {#set-up} -1. In **Settings → QuickAdd**, click **New choice** → **Capture**. The +1. In **Settings → QuickAdd**, click **New choice** → **Add to a note**. The Capture builder opens as a page of the settings window; set **Name** to `Add to journal`. (Before QuickAdd 2.30.0, the builder is a dialog; click its name at the top to rename it.) -2. Set **Capture to** to where entries should land, for example - `Journal/{{DATE}}.md`. -3. Turn on **Create file if it doesn't exist**, so the first capture of the - day creates today's note instead of stopping with a "Target file missing" - notice. -4. In **Capture format**, describe one entry, for example - `- {{DATE:HH:mm}} {{VALUE}}`. (Before QuickAdd 2.30.0, turn on the - **Capture format** toggle first.) +2. Set **Where** to where entries should land, for example + `Journal/{{DATE}}.md`. (In earlier versions, this is **Capture to**.) +3. Click **More settings** and turn on **Create note if it doesn't exist**, so + the first capture of the day creates today's note instead of stopping with + a notice that the note does not exist. +4. In **What**, describe one entry, for example + `- {{DATE:HH:mm}} {{VALUE}}`. (In earlier versions, this is **Capture + format**; before QuickAdd 2.30.0, turn on its toggle first.) 5. Run it: command palette → `QuickAdd: Run`, pick `Add to journal`, type your entry. @@ -40,10 +40,10 @@ You now have this in today's journal note: Assign the choice a hotkey (⚡ icon, or Obsidian's Hotkeys settings) once it behaves the way you want. -## Choose where it goes: Capture To {#capture-to} +## Choose where it goes: Where {#capture-to} -_Capture To_ is the note you are capturing to. Either enable **Capture to -active file** to write into the note you are currently in, or enter a file +_Where_ is the note you are capturing to. Either enable **Capture to +active note** to write into the note you are currently in, or enter a file path. The path supports [format syntax](/docs/FormatSyntax/), so it can be dynamic. @@ -55,10 +55,10 @@ Journal/{{DATE:YYYY-MM-DD - ddd MMM D}}.md Every run finds today's file, and your entry is captured to it. -For your daily note, click **Daily note** next to **Capture to** (QuickAdd +For your daily note, click **Daily note** next to **Where** (QuickAdd 2.30.0 or later). It writes [`{{DAILY}}`](/docs/FormatSyntax/#daily) into the field and turns on **Create -file if it doesn't exist**. `{{DAILY}}` uses the folder, date format, and +note if it doesn't exist**. `{{DAILY}}` uses the folder, date format, and template from Obsidian's **Daily notes** settings, or from Periodic Notes when it manages your daily notes, so the path always matches the note **Open today's daily note** opens. [`{{WEEKLY}}`, `{{MONTHLY}}`, `{{QUARTERLY}}`, and `{{YEARLY}}`](/docs/FormatSyntax/#periodic-notes) @@ -78,7 +78,7 @@ from that path segment. The text inserted into the note is not changed. ### How QuickAdd picks the target {#how-quickadd-picks-a-target} -When **Capture to active file** is off, the resolved _Capture to_ +When **Capture to active note** is off, the resolved _Where_ value decides what happens: | You write | What happens | @@ -99,7 +99,7 @@ modification time on purpose, so a sync that touches old notes doesn't push them to the top. Files in Obsidian's **Excluded files** list sink to the bottom but stay selectable. -You can also **type a new name** into the picker: with **Create file if it +You can also **type a new name** into the picker: with **Create note if it doesn't exist** enabled, a **Create new note: <name>** row appears and QuickAdd creates the note for you. The create row is selected first, so press Enter to create the exact name you typed even when existing notes are fuzzy @@ -119,7 +119,7 @@ folder to capture to - nested folders included. Format syntax works here too. For example: you keep one note per person in `CRM/people`. Set _Capture To_ to `CRM/people`, run the capture, and pick the person. Type `John Doe` instead -and QuickAdd creates `CRM/people/John Doe.md` (with **Create file if it +and QuickAdd creates `CRM/people/John Doe.md` (with **Create note if it doesn't exist** enabled). ### Capture to a tag {#capturing-to-tags} @@ -152,7 +152,7 @@ Type `property:=` to limit the picker to notes whose frontmatter matches. If your notes have a `type` field, `property:type=draft` opens a picker containing only the notes whose `type` is `draft`. -This selects the destination note. [**Write position → Property**](#property) +This selects the destination note. [**Position → Property**](#property) controls whether the capture updates one of that note's properties. - `property:type=draft` - notes whose `type` equals `draft`. @@ -173,7 +173,7 @@ Good to know: - The field name matches case-insensitively (`property:type` matches a `Type:` field), and value matching is always case-insensitive. - Only the `folder:` / `tag:` / `exclude-folder:` / `exclude-tag:` / `exclude-file:` pipe filters are applied here. - Because `|` starts a filter, a property value cannot itself contain `|`. -- Typing a new note name (with **Create file if it doesn't exist**) creates the note, but does not automatically give it the property. +- Typing a new note name (with **Create note if it doesn't exist**) creates the note, but does not automatically give it the property. ### Send one entry to several notes {#capturing-the-same-entry-to-multiple-files} @@ -182,10 +182,10 @@ fixed notes, compose Capture choices with a [Macro](/docs/Choices/MacroChoice/): 1. Create one Capture choice per destination. 2. Give each the same named value, for example `- {{VALUE:entry}}`. -3. Create a Macro and add each Capture choice as a **Nested Choice** command. +3. Create a Macro and add each Capture choice with **Add a step** → **Run a choice**. 4. Run the Macro: QuickAdd prompts for `entry` once and reuses the answer. -| Choice | Capture To | Format | +| Choice | Where | Format | | --- | --- | --- | | Log to Person A | `People/Person A.md` | `- {{VALUE:entry}}` | | Log to Person B | `People/Person B.md` | `- {{VALUE:entry}}` | @@ -195,7 +195,7 @@ target and run it repeatedly from a [user script](/docs/UserScripts/): | Setting | Value | | --- | --- | -| Capture To | `People/{{VALUE:person}}.md` | +| Where | `People/{{VALUE:person}}.md` | | Format | `- {{VALUE:entry}}` | ```js @@ -231,9 +231,9 @@ alias with the note's name beneath it, as in Obsidian's quick switcher, and typing an alias exactly picks its note instead of offering to create a new one (QuickAdd 2.30.0 or later). -## Shape the entry: Capture format {#capture-format} +## Shape the entry: What {#capture-format} -_Capture format_ is what actually gets written - think of it as a mini +_What_ is the capture format: what actually gets written - think of it as a mini template for one entry. Left empty, QuickAdd writes `{{VALUE}}`: whatever you type in the prompt (or your editor selection, if selection-as-value is enabled). Before QuickAdd 2.30.0, the field has a toggle: it is hidden while @@ -278,10 +278,10 @@ merge. :::note To insert `.base` content into your current note, keep **Capture to active -file** enabled and use a `{{TEMPLATE:...}}` placeholder pointing at a `.base` file in the format - see -[Capture: Insert a Related Notes Base into an MOC Note](/docs/Examples/Capture_InsertBaseTemplateIntoActiveFile/). +note** enabled and use a `{{TEMPLATE:...}}` placeholder pointing at a `.base` file in the format - see +[Capture: Insert a related notes Base into an MOC note](/docs/Examples/Capture_InsertBaseTemplateIntoActiveFile/). To create a brand-new note that embeds a Base, use a Template choice - see -[Template: Create an MOC Note with a Link Dashboard](/docs/Examples/Template_CreateMOCNoteWithLinkDashboard/). +[Template: Create an MOC note with a link dashboard](/docs/Examples/Template_CreateMOCNoteWithLinkDashboard/). ::: If your format includes an inline `js quickadd` block and you need to @@ -292,8 +292,17 @@ don't put `{{VALUE}}` inside JavaScript string literals. See ## The options, one by one {#capture-options} -The Capture builder groups its settings into **Location**, **Position**, -**Linking**, **Content**, and **Behavior**. +The Capture builder starts with one line that says what the capture does, for +example *Adds a line at the bottom of Journal/{date}*. It changes as you change +the settings below it. + +Under it are the settings every capture needs: **Where**, **Position**, and +**What**, with the **Task** toggle next to **What**. Then come +[Inputs](#inputs) and [Steps](#steps). The rest of the options below are behind +**More settings** at the bottom. **More settings** opens by itself when one of +them is changed from what a new capture has, so a capture you set up shows what +you set. Once you open it, it stays open for that capture until Obsidian +restarts. ### Create the note if it's missing {#create-file-if-it-doesnt-exist} @@ -303,7 +312,8 @@ setting. ### Format the entry as a task {#task} -_Task_ formats your captured text as a task (`- [ ] ...`). +_Task_, the toggle next to **What**, formats your captured text as a task +(`- [ ] ...`). ### One entry per line {#one-entry-per-line} @@ -357,22 +367,22 @@ editor is used as `{{VALUE}}` instead of prompting: **Follow global setting**, [**Settings → QuickAdd → Advanced**](/docs/Settings/#advanced-input), or on the main QuickAdd tab before QuickAdd 2.30.0). This does not affect `{{SELECTED}}`. -### Pick where in the note it lands: Write position {#write-position} +### Pick where in the note it lands: Position {#write-position} -_Write position_ controls where in the note the entry is written. The options -depend on whether **Capture to active file** is enabled: +_Position_ controls where in the note the entry is written. The options +depend on whether **Capture to active note** is enabled: -- **At cursor** (active file) / **Top of file** (target file) - the first option's label changes with the mode -- **Top of file (after frontmatter)** (active file only) +- **At cursor** (active file) / **Top of note** (target file) - the first option's label changes with the mode +- **Top of note (after frontmatter)** (active file only) - **New line above cursor** / **New line below cursor** (active file only) - **After line…** - insert after a target line you specify, or pick a heading at run time. The workhorse for structured notes - see [Insert after](#insert-after). - **Before line…** - see [Insert before](#insert-before) -- **Bottom of file** - starts the entry on a new line. For a blank line between entries, put one in the format, as in `{{VALUE}}\n\n`. Before QuickAdd 2.30.0, a format ending in `\n` also left a blank line before each new entry. +- **Bottom of note** - starts the entry on a new line. For a blank line between entries, put one in the format, as in `{{VALUE}}\n\n`. Before QuickAdd 2.30.0, a format ending in `\n` also left a blank line before each new entry. - **Property** - set a frontmatter value or add items to a list. ### Capture into a property {#property} -**Write position → Property** writes the Capture format to one frontmatter +**Position → Property** writes the capture format to one frontmatter property in a Markdown note. The note's body and unrelated property values stay intact. QuickAdd uses Obsidian's frontmatter writer, so YAML formatting can change. @@ -432,7 +442,7 @@ Inline scripts read the current value through [Property Capture variables](/docs/InlineScripts/#property-capture-variables). **Create property if missing** permits a new key. When disabled, a missing key -stops the capture. **Create file if it doesn't exist** separately controls +stops the capture. **Create note if it doesn't exist** separately controls whether QuickAdd can create the destination note. The Capture format supplies the value. A format made entirely of one `VALUE`, @@ -478,7 +488,7 @@ later **Add to list**. Use **Add to list** to create it as a list, or set the property's type in Obsidian first - choose Text to keep the lines as one value. QuickAdd collects and validates the property inputs before writing or creating -the note. **Task** and **Run Templater on entire destination file after capture** +the note. **Task** and **Run Templater on entire destination note after capture** are hidden and do not apply to property captures. Scripts can supply native values through `executeChoice`. With **Add to list**, @@ -566,8 +576,8 @@ paste into another note. ### Open the captured note {#opening-the-captured-file} -When **Capture to active file** is off, the **Behavior** section shows an -_Open_ toggle. Enabling it reveals: +When **Capture to active note** is off, the **Behavior** section under **More +settings** shows an _Open_ toggle. Enabling it reveals: - _File opening location_ - **Reuse current tab**, **New tab**, **Split pane**, **New window**, **Left sidebar**, or **Right sidebar** - _Split direction_ - **Split right** or **Split down** (shown for **Split pane**) @@ -592,6 +602,12 @@ day)"** registers a second command that asks which day first. One hotkey captures to today, the other to whichever day you pick, from the same choice. +### Put it in the ribbon: Show in ribbon {#show-in-ribbon} + +**Show in ribbon** adds an icon to Obsidian's ribbon that runs the capture. The +icon and its tooltip are the choice's icon and name. The setting saves as soon +as you flip it. A choice nested inside a macro doesn't have it. To put a button that runs it in a note instead, see [Buttons in notes](/docs/Choices/NoteButtons/). + ### Run Templater on the whole file afterwards {#run-templater-on-entire-destination-file-after-capture} :::caution[Deprecated] @@ -610,7 +626,7 @@ cursor at `{{CURSOR}}`, since the text under the marker may have moved. Body captures have two Templater paths when they create a missing Markdown file: -- **Create file if it doesn't exist** without a QuickAdd template: QuickAdd creates a blank file first. If Templater's new-file trigger applies to that location, QuickAdd waits for Templater to finish before inserting the capture. +- **Create note if it doesn't exist** without a QuickAdd template: QuickAdd creates a blank file first. If Templater's new-file trigger applies to that location, QuickAdd waits for Templater to finish before inserting the capture. - **Create with template**: QuickAdd owns the initial content. It renders the selected QuickAdd template, suppresses Templater's new-file/directory trigger for that creation, then runs Templater once on the content QuickAdd wrote. So a blank Capture-created file can receive Templater's directory template @@ -621,6 +637,56 @@ Property captures prepare the property value before creating the file. After a new-file Templater pass, QuickAdd applies the property update to the resulting frontmatter so the template does not discard the capture. +## See what it asks for: Inputs {#inputs} + +The **Inputs** group, above **Steps**, lists what the capture asks for when it +runs, in the order it first appears: in **Where**, then in the capture +format, then in the template a missing note is created with. Each row shows the +input's name, its kind (*value*, *date*, *field*, *file*, *math*, or *pick* for +the note you pick from a folder or tag), and where it is defined. An empty +capture format still asks for `{{VALUE}}`, so it is listed too. + +Two controls change how a value, date, or file input is asked for, without +editing the placeholder: + +- **Label** - the title of its prompt, and of its field in the one-page form. + Leave it empty to keep the placeholder's own, shown greyed out in the field. +- **Optional** - whether you can leave it empty. It starts as the placeholder + says, with `|optional` or without. + +Both save as soon as you change them. A run that is given the value up front, +from the CLI or a URI, isn't affected. Rename the placeholder and the input +asks as the placeholder says again. + +An input from the template file reads *Defined in* and the file's name. Click +the name to open the file, and change the placeholder there. + +A capture nested inside a macro lists its inputs without the controls. + +## Do more afterwards: Add a step {#steps} + +The last group in the builder, **Steps**, lists what the capture does, one line +per step, for example *Adds a line at the bottom of Inbox* and *Opens it*. The +list follows the settings as you change them. + +**Add a step** adds something to do after the capture: + +- **Run a script** - a script step with no file yet. Click **Choose file** on + it to pick the script. +- **Open a note** - an **Open File** step. Set the note in its settings. +- **Link it** - links the note on a new line in the current note. +- **Run Templater** - runs Templater on the note. +- **Wait** - a pause of 100 ms. + +Adding a step turns the choice into a [macro](/docs/Choices/MacroChoice/). +QuickAdd saves the capture, makes it the macro's first step, adds the new +step after it, and opens the macro builder. The choice +keeps its name, its command, and its hotkey. To change the capture's settings +later, use the gear on its step in the macro. + +A capture that is already a step inside a macro lists its steps but has no +**Add a step** button. + ## Insert after {#insert-after} **After line…** inserts the entry after a line with the text you specify - @@ -678,7 +744,7 @@ is added above older ones, while a fixed title stays pinned at the top. The classic "daily log, newest first" recipe (issue [#481](https://github.com/chhoumann/quickadd/issues/481)): -- **Capture to**: your log note (enable `Create file if it doesn't exist` to auto-create it) +- **Where**: your log note (enable `Create note if it doesn't exist` to auto-create it) - **Format**: the entry with a trailing newline, e.g. `- {{DATE:HH:mm}} {{VALUE}}\n` (task captures add their own newline) - **Insert after**: the day heading, `## {{DATE:YYYY-MM-DD}}` - **Insert at end of section**: off, so each entry lands directly under the day heading (newest first within the day) @@ -838,7 +904,7 @@ QuickAdd supports two Canvas capture workflows: ### Capture to the selected card {#1-capture-to-selected-card-in-active-canvas} -Enabled when **Capture to active file** is on and the active view is a +Enabled when **Capture to active note** is on and the active view is a Canvas. Supported card targets: - Text cards @@ -846,26 +912,26 @@ Canvas. Supported card targets: ### Capture to a card in a specific file {#2-capture-to-specific-card-in-specific-canvas-file} -Enabled when **Capture to active file** is off, the capture path resolves to +Enabled when **Capture to active note** is off, the capture path resolves to a `.canvas` file, and **Target canvas node** is set. When the path is a `.canvas` file, QuickAdd shows a node picker so you can choose the card directly from that board. -### Write positions in Canvas {#write-position-support-in-canvas} +### Positions in Canvas {#write-position-support-in-canvas} -- Text cards and file cards (Markdown targets) support: **Top of file**, **Bottom of file**, **After line...**, **Before line...** -- Cursor-based modes (**At cursor**, **New line above/below cursor**) don't exist in Canvas. If **Capture to active file** is on and the write position is still the default **At cursor**, the capture aborts until you switch to a supported mode. +- Text cards and file cards (Markdown targets) support: **Top of note**, **Bottom of note**, **After line...**, **Before line...** +- Cursor-based modes (**At cursor**, **New line above/below cursor**) don't exist in Canvas. If **Capture to active note** is on and the write position is still the default **At cursor**, the capture aborts until you switch to a supported mode. Selected-card mode needs exactly one selected card. If the selection is missing, multiple, or unsupported, QuickAdd aborts with a notice instead of writing to the wrong place. -When **Link to captured file** is **Enabled (strict)** and the capture runs +When **Link to captured note** is **Enabled (strict)** and the capture runs from a Canvas card without a focused Markdown editor, the capture still writes and link insertion is skipped. For a step-by-step setup, see -[Capture: Canvas Capture](/docs/Examples/Capture_CanvasCapture/). +[Capture: Canvas capture](/docs/Examples/Capture_CanvasCapture/). ### Canvas capture FAQ {#canvas-capture-faq} diff --git a/docs/src/content/docs/docs/Choices/MacroChoice.md b/docs/src/content/docs/docs/Choices/MacroChoice.md index 9eb36f3f6..34d5c5099 100644 --- a/docs/src/content/docs/docs/Choices/MacroChoice.md +++ b/docs/src/content/docs/docs/Choices/MacroChoice.md @@ -37,6 +37,10 @@ gives you something to trigger. - **Commands** - the individual steps (Obsidian commands, scripts, AI prompts, and more). - **Variables** - data that one command sets and a later command reads, all within a single run. +A Capture or Template choice can grow into a macro: **Add a step** at the +bottom of its settings turns it into a macro that runs it first. See [Do more +afterwards](/docs/Choices/CaptureChoice/#steps) on the Capture page. + ## Set up your first macro {#creating-a-macro} We'll build a tiny macro with no code: it opens today's daily note and drops @@ -44,48 +48,69 @@ your cursor at the end, ready to type. Three commands, run as one. ### Step 1: Create the macro choice {#step-1-create-a-macro-choice} -1. In **Settings → QuickAdd**, click **New choice** → **Macro**. The Macro - Builder opens as a page of the settings window; set **Name** to +1. In **Settings → QuickAdd**, click **New choice** → **Run a sequence of + steps**. The Macro Builder opens as a page of the settings window; set **Name** to `Open daily note`. To reopen the builder later, click the gear on the choice's row (on a phone, **⋮** → **Configure**). Going back saves it. (Before QuickAdd 2.30.0, the builder is a dialog; click its name at the top to rename it.) -![The Macro builder page, with the Commands and Behavior sections](../Images/choices/macro-builder.png) +![The sequence builder page: the line saying what the macro does, three numbered steps, Add a step and More settings](../Images/choices/macro-builder.png) ### Step 2: Build the macro {#step-2-build-your-macro} -1. In the Macro Builder, add an **Obsidian Command** and pick +1. Click **Add a step** → **Run a command** and pick `Daily notes: Open today's daily note`. -2. Click the clock button (**Add wait command**) to add a **Wait** step of - 100 ms. The command step doesn't wait for the daily note to open, so without - the pause the cursor moves before the note is there. If the cursor still ends - up in the wrong note, click the number and wait longer. -3. Add an **Editor commands** entry and choose **Move cursor to file end**. -4. Close the builder, then run it: command palette → +2. Click **Add a step** → **Wait** to add a wait of 100 ms. The command step + doesn't wait for the daily note to open, so without the pause the cursor + moves before the note is there. If the cursor still ends up in the wrong + note, click the number under **Wait** and wait longer. +3. Click **Add a step** → **Run an editor command** and choose **Move cursor + to file end**. +4. Go back to save it, then run it: command palette → `QuickAdd: Run` → `Open daily note`. Your daily note opens and the cursor sits at the end of the file, ready for the next line - all three steps in a single command. Assign the choice a hotkey (the ⚡ icon, or Obsidian's Hotkeys settings) once it behaves the way you want. -## The commands you can add {#command-types} +## The builder page {#builder-page} -The Macro Builder offers these command types. Add as many as you like, in any -order. +The page opens with one line that says what the macro does, made from its +steps: "Runs 'Daily notes: Open today's daily note', waits 100 ms, ...". It +follows every change you make below it. -| Command | What it does | -| --- | --- | -| **Obsidian Command** | Run any Obsidian command, for example `Daily notes: Open today's daily note` or `Toggle reading view`. | -| **Editor commands** | Manipulate text in the active editor: copy, cut, paste, [paste with format](#paste-with-format), select the line or a link on it, and move the cursor. See [Editor commands](#editor-commands). | -| **User Script** | Run your own JavaScript to reach the Obsidian API, do complex work, or integrate with other plugins. See [Add a user script command](#add-a-user-script-command). | -| **Nested Choice** | Run another QuickAdd choice - a template, capture, or another macro - so you can reuse existing work and build modular workflows. | -| **Wait** | Pause for a set number of milliseconds, useful when a previous command needs time to finish. | -| **AI Assistant** | Run an AI prompt to generate or process content. Available once you've configured an AI provider. | -| **Open File** | Open an existing file at a formatted path. Supports all [format syntax](/docs/FormatSyntax/) (`{{DATE}}`, `{{VALUE}}`, and so on), with tab and split options. It opens in the default view mode with focus, and only opens files that already exist (it won't create one). | -| **Conditional** | Branch the run based on live data. See [Branch with a conditional](#conditional-commands). | +Under **Steps**, each step is a numbered row: its name, and under it what it +does ("Adds a line at the bottom of Inbox", "Runs streaks.js", "Waits 100 +ms"). A row's gear opens its settings; a Create or Add row opens that note +step's own page. Drag a row by its handle to reorder it, or focus the handle +and press the up and down arrow keys. The trash can removes the step. + +**Add a step** opens a menu of the steps a macro can hold. The macro's +settings (one-page input, Which day, Run on startup, the command palette, the +ribbon and the icon) are under **More settings**, which opens by itself when +one of them is set. See [Macro settings](#macro-settings). -### Add a user script command {#add-a-user-script-command} +## The steps you can add {#command-types} + +Pick a step from **Add a step**. Add as many as you like, in any order. + +| Step | What it does | +| --- | --- | +| **Create a note** | Create a note from a template. It is added as a new Template choice inside the macro, and its page opens so you can set it up. | +| **Add to a note** | Write into a note. It is added as a new Capture choice inside the macro, and its page opens so you can set it up. | +| **Open a note** | Open an existing file at a formatted path. Supports all [format syntax](/docs/FormatSyntax/) (`{{DATE}}`, `{{VALUE}}`, and so on), with tab and split options and a **View** (as saved, source mode, reading view or Live Preview). It only opens files that already exist (it won't create one). | +| **Link it** | Link the note an earlier step wrote ([`{{NOTE}}`](/docs/FormatSyntax/#note)) on a new line in the current note. Its settings pick another note, where the link goes, and whether to copy the link too. | +| **Run Templater** | Run Templater's *Replace templates* over the note an earlier step wrote (`{{NOTE}}`), or over the note its settings name. Does nothing without Templater. | +| **Run a script** | Run your own JavaScript to reach the Obsidian API, do complex work, or integrate with other plugins. See [Add a script step](#add-a-user-script-command). | +| **Run a command** | Run any Obsidian command, for example `Daily notes: Open today's daily note` or `Toggle reading view`. | +| **Run an editor command** | Manipulate text in the active editor: copy, cut, paste, [paste with format](#paste-with-format), select the line or a link on it, and move the cursor. See [Editor commands](#editor-commands). | +| **Ask AI** | Run an AI prompt to generate or process content. Offered while online features are on; set up a provider first. | +| **Run a choice** | Run another of your QuickAdd choices - a template, capture, or another macro - so you can reuse existing work and build modular workflows. | +| **If** | Branch the run based on live data. See [Branch with a conditional](#conditional-commands). | +| **Wait** | Pause for a set number of milliseconds, useful when a previous step needs time to finish. | + +### Add a script step {#add-a-user-script-command} Macros don't contain JavaScript directly. Your code lives either in a `.js` file inside your vault **or** in a ` ```js ` code block inside a note, and the macro @@ -98,25 +123,31 @@ Create a script file such as `scripts/my-macro.js`, or a note such as block. QuickAdd runs the **first** matching JavaScript block in a note and ignores the surrounding prose. -To add it, open the Macro Builder and add a **User Script** command. There are -two ways to point it at your script: - -- **Browse** opens QuickAdd's script picker (not your operating system's file - picker). It lists the `.js` files and notes-with-a-code-block that Obsidian - has already discovered, so it can't reach files outside the vault or hidden - from Obsidian's index. Each entry shows its full path, and you can search by - folder, so same-named scripts such as several `view.js` files are easy to - tell apart. -- **Type it in.** For a `.js` file, type its basename - for - `scripts/my-macro.js`, enter `my-macro`; if two `.js` files share that name, - use its vault path instead. For a note, type its vault path, for example - `Scripts/my-macro.md`. Then click **Add**. To run a specific exported - function, append it with `::`, such as `my-macro::start`. +To add it, click **Add a step** → **Run a script**. That opens QuickAdd's +script picker (not your operating system's file picker). It lists the `.js` +files and notes-with-a-code-block that Obsidian has already discovered, so it +can't reach files outside the vault or hidden from Obsidian's index. Each +entry shows its full path, and you can search by folder, so same-named scripts +such as several `view.js` files are easy to tell apart. + +To run a specific exported function, type the script with the function after +`::` and press Enter: `my-macro::start` for `scripts/my-macro.js`. If two `.js` +files share that name, use its vault path instead; for a note, type its vault +path, for example `Scripts/my-macro.md::start`. If the script exports more than one function and you don't name one, QuickAdd asks which export to run. You can also set an output variable name so later commands can reuse the result. +A script step says which file it runs under its name ("Runs my-macro.js"); +hover it for the full path. A macro made from the +**Run a script** [preset](/docs/Choices/Presets/) starts with a step that has +no file yet: it says **No file chosen** and offers **Choose file**, which opens +the same script picker. Once the step has a file, its gear opens +the script's settings, starting with **Script file** and a **Change** button. +See [The script step](/docs/UserScripts/#script-step) for what each state means +and what changing the file resets. + :::caution[Where to keep scripts] Keep the script inside your vault, but **not** inside `.obsidian` or any folder whose name starts with a dot. Obsidian may exclude hidden folders from its file @@ -129,9 +160,9 @@ underscore-prefixed folder such as `_quickadd/scripts/`. Full rules are in Good to know: - To **insert text into a note**, don't write it in a script. Use a **Template** - or **Capture** choice and run it from the macro as a **Nested Choice** - command. That's the intended way to write content, and no YAML frontmatter is - required. + or **Capture** choice and run it from the macro with **Add to a note**, + **Create a note** or **Run a choice**. That's the intended way to write + content, and no YAML frontmatter is required. - If your script calls the API of another plugin, that plugin must be installed and enabled in your vault. You don't need any extra plugin just to run user scripts. @@ -157,13 +188,14 @@ boilerplate JavaScript. Each conditional has: To add one: -1. Click the branch icon in the command bar of the Macro Builder (or of any - conditional branch editor). -2. Click the settings icon on the new command to define the condition. -3. Use the branch buttons to set the commands that run for the **Then** and - **Else** outcomes. Each branch opens as a page over the macro; go back to - return to it. (Before QuickAdd 2.30.0, a branch opens in a dialog with - **Save** and **Cancel**.) +1. Click **Add a step** → **If** in the macro (or in any branch). The + condition's settings open; define the condition there. The step's gear + opens them again later. +2. Use the branch buttons to set the steps that run for the **Then** and + **Else** outcomes. Each branch opens as a page over the macro, led by the + If step's line, with its own steps and **Add a step**; go back to return to + it. (Before QuickAdd 2.30.0, a branch opens in a dialog with **Save** and + **Cancel**.) The macro runs the matching branch in order, then continues with the rest of the macro. Branch commands share the same variable map as the outer macro, so they @@ -294,7 +326,8 @@ commands share the same name, rename one before using the selector form. ## Macro settings {#macro-settings} -![The Macro builder, including the Run on startup toggle](../Images/choices/macro-builder.png) +These are under **More settings** on the builder page, which opens by itself +when one of them is set. ### Which day {#date-origin} @@ -319,6 +352,11 @@ the macro runs. See [Command palette](/docs/Choices/TemplateChoice/#command-palette) on the Template page. +### Show in ribbon {#show-in-ribbon} + +**Show in ribbon** adds an icon to Obsidian's ribbon that runs the macro, with +the choice's icon and name. It saves as soon as you flip it. To put a button that runs it in a note instead, see [Buttons in notes](/docs/Choices/NoteButtons/). + ## Practical examples {#practical-examples} ### Example 1: Log a book to your daily note {#example-1-book-logging-macro} @@ -400,6 +438,14 @@ module.exports = async (params) => { }; ``` +## How a sequence runs {#how-a-sequence-runs} + +A sequence runs one step at a time, in order. A step that creates a note or +adds to one runs exactly as a Template or Capture choice would, and the note it +ends on becomes the run note, [`{{NOTE}}`](/docs/FormatSyntax/#note), for the +steps after it. A step that links to, opens, or runs Templater on `{{NOTE}}` +works on that note. When a step stops the run, the steps after it do not run. + ## When a macro stops {#macro-execution-control} ### What stops a macro {#automatic-abort-behavior} @@ -468,7 +514,7 @@ Descriptive names keep a macro readable: Break a complex macro into smaller, reusable parts: - Put distinct operations in separate scripts. -- Reuse existing choices with **Nested Choice** commands. +- Reuse existing choices with **Run a choice** steps. - Keep each script focused on a single purpose. ## Troubleshooting {#troubleshooting} diff --git a/docs/src/content/docs/docs/Choices/MultiChoice.md b/docs/src/content/docs/docs/Choices/MultiChoice.md index a07daca0b..dce39dc4e 100644 --- a/docs/src/content/docs/docs/Choices/MultiChoice.md +++ b/docs/src/content/docs/docs/Choices/MultiChoice.md @@ -8,14 +8,15 @@ A Multi is a **folder for your other choices**. Group related choices under one entry in the QuickAdd picker, then open it to see what's inside - handy once your picker grows past a handful of items. In the settings list, a Multi is the entry you can fold and unfold. Create one with **New folder** in **Settings → -QuickAdd**. +QuickAdd**. Under its name, the settings list and the picker show how many +choices it holds. ![The QuickAdd choice list with a Journal folder unfolded, showing two choices nested inside it](../Images/choices/multi-choice-list.png) ## Put choices inside a multi {#add-choices} -To create a new choice inside a multi, unfold it and click its **Add choice** -link. To move an existing choice in, **drag it in**. Make sure the multi is +To create a new choice inside a multi, unfold it, click its **Add choice** +link, and pick a [preset](/docs/Choices/Presets/). To move an existing choice in, **drag it in**. Make sure the multi is unfolded (as in the screenshot above), then grab the drag handle (⠿) at the right end of the choice's row - it appears when you hover the row - and drop the choice onto the rows under the multi. When it works, the choice appears diff --git a/docs/src/content/docs/docs/Choices/NoteButtons.md b/docs/src/content/docs/docs/Choices/NoteButtons.md new file mode 100644 index 000000000..08c362090 --- /dev/null +++ b/docs/src/content/docs/docs/Choices/NoteButtons.md @@ -0,0 +1,89 @@ +--- +title: Buttons in notes +description: "Put buttons that run QuickAdd choices in any note with a quickadd code block: a toolbar on a dashboard note, a tap on your phone" +slug: docs/Choices/NoteButtons +--- + +A `quickadd` code block in a note shows as a row of buttons. Each button runs +a choice, the same as running it from the command palette. Put one at the top +of a dashboard or daily note and your most used choices are a click, or a tap +on your phone, away. Nothing else to install. + +````markdown +```quickadd +Log +Task +Meeting note +``` +```` + +Each button shows the choice's icon and name. Hover over it to see what the +choice does, the same line the settings list shows under its name. The buttons +show in reading view and in live preview; in source mode you see the block's +text. + +## The block {#the-block} + +Write one choice per line. Blank lines are skipped, and so is a line that +starts with `#`, so you can leave notes for yourself in the block: + +````markdown +```quickadd +# Morning +Log +Task +``` +```` + +A line names a choice by its name, exactly as it reads in **Settings → +QuickAdd**. When no choice has that exact name, a choice whose name differs +only in upper and lower case counts too. Choices inside folders count. + +## Names and ids {#names-and-ids} + +A name breaks when you rename the choice, and it can't tell two choices with +the same name apart. A line can name the choice by its id instead, which stays +the same through renames: + +````markdown +```quickadd +id: 2b60f8ae-56ce-4367-b16b-f9415e94a872 +``` +```` + +When a line doesn't find exactly one choice, its button is greyed out and says +why: `No choice named 'Log'` or `Several choices named 'Log'`. Rename the +choice back, fix the line, or switch it to the choice's id. + +Open notes follow your choices: rename a choice, add one, or delete one, and +the buttons in open notes update straight away. + +## Labels {#labels} + +To show something other than the choice's name, put a label after a pipe: + +````markdown +```quickadd +Log | Add to journal +id: 2b60f8ae-56ce-4367-b16b-f9415e94a872 | New meeting +``` +```` + +## Copy button block {#copy-button-block} + +You don't have to write the block yourself. In **Settings → QuickAdd**, +right-click a choice, or click its **More options** button, and pick **Copy +button block**. QuickAdd puts a block with a button for that choice on the +clipboard, ready to paste in a note. The block names the choice, or uses its +id when another choice has the same name. + +To make a row of several buttons, copy each and put their lines in one block. + +## On a phone {#on-a-phone} + +A button is a tap: it runs the choice and opens its prompts as the command +would. On a phone the buttons are taller, so they are easy to hit, and the row +wraps onto more lines when it doesn't fit the screen. + +While a choice runs, its button is greyed out, so a second tap doesn't start +it twice. It comes back when the run finishes or you cancel it. diff --git a/docs/src/content/docs/docs/Choices/Packages.md b/docs/src/content/docs/docs/Choices/Packages.md index cf77ebccd..9401e0490 100644 --- a/docs/src/content/docs/docs/Choices/Packages.md +++ b/docs/src/content/docs/docs/Choices/Packages.md @@ -28,6 +28,27 @@ If a referenced script is missing from your vault, the exporter finishes with a warning so you can locate or recreate the file before you share the package. ::: +## Browse recipes in the app {#browse-recipes} + +The [examples](/docs/Examples/) in these docs also ship inside QuickAdd as +recipes, so you can add one without leaving Obsidian: + +1. Open **Settings → QuickAdd** and click **New choice → Browse recipes…**. + **Browse recipes…** under **Packages** opens the same gallery, and so does + **or browse recipes** on an empty list. +2. Type in the filter to narrow the list. Each recipe says what it adds, for + example *2 choices, 2 templates*, and what it needs first. **Guide** opens + its page in these docs. +3. Click **Add**. A recipe with nothing to decide is added straight away. If it + bundles a script, or a choice or file it adds is already in your vault, + QuickAdd shows the [review](#review-what-a-package-can-do) a pasted package + gets; click **Add recipe** when you are done, or **Back** to leave it. +4. The recipe's card reads **Added** and lists what to do next, the same steps + as **After importing** on its page. + +Templates a recipe bundles land in your first QuickAdd template folder, as +they do when you import a package. + ## Install an example from the docs {#install-an-example} Every [example](/docs/Examples/) page has a **Get this workflow** card at the diff --git a/docs/src/content/docs/docs/Choices/Presets.md b/docs/src/content/docs/docs/Choices/Presets.md new file mode 100644 index 000000000..50f70f5aa --- /dev/null +++ b/docs/src/content/docs/docs/Choices/Presets.md @@ -0,0 +1,103 @@ +--- +title: Starting from a preset +description: "The New choice menu: pick what you want to happen, and QuickAdd creates a Capture, Template, or Macro choice that is already set up for it" +slug: docs/Choices/Presets +--- + +**New choice** in **Settings → QuickAdd** asks what you want to happen, not +which type of choice to make. Each entry is a preset: it creates a Capture, +Template, or Macro choice that is already set up for that job, and opens its +settings so you can adjust it. The menu groups the presets by outcome: +**Add to a note**, **Create a note**, and **Automate**. + +### Add to a note {#add-to-a-note} + +| Preset | Creates | What it starts with | +| --- | --- | --- | +| **Log with a timestamp** | Capture | Adds `- {{TIME}} {{VALUE}}` under `## Log` in today's daily note. Creates the note and the heading if they are missing. | +| **Add a task** | Capture | Adds a task under `## Tasks` in today's daily note. Creates the note and the heading if they are missing. | +| **Add to a note you pick** | Capture | Asks which note each time and writes at the bottom of it. | +| **Save the selection or clipboard** | Capture | Asks which note each time and writes the text you have selected at the bottom of it. With nothing selected, it asks for the text, so paste what you copied. | +| **Fill in a property** | Capture | Asks which property of the note you are in to set, then for its value. Adds the property if the note does not have it. | + +### Create a note {#create-a-note} + +| Preset | Creates | What it starts with | +| --- | --- | --- | +| **New note from a template** | Template | Asks for a title, then creates and opens the note. Set **Template** to the template to use. | +| **New note, linked from here** | Template | Creates the note, puts a link to it on a new line in the note you are in (if any), and opens it. | +| **New note of a type** | Template | Asks which template to use, which folder to put the note in, and its title, then creates the note and opens it. The templates offered are the notes in your template folder: QuickAdd's first template folder, else the Templates core plugin's folder, else `Templates/`. | + +### Automate {#automate} + +| Preset | Creates | What it starts with | +| --- | --- | --- | +| **Run a script** | Macro | One script step with no file yet. Click **Choose file** on it to pick the script. See [Add a user script command](/docs/Choices/MacroChoice/#add-a-user-script-command). | +| **Run a sequence of steps** | Macro | No steps. Add them in the Macro builder. | +| **Ask AI** | Macro | One [AI Assistant](/docs/AIAssistant/) step. Offered only while **Disable AI & online features** is off. | + +Below the groups, **Browse recipes…** opens the +[Recipes gallery](/docs/Choices/Packages/#browse-recipes), ready-made +workflows from these docs, and **Import a package…** opens the +[package import](/docs/Choices/Packages/#import-a-package). + +**New folder** is a separate button next to **New choice**. It adds a +[folder](/docs/Choices/MultiChoice/) for grouping choices. + +The new choice is named after the preset, for example `Log`, and uses the +preset's icon. Change both in its settings. It asks for all its inputs in one +[one-page form](/docs/Advanced/onePageInputs/), as do the choices from +[Your first choices](#first-run). A preset only fills in settings, +so everything it sets can be changed later. A choice made from +**Log with a timestamp** is an ordinary Capture choice, documented on the +[Capture](/docs/Choices/CaptureChoice/) page. + +To add a choice inside a folder, unfold the folder and click its **Add +choice** link. It offers the same presets, without the recipes and package +import. + +:::tip +Hold Alt (⌥ on macOS) while you pick a preset to add the choice without +opening its settings. +::: + +## Your first choices {#first-run} + +When the list in **Settings → QuickAdd** is empty, it asks **What do you do in +Obsidian?** and offers five answers. Click the ones that fit, then click +**Create choices**. The button counts the choices it will add. + +| Answer | Adds | +| --- | --- | +| **Keep a daily journal** | **Log** adds `- {{TIME}} {{VALUE}}` under `## Log`, and **Thought** adds `- {{VALUE}}` under `## Thoughts`, in today's daily note. | +| **Track tasks** | **Task** adds a task under `## Tasks` in today's daily note. With the Tasks plugin on, it also asks for an optional due date and writes it as `📅 2026-06-14`. | +| **Meeting and people notes** | **Meeting note** creates `Meetings/{{DATE}} {{VALUE:Topic}}` from a meeting template and opens it. | +| **Collect reading and ideas** | **Inbox** adds a line at the bottom of `Inbox.md`, and **Save link** adds a task at the bottom of `Reading list.md`. | +| **Run projects** | **Project** creates `Projects/{{VALUE:Name}}` from a project template, links it on a new line in the note you are in, and opens it. | + +The choices follow your vault: + +- **Daily notes.** Without the Daily notes core plugin or Periodic Notes, Log, + Thought, and Task write to `Journal/{{DATE:YYYY-MM-DD}}.md` instead of + today's daily note. +- **Templates.** Meeting note and Project use a note in your template folder + whose name contains "meeting" or "project". The template folder is + QuickAdd's first template folder, else the Templates core plugin's folder, + else `Templates/`. When there is no such note, QuickAdd creates `Meeting.md` + or `Project.md` there, and the answer's card says *Adds a Meeting template*. +- **Missing notes and headings** are created on the first run. QuickAdd never + overwrites a note that exists. + +Each one is an ordinary choice; change it in its settings like any other. +**or browse recipes** under **Create choices** opens the +[Recipes gallery](/docs/Choices/Packages/#browse-recipes) instead, and the +quieter **New choice** and **New folder** buttons start from scratch. + +## The summary line {#summary} + +Every choice shows one line under its name that says what it does, for +example *Adds a line under ## Log in today's daily note*. The line appears in +the settings list and in the launcher. Placeholders appear as short names in +braces, so an **Add to a note you pick** choice that captures to `Journal/{{DATE}}.md` +reads *Adds a line at the bottom of Journal/{date}*. +A folder shows how many choices it holds instead. diff --git a/docs/src/content/docs/docs/Choices/TemplateChoice.md b/docs/src/content/docs/docs/Choices/TemplateChoice.md index cf75ace5a..a052f57b5 100644 --- a/docs/src/content/docs/docs/Choices/TemplateChoice.md +++ b/docs/src/content/docs/docs/Choices/TemplateChoice.md @@ -16,7 +16,7 @@ templates come from the Templater plugin, see [Coming from Templater](/docs/ComingFromTemplater/) for the QuickAdd-native way to do each familiar job. -![The QuickAdd Template builder page, showing the Name field and the Template, Location, Linking, and Behavior sections](../Images/choices/template-builder.png) +![The QuickAdd Template builder page: the Name field, the line that says what the choice does, the Template, Folder, and File name fields, Inputs, Steps, and More settings](../Images/choices/template-builder.png) ## Set up your first template choice {#set-up} @@ -32,16 +32,17 @@ to do each familiar job. Started {{DATE}} ``` -2. Open **Settings → QuickAdd** and choose **New choice → Template**. +2. Open **Settings → QuickAdd** and choose **New choice → New note from a + template**. 3. The choice's settings open as a page of the settings window. Set **Name** to `New book note`. (Before QuickAdd 2.30.0, they open in a dialog; click the name at the top to rename it.) -4. Set **Template path** to `Templates/Book.md`. -5. In **File name**, enter `{{VALUE:title}}`. (Before QuickAdd 2.30.0, this is **File name format**; turn its toggle on first.) -6. Set **New note location** to **In a specific folder**. Enter `Books` in - **Folder path** and click **Add**. -7. Turn **Open** on. Set **File opening location** to **Reuse current tab** - and **View mode** to **Live Preview**. +4. Set **Template** to `Templates/Book.md`. (In earlier versions, this is + **Template path**.) +5. In **Note name**, enter `{{VALUE:title}}`. (Before QuickAdd 2.30.0, this is **File name format**; turn its toggle on first.) +6. In **Folder**, enter `Books`. +7. Click **More settings**. Turn **Open** on. Set **Opening location** to + **Reuse current tab** and **View mode** to **Live Preview**. 8. Close Settings. Leaving the page saves it. (Before QuickAdd 2.30.0, choose **Done** first.) 9. Run **QuickAdd: Run** from the command palette and pick `New book note`. @@ -94,15 +95,53 @@ Template choice (below) when you need a fixed location, file-name format, linking, or a hotkey. ::: -The builder groups a Template choice's settings into four sections: -**Template** (template path and file name format), **Location** (where the file -is created), **Linking** (whether and how to link to the created file), and -**Behavior** (what happens when the file already exists, and how the file is -opened). +## The builder page {#builder} -## Point to the template file: Template path {#mandatory} +The page starts with one line that says what the choice does, for example +*Creates Books/{title} from Book, opens it*. It changes as you change the +settings below it. -**Template path** is the one required setting: the path to the template you want +Under it are the settings most template choices need: + +- **Template** - the template file the note is made from. +- **Folder** - the folder the note is created in. Leave it empty to use + Obsidian's "Default location for new notes". +- **Note name** - the new note's name. Leave it empty to ask for the title. + +When the template file uses Templater (it holds a `<%` tag), a line under +**Template** says *Templater runs after the note is created*, the opening line +ends with *runs Templater*, and Templater's own prompts are listed in +[Inputs](#inputs). If Templater isn't installed, the line says so instead. See +[Using Templater with QuickAdd 3](/docs/ComingFromTemplater/#templater-in-quickadd-3). + +Then come [Inputs](#inputs) and [Steps](#steps). Everything else is behind +**More settings** at the bottom: the other places a note can go, what happens +when the note already exists, searching existing notes first, linking, +opening the note, which day `{{DATE}}` is about, the command palette, the +ribbon, and the icon. **More settings** opens by itself when one of those is +changed from what a new choice has, so a choice you set up shows what you set. +Once you open it, it stays open for that choice until Obsidian restarts. + +### Make a template from the builder: New template… {#new-template} + +When there are no template files yet, or **Template** is empty, **New +template…** shows next to it. It asks for a name and creates a Markdown file +with that name in your first [template folder](/docs/Settings/#template-folders), +in the folder of Obsidian's Templates core plugin when you have none, or else +in `Templates`. The new template starts with a heading the note's title fills +in: + +```markdown title="Templates/Meeting.md" +# {{VALUE:Title}} +``` + +QuickAdd puts its path in **Template** and opens it in a new tab behind +Settings, ready to write once you close them. If a file with that name already +exists, QuickAdd leaves it alone and creates nothing. + +## Point to the template file: Template {#mandatory} + +**Template** is the one required setting: the path to the template you want to insert. Paths are vault-relative; a leading `/` is ignored. ```text title="Template path" @@ -112,7 +151,7 @@ Templates/Book.md QuickAdd supports markdown (`.md`), canvas (`.canvas`), and base (`.base`) templates. The created file uses the same extension as the template. If you want a new markdown note to include a live embedded Base dashboard, see -[Template: Create an MOC Note with a Link Dashboard](/docs/Examples/Template_CreateMOCNoteWithLinkDashboard/). +[Template: Create an MOC note with a link dashboard](/docs/Examples/Template_CreateMOCNoteWithLinkDashboard/). ### Use a dynamic template path {#dynamic-template-path} @@ -152,7 +191,7 @@ up-front form. ## Name the new note: File name {#optional} -**File name** sets a format for the created file's name, using +**Note name** sets a format for the created file's name, using [format syntax](/docs/FormatSyntax/) - so file names can be dynamic too. ```text title="You configure" @@ -163,7 +202,7 @@ up-front form. £ 2021-06-12 Manually-Written-File-Name ``` -`{{NAME}}` is a value you enter when invoking the template. Leave **File name** +`{{NAME}}` is a value you enter when invoking the template. Leave **Note name** empty and QuickAdd asks for the note title when you run the choice, the same as writing `{{VALUE}}`. Before QuickAdd 2.30.0, the field is **File name format**, with a toggle that hides it while off; off asks for the note title. @@ -212,7 +251,7 @@ requires a Markdown template. The picker names the action beside each existing note, so an update is visible before you select it. -The selected note keeps its path and name. QuickAdd skips **File name**, +The selected note keeps its path and name. QuickAdd skips **Note name**, **New note location**, and the new-note collision setting. `{{TITLE}}` and the anonymous `{{VALUE}}` use the selected note's basename, and `{{FOLDER}}` uses its folder. The template's other inputs still appear, including in the @@ -260,8 +299,9 @@ if you want the picker. ## Decide where the note is created: New note location {#new-note-location} -**New note location** is a dropdown that controls where the note is created. -Pick one of four modes: +**Folder** covers the two common cases: a folder you type, or Obsidian's +default location when it is empty. For anything else, use **New note +location** under **More settings**, a dropdown with four modes: - **Obsidian default** - use Obsidian's "Default location for new notes" setting. - **In a specific folder** - create the note in the folder(s) you configure @@ -271,7 +311,7 @@ Pick one of four modes: toggle (shown only in this mode) lets the suggester offer the selected folders *and* their subfolders. In QuickAdd 2.30.0 or later, a folder you typed but didn't **Add** is added when you close the builder; earlier versions drop it. -- **Same folder as current file** - create the note next to the currently active +- **Same folder as current note** - create the note next to the currently active file (falls back to the vault root if no file is open). - **Ask for folder each time** - prompt you to pick any folder in the vault each time the choice runs. @@ -289,9 +329,9 @@ Projects/{{VALUE:client}}/{{DATE:YYYY}} This prompts for a client and creates the file under that client's folder for the current year. -## Link to the new note: Link to created file {#link-to-created-file} +## Link to the new note: Link to created note {#link-to-created-file} -**Link to created file** controls whether QuickAdd inserts a link to the note it +**Link to created note** controls whether QuickAdd inserts a link to the note it just created - handy for leaving a trail in the note you were in. Three modes: - **Enabled (strict)** - require the configured link destination to be available @@ -377,7 +417,7 @@ links get `[Meeting with Mark](20240101%20Meeting%20with%20Mark.md)`. ### Copy a link to the clipboard {#copy-link-to-clipboard} **Copy link to clipboard** copies a link to the created file after the Template -choice runs. This works separately from **Link to created file**, so you can copy +choice runs. This works separately from **Link to created note**, so you can copy the link without inserting it into the current note, or do both. The copied link is a vault-path wikilink, ready to paste into another note. @@ -386,7 +426,7 @@ is a vault-path wikilink, ready to paste into another note. **Open** opens the created file. When enabled, additional file-opening controls appear (these are shared with the Capture choice): -- **File opening location** - where to open the file: **Reuse current tab**, +- **Opening location** - where to open the file: **Reuse current tab**, **New tab**, **Split pane**, **New window**, **Left sidebar**, or **Right sidebar**. - **Split direction** - shown only when the location is **Split pane**. Arrange @@ -416,9 +456,15 @@ more command that asks which day before it runs, so you can have one hotkey for today's note and another for any other day, from the same choice. Your main hotkey keeps using Which day. +## Put it in the ribbon: Show in ribbon {#show-in-ribbon} + +**Show in ribbon** adds an icon to Obsidian's ribbon that runs the template +choice. The icon and its tooltip are the choice's icon and name. The setting +saves as soon as you flip it. A choice nested inside a macro doesn't have it. To put a button that runs it in a note instead, see [Buttons in notes](/docs/Choices/NoteButtons/). + ## When the note already exists {#file-already-exists-behavior} -**If the target file already exists** decides what QuickAdd does when a note with +**If the note already exists** decides what QuickAdd does when a note with the target name is already there. The setting works in two steps: first pick a high-level behavior, then a follow-up field appears for the two behaviors that need a detail. @@ -427,10 +473,10 @@ With **Search existing notes before creating** enabled, this setting is called **If a new note's path already exists**. It applies to new-note creation. [Selecting an existing match](#search-existing) has its own action. -- **If the target file already exists** - choose **Ask every time**, **Update - existing file**, **Create another file**, or **Keep existing file**. -- **Update action** - shown only when you choose **Update existing file**. -- **New file naming** - shown only when you choose **Create another file**. +- **If the note already exists** - choose **Ask every time**, **Update + existing note**, **Create another note**, or **Keep existing note**. +- **Update action** - shown only when you choose **Update existing note**. +- **New note naming** - shown only when you choose **Create another note**. ### Let QuickAdd ask each time {#ask-every-time} @@ -439,7 +485,7 @@ already exists: - **Append to bottom** - **Append to top** -- **Overwrite file** +- **Overwrite note** - **Increment trailing number** - **Append duplicate suffix** - **Do nothing** @@ -453,7 +499,7 @@ These options modify the existing markdown, canvas, or base file: the note ends. An empty note gets the template with no blank line above it. - **Append to top** - adds the template content to the beginning of the existing file. -- **Overwrite file** - replaces the existing file content with the template. +- **Overwrite note** - replaces the existing file content with the template. :::note For markdown files, **Append to bottom** and **Append to top** handle template @@ -479,8 +525,64 @@ These options keep the existing file untouched and create a new file instead: ### Keep the existing note {#keep-existing-file} -Selecting **Keep existing file** applies the same result as choosing **Do +Selecting **Keep existing note** applies the same result as choosing **Do nothing** from the prompt: - **Do nothing** - leaves the existing file unchanged and opens it automatically. This does not require the separate **Open** setting. + +## See what it asks for: Inputs {#inputs} + +The **Inputs** group, above **Steps**, lists what the template choice asks for +when it runs, in the order it first appears: in the file name, then in the +folders, then in the template file. Each row shows the input's name, its kind +(*value*, *date*, *field*, *file*, or *math*), and where it is defined. With no +file name format, QuickAdd asks for the note's title, which is listed as the +*value* defined in the file name. + +Two controls change how a value, date, or file input is asked for, without +editing the placeholder: + +- **Label** - the title of its prompt, and of its field in the one-page form. + Leave it empty to keep the placeholder's own, shown greyed out in the field. +- **Optional** - whether you can leave it empty. It starts as the placeholder + says, with `|optional` or without. + +Both save as soon as you change them. A run that is given the value up front, +from the CLI or a URI, isn't affected. Rename the placeholder and the input +asks as the placeholder says again. + +An input from the template file reads *Defined in* and the file's name. Click +the name to open the file, and change the placeholder there. + +A Templater prompt in the template file, `tp.system.prompt("Guest")` or +`tp.system.suggester(...)`, is listed after the file's own inputs and reads +*Asked by Templater, in* and the file's name. It has no controls: Templater +asks it when it runs, after QuickAdd's prompts, and it isn't part of the +one-page form. + +A template choice nested inside a macro lists its inputs without the controls. + +## Do more afterwards: Add a step {#steps} + +The last group in the builder, **Steps**, lists what the template choice does, +one line per step, for example *Creates {title}*, *Links it on a new line +here*, and *Opens it*. The list follows the settings as you change them. + +**Add a step** adds something to do after the template choice: + +- **Run a script** - a script step with no file yet. Click **Choose file** on + it to pick the script. +- **Open a note** - an **Open File** step. Set the note in its settings. +- **Link it** - links the note on a new line in the current note. +- **Run Templater** - runs Templater on the note. +- **Wait** - a pause of 100 ms. + +Adding a step turns the choice into a [macro](/docs/Choices/MacroChoice/). +QuickAdd saves the template choice, makes it the macro's first step, adds the +new step after it, and opens the macro builder. The +choice keeps its name, its command, and its hotkey. To change the template +choice's settings later, use the gear on its step in the macro. + +A template choice that is already a step inside a macro lists its steps but has +no **Add a step** button. diff --git a/docs/src/content/docs/docs/ComingFromTemplater.md b/docs/src/content/docs/docs/ComingFromTemplater.md index 355e08e09..f718094b8 100644 --- a/docs/src/content/docs/docs/ComingFromTemplater.md +++ b/docs/src/content/docs/docs/ComingFromTemplater.md @@ -78,10 +78,10 @@ A [Capture choice](/docs/Choices/CaptureChoice/) whose format is `{{TEMPLATE:Tem Appending to today's note is a Capture choice that targets the daily note - the file doesn't have to exist beforehand: -- **Capture to**: [`{{DAILY}}`](/docs/FormatSyntax/#daily), the note **Open today's daily note** opens. Click **Daily note** next to the field to fill it in (QuickAdd 2.30.0 or later). -- **Create file if it doesn't exist**, with **Create file with a template** set to your daily template, so QuickAdd fills in its tokens +- **Where**: [`{{DAILY}}`](/docs/FormatSyntax/#daily), the note **Open today's daily note** opens. Click **Daily note** next to the field to fill it in (QuickAdd 2.30.0 or later). +- **Create note if it doesn't exist**, with **Create note with a template** set to your daily template, so QuickAdd fills in its tokens - **Insert after**: `## Log`, with **Create line if not found** -- **Capture format**: `- {{VALUE}}` +- **What**: `- {{VALUE}}` Say today is 2026-07-06 and your daily notes live in `Daily`: running it and typing `did a thing` creates `Daily/2026-07-06.md` from the template on first capture and appends `- did a thing` under `## Log` - one hotkey, with or without an existing note. Before QuickAdd 2.30.0, set **Capture to** to a date-formatted path such as `Daily/{{DATE}}.md` instead. For a step-by-step walkthrough with variations, see [Capture: Add entries to your daily note](/docs/Examples/Capture_ToDailyNote/); [Capture choices](/docs/Choices/CaptureChoice/) covers every target and position option. @@ -142,6 +142,29 @@ QuickAdd runs JavaScript in two shapes: `{{MACRO:My macro}}` embeds a macro's return value anywhere format syntax is accepted, so a computed value can flow straight into a file name, template body, or capture line. +## Using Templater with QuickAdd 3 {#templater-in-quickadd-3} + +You don't have to port a template to use it. A Template choice can point at a +template file with Templater tags: QuickAdd fills in its own tokens and creates +the note, then Templater runs the `<% %>` tags in it. The builder says so: + +- **The Template field.** When the template file holds a `<%` tag, a line under + **Template** reads *Templater runs after the note is created*, and the line at + the top of the page ends with *runs Templater*. +- **Inputs.** The questions Templater will ask are listed in + [Inputs](/docs/Choices/TemplateChoice/#inputs), after QuickAdd's own, as + *Asked by Templater, in* the template's name: `tp.system.prompt("Guest")` is + listed as *Guest*, and `tp.system.suggester(["Happy", "Sad"], ...)` as *Happy, + Sad* (a suggester built from variables reads *a choice*). They have no Label + or Optional controls, and the [one-page form](/docs/Advanced/onePageInputs/) + doesn't include them: Templater asks them itself when it runs. +- **When Templater isn't installed.** The line under **Template** says *This + template uses Templater, which is not installed*, with a link to Templater in + Community plugins. Without it, the tags stay in the note as text. + +A prompt you answer in both engines is a [double prompt](#common-migration-snags): +move it to `{{VALUE:name}}` when QuickAdd should own it. + ## Common migration snags These are the classic symptoms of splitting one template between two engines - each has a QuickAdd-native fix: @@ -149,4 +172,4 @@ These are the classic symptoms of splitting one template between two engines - e - **You get prompted twice.** QuickAdd resolves all of its prompts before the file is created. If another engine prompts in the same template, you answer twice - once per engine. Let QuickAdd own the prompt with `{{VALUE:name}}` and reuse the answer everywhere it's needed. - **Template syntax shows up unrendered.** QuickAdd renders QuickAdd tokens; another engine's syntax is only rendered by that engine. If it isn't installed or doesn't run on the file, its markup stays behind as literal text. Port the line to the matching token from [the map](#the-quick-map). - **Templater code runs twice.** A macro that runs Templater's **Replace templates in the active file** right after a QuickAdd Template or Capture step runs the templates a second time: QuickAdd already ran them when it wrote the note. That step is deprecated, and QuickAdd 2.30.0 or later shows a notice once per session when a macro runs it. Remove it from the macro. -- **Capturing into a note throws template errors.** A note that keeps live template syntax can re-execute or error whenever a plugin processes the file again. QuickAdd tokens like `{{DATE:YYYY-MM-DD}}` render once, at creation, into plain text - later captures find nothing to re-run. Migrate the offending line to a QuickAdd token and let QuickAdd create the note so the token renders - a Capture with **Create file if it doesn't exist** plus that template does both (see [Today's daily note](#todays-daily-note)). +- **Capturing into a note throws template errors.** A note that keeps live template syntax can re-execute or error whenever a plugin processes the file again. QuickAdd tokens like `{{DATE:YYYY-MM-DD}}` render once, at creation, into plain text - later captures find nothing to re-run. Migrate the offending line to a QuickAdd token and let QuickAdd create the note so the token renders - a Capture with **Create note if it doesn't exist** plus that template does both (see [Today's daily note](#todays-daily-note)). diff --git a/docs/src/content/docs/docs/ControllingPrompts.md b/docs/src/content/docs/docs/ControllingPrompts.md index e4b1dd0f5..3907cf35a 100644 --- a/docs/src/content/docs/docs/ControllingPrompts.md +++ b/docs/src/content/docs/docs/ControllingPrompts.md @@ -95,7 +95,7 @@ Skipping is an answer; pressing **Esc** still cancels the whole choice. If the s | Multi-line input | `Ctrl/Cmd+Enter` (`Enter` inserts a newline) | `Tab` indents; `Shift+Tab` moves focus out | | Pick list / suggester | `Enter` picks the highlighted option | | | Math prompt ([`{{MVALUE}}`](/docs/FormatSyntax/#mvalue)) | `Ctrl/Cmd+Enter` | `Tab` jumps to the cursor marker | -| One-page input form | `Ctrl/Cmd+Enter` | `Tab` moves between fields | +| One-page input form | `Enter` in a one-line field, `Ctrl/Cmd+Enter` in any field | `Tab` moves between fields | | Any optional prompt | | `Ctrl/Cmd+Shift+Enter` skips | `Esc` cancels the prompt and with it the whole run - nothing is created or captured by the cancelled choice. (In a macro, steps that already ran are not undone.) To get a notice when that happens, enable **Show input cancellation notifications** under [Settings → QuickAdd → Advanced](/docs/Settings/#advanced-notifications) (QuickAdd 2.30.0 or later; earlier versions show it on the main QuickAdd tab). @@ -120,9 +120,9 @@ These triggers work in single-line and multi-line prompts, and in text and texta ## One form instead of many prompts {#one-form-instead-of-many-prompts} -Rather than answering prompts one at a time, QuickAdd can collect everything in a single form before the choice runs. Every unanswered variable appears as the right widget - text, textarea, date with a calendar, dropdown, slider - with optional fields badged, and Template choices with a file name format get a live file name preview. +Rather than answering prompts one at a time, QuickAdd can collect everything in a single form before the choice runs. Every unanswered variable appears as the right widget - text, textarea, date with a calendar, dropdown, slider - with optional fields badged. Above them, the form shows [where the run lands](/docs/Advanced/onePageInputs/#preview): the note a Template creates, or the note and heading a Capture adds to, and the dates you typed. -- Turn it on for everything with **One-page input for choices** under [Settings → Input](/docs/Settings/#input). +- Choices made from a [preset](/docs/Choices/Presets/) use it from the start. Turn it on for everything with **One-page input for choices** under [Settings → Input](/docs/Settings/#input). - Template and Capture choices each have a **One-page input override** dropdown in their builder (**Follow global setting**, **Always**, **Never**), so you can flip the form on or off for one choice. - A few inputs still run as follow-up steps after the form, such as [`{{FIELD:...|multi}}`](/docs/FormatSyntax/#field-multi) pickers and Capture's insert-after heading picker. - Cancelling the form cancels the whole run, exactly like cancelling a sequential prompt. diff --git a/docs/src/content/docs/docs/Examples/Capture_AddJournalEntry.md b/docs/src/content/docs/docs/Examples/Capture_AddJournalEntry.md index c4b9f45c5..dfd7e3e0a 100644 --- a/docs/src/content/docs/docs/Examples/Capture_AddJournalEntry.md +++ b/docs/src/content/docs/docs/Examples/Capture_AddJournalEntry.md @@ -15,8 +15,8 @@ For reference, the journal entry capture in compact form: | Setting | Value | | --- | --- | | Capture to | `{{DAILY}}` (click **Daily note** next to the field) | -| Create file if it doesn't exist | On | -| Write position | **After line...** | +| Create note if it doesn't exist | On | +| Position | **After line...** | | Insert after | `## What did I do today?` | | Capture format | `- {{DATE:HH:mm}} {{VALUE}}` | diff --git a/docs/src/content/docs/docs/Examples/Capture_AddTaskToKanbanBoard.md b/docs/src/content/docs/docs/Examples/Capture_AddTaskToKanbanBoard.md index ee9a6e968..e49541c67 100644 --- a/docs/src/content/docs/docs/Examples/Capture_AddTaskToKanbanBoard.md +++ b/docs/src/content/docs/docs/Examples/Capture_AddTaskToKanbanBoard.md @@ -1,5 +1,5 @@ --- -title: "Capture: Add a Task to a Kanban Board" +title: "Capture: Add a task to a Kanban board" description: Add a task to a chosen lane on an Obsidian Kanban board by capturing after the lane heading, with optional date formatting slug: docs/Examples/Capture_AddTaskToKanbanBoard package: kanban-task @@ -16,19 +16,19 @@ You end up with one QuickAdd command that drops whatever you type onto a Kanban Imported the package above? Follow **After importing** in the card, then skip the manual setup below and read [What you get](#what-you-get). -1. In **Settings → QuickAdd**, click **New choice** → **Capture**. The Capture builder opens; click its name at the top to rename it (for example, `Add to board`). -2. Set **Capture to** to your Kanban board file. +1. In **Settings → QuickAdd**, click **New choice** → **Add to a note**. The Capture builder opens; set **Name** (for example, `Add to board`). +2. Set **Where** to your Kanban board file. 3. Enable the **Task** toggle (in the **Content** section). This wraps your text in `- [ ]` so Kanban reads it as a card. -4. Set **Write position** to **After line…**. +4. Set **Position** to **After line…**. 5. In the **Insert after** field that appears, write `## ` followed by the name of the lane you want to add the card to. For a lane called `Backlog`, that is `## Backlog`. ## What you get -You run the choice, type `Buy milk`, and QuickAdd adds `- [ ] Buy milk` as a new card at the top of the `Backlog` lane. +You run the choice, type `Buy milk`, and QuickAdd adds `- [ ] Buy milk` as a new card at the end of the `Backlog` lane. ## Add a date to the card -Kanban recognizes a date written as `@{YYYY-MM-DD}` on a card. Set **Capture format** to add one (before QuickAdd 2.30.0, turn on the **Capture format** toggle first): +Kanban recognizes a date written as `@{YYYY-MM-DD}` on a card. Set **What** to add one (before QuickAdd 2.30.0, turn on the **Capture format** toggle first): - Use today's date automatically: diff --git a/docs/src/content/docs/docs/Examples/Capture_CanvasCapture.md b/docs/src/content/docs/docs/Examples/Capture_CanvasCapture.md index 8c97b05a1..dc5e2877d 100644 --- a/docs/src/content/docs/docs/Examples/Capture_CanvasCapture.md +++ b/docs/src/content/docs/docs/Examples/Capture_CanvasCapture.md @@ -1,5 +1,5 @@ --- -title: "Capture: Canvas Capture" +title: "Capture: Canvas capture" description: Capture formatted text into a selected Canvas card or a specific node in a .canvas file, with supported write positions and linking slug: docs/Examples/Capture_CanvasCapture package: canvas-capture @@ -29,12 +29,12 @@ Good fits: Imported the package above? Follow **After importing** in the card; the steps below build the same choice by hand. -1. Create a Capture choice. -2. Enable **Capture to active file**. +1. Create a Capture choice: **New choice** → **Add to a note**. +2. Enable **Capture to active note**. 3. Open a Canvas file. 4. Select exactly one supported card. -5. Set **Write position** to **Top of file (after frontmatter)**, - **Bottom of file**, or **After line…** / **Before line…**. +5. Set **Position** to **Top of note (after frontmatter)**, + **Bottom of note**, or **After line…** / **Before line…**. 6. Run the Capture choice. ![Selecting the Contact flow card on a Website brainstorm canvas, running QuickAdd: Run, and picking an Add idea to card Capture that has Capture to active file on, Write position set to Bottom of file, and the format "- {{VALUE}}". After typing "Add a map with the studio address", the text appears as a new bullet at the bottom of the selected card](../Images/examples/canvas-capture-selected-card.gif) @@ -49,9 +49,9 @@ selected, or the selected card is unsupported. ## Capture to a specific card -1. Create a Capture choice. -2. Turn off **Capture to active file**. -3. Set **Capture to** to a `.canvas` file. +1. Create a Capture choice: **New choice** → **Add to a note**. +2. Turn off **Capture to active note**. +3. Set **Where** to a `.canvas` file. 4. Choose **Target canvas node**. 5. Pick the card you want QuickAdd to write to. 6. Set a supported write position. @@ -63,9 +63,9 @@ to the same Canvas card. Canvas capture supports these write positions: -- **Top of file** (shown as **Top of file (after frontmatter)** when - **Capture to active file** is enabled) -- **Bottom of file** +- **Top of note** (shown as **Top of note (after frontmatter)** when + **Capture to active note** is enabled) +- **Bottom of note** - **After line…** - **Before line…** @@ -75,12 +75,12 @@ Canvas capture does not support cursor-based write positions: - **New line above cursor** - **New line below cursor** -If **Capture to active file** is enabled and the write position is still +If **Capture to active note** is enabled and the write position is still **At cursor**, QuickAdd aborts instead of writing to the wrong place. ## Link-to-captured-file behavior -When **Link to captured file** is set to **Enabled (strict)** and +When **Link to captured note** is set to **Enabled (strict)** and capture runs from a Canvas card without a focused Markdown editor, the capture still writes. QuickAdd skips link insertion because there is no active Markdown file to link from. @@ -92,10 +92,10 @@ file to link from. | Capture aborts before writing | No card or multiple cards are selected | Select exactly one supported card | | Capture aborts with cursor-position wording | The write mode is cursor-based | Use top, bottom, after-line, or before-line placement | | Nothing is written to a file card | The file card points to a non-Markdown file | Use a Markdown file card or a text card | -| The target picker is not shown | Capture target is not a `.canvas` file | Set **Capture to** to the Canvas file path | +| The target picker is not shown | Capture target is not a `.canvas` file | Set **Where** to the Canvas file path | ## Related docs - [Capture Choices](/docs/Choices/CaptureChoice/) - [Format Syntax](/docs/FormatSyntax/) -- [Template: Create an MOC Note with a Link Dashboard](/docs/Examples/Template_CreateMOCNoteWithLinkDashboard/) +- [Template: Create an MOC note with a link dashboard](/docs/Examples/Template_CreateMOCNoteWithLinkDashboard/) diff --git a/docs/src/content/docs/docs/Examples/Capture_FetchTasksFromTodoist.md b/docs/src/content/docs/docs/Examples/Capture_FetchTasksFromTodoist.md index 3b48f4128..a2bfedf62 100644 --- a/docs/src/content/docs/docs/Examples/Capture_FetchTasksFromTodoist.md +++ b/docs/src/content/docs/docs/Examples/Capture_FetchTasksFromTodoist.md @@ -1,5 +1,5 @@ --- -title: "Capture: Fetch Tasks From Todoist" +title: "Capture: Fetch tasks from Todoist" description: Import Todoist tasks into a note using a macro and user script, selecting from all tasks, a project, or a single section slug: docs/Examples/Capture_FetchTasksFromTodoist package: todoist-tasks @@ -32,12 +32,12 @@ By default, the script completes every task it imports, so the same task isn't i Imported the package above? The script, the macro, and the Capture choice are already in place. Open the **Todoist** macro and follow step 3 to save your token and decide whether imported tasks are completed. 1. Save the Todoist Script to your vault, for example as `scripts/todoistTaskSync.js`. -2. In **Settings → QuickAdd**, add a [Macro choice](/docs/Choices/MacroChoice/) named `Todoist`, and add the script to its command list. Add it by its file name (`todoistTaskSync`) to pick an export when the macro runs, or append an export (`todoistTaskSync::GetAllTasksFromProject`) to always run that one. Either way, the script's settings apply. +2. In **Settings → QuickAdd**, click **New choice** → **Run a sequence of steps** to add a [Macro choice](/docs/Choices/MacroChoice/). Name it `Todoist`, and add the script to its command list. Add it by its file name (`todoistTaskSync`) to pick an export when the macro runs, or append an export (`todoistTaskSync::GetAllTasksFromProject`) to always run that one. Either way, the script's settings apply. 3. Click the gear (⚙️) next to the script command, paste your Todoist API token into **Todoist API token**, and click the save icon next to it. QuickAdd keeps it in Obsidian's secret storage, not in `data.json`. Leave **Complete imported tasks in Todoist** ticked, or untick it to leave tasks open in Todoist. ![Todoist script settings](../Images/Todoist-ScriptSettings.png) -4. Add a [Capture choice](/docs/Choices/CaptureChoice/) with these settings: +4. Add a [Capture choice](/docs/Choices/CaptureChoice/) with **New choice** → **Add to a note**, and give it these settings: - _Capture to:_ the path to the file where you want to store the tasks. - _Capture format:_ Enabled - and in the format, write `{{MACRO:Todoist}}` to be asked which export to run, or `{{MACRO:Todoist::GetAllTasksFromProject}}` (or any of the other exports) to run that one directly. diff --git a/docs/src/content/docs/docs/Examples/Capture_InsertBaseTemplateIntoActiveFile.md b/docs/src/content/docs/docs/Examples/Capture_InsertBaseTemplateIntoActiveFile.md index e49d32b51..e67b6bf7a 100644 --- a/docs/src/content/docs/docs/Examples/Capture_InsertBaseTemplateIntoActiveFile.md +++ b/docs/src/content/docs/docs/Examples/Capture_InsertBaseTemplateIntoActiveFile.md @@ -1,5 +1,5 @@ --- -title: "Capture: Insert a Related Notes Base into an MOC Note" +title: "Capture: Insert a related notes Base into an MOC note" description: Insert a live Base view of related notes into an active MOC note by capturing from a .base template into the current file slug: docs/Examples/Capture_InsertBaseTemplateIntoActiveFile package: moc-related-notes @@ -38,10 +38,10 @@ views: name: Related notes ``` -2. Create a Capture choice. -3. Enable **Capture to active file**. -4. Set **Write position** to **Top of file (after frontmatter)**. -5. (Before QuickAdd 2.30.0, turn on the **Capture format** toggle first.) In **Capture format**, reference your `.base` template with an explicit file +2. Create a Capture choice: **New choice** → **Add to a note**. +3. Enable **Capture to active note**. +4. Set **Position** to **Top of note (after frontmatter)**. +5. (Before QuickAdd 2.30.0, turn on the **Capture format** toggle first.) In **What**, reference your `.base` template with an explicit file extension: Example: diff --git a/docs/src/content/docs/docs/Examples/Capture_ToDailyNote.md b/docs/src/content/docs/docs/Examples/Capture_ToDailyNote.md index ce8fa8cb3..23f537184 100644 --- a/docs/src/content/docs/docs/Examples/Capture_ToDailyNote.md +++ b/docs/src/content/docs/docs/Examples/Capture_ToDailyNote.md @@ -7,7 +7,7 @@ package: daily-note-captures This cookbook gives you one QuickAdd choice that adds text to today's daily note - even when the note or the target heading doesn't exist yet. -Every recipe starts from the same base Capture choice; you only change the **Capture format** and the target heading. +Every recipe starts from the same base Capture choice; you only change the **What** and the target heading. ## Base setup @@ -15,16 +15,16 @@ The package above needs QuickAdd 2.30.0 or later. On an earlier version, the pac Imported the package above? Follow **After importing** in the card, then skip the base setup below. [Recipes](#recipes) explains what each imported capture does and how to add more. -1. In **Settings → QuickAdd**, click **New choice** → **Capture**. The Capture builder opens as a page of the settings window; set **Name** to `Daily entry`. (Before QuickAdd 2.30.0, the builder is a dialog; click its name at the top to rename it.) -2. Disable **Capture to active file**. -3. Click **Daily note** next to **Capture to** (QuickAdd 2.30.0 or later). It fills in `{{DAILY}}`, which uses the folder, date format, and template from Obsidian's **Daily notes** settings, or from Periodic Notes when it manages your daily notes. On earlier versions, type your daily-note path and date pattern instead, for example `Daily/{{DATE:YYYY-MM-DD}}.md`. -4. Make sure **Create file if it doesn't exist** is on. The **Daily note** button turns it on. On earlier versions, turn it on yourself; to start a new note from your daily-note template, also turn on **Create file with a template** and pick the template. -5. Set **Write position** to **After line...**. +1. In **Settings → QuickAdd**, click **New choice** → **Log with a timestamp**. The Capture builder opens as a page of the settings window; set **Name** to `Daily entry`. The preset already does steps 2 to 8 below, with `## Log` as the heading, so check them and change the heading to your own. (Before QuickAdd 2.30.0, the builder is a dialog; click its name at the top to rename it.) +2. Disable **Capture to active note**. +3. Click **Daily note** next to **Where** (QuickAdd 2.30.0 or later). It fills in `{{DAILY}}`, which uses the folder, date format, and template from Obsidian's **Daily notes** settings, or from Periodic Notes when it manages your daily notes. On earlier versions, type your daily-note path and date pattern instead, for example `Daily/{{DATE:YYYY-MM-DD}}.md`. +4. Make sure **Create note if it doesn't exist** is on. The **Daily note** button turns it on. On earlier versions, turn it on yourself; to start a new note from your daily-note template, also turn on **Create note with a template** and pick the template. +5. Set **Position** to **After line...**. 6. In the **Insert after** field, enter the heading you want entries placed under, for example `## Journal`. 7. Make sure **Insert at end of section** is on, so each capture appends at the bottom of the section. 8. Make sure **Create line if not found** is on, and set its placement to **Bottom**, so a note that lacks the heading gets it at the end instead of above its title. A new Capture starts with **Insert at end of section** and **Create line if not found** on in QuickAdd 2.30.0 or later; on earlier versions, turn them on. -9. Leave **Link to captured file** disabled. -10. Fill in **Capture format** with one of the recipes below. +9. Leave **Link to captured note** disabled. +10. Fill in **What** with one of the recipes below. ## Recipes @@ -106,7 +106,7 @@ Same format as the callout recipe but targeting a regular heading. Produces a bl ### Table row -Use this when the daily note already has a table under a heading and the table is the last block in that section. Keep **Write position** as **After line...**, set **Insert after** to the heading above the table, and keep **Insert at end of section** enabled. If more content follows the table in the same section, target the table separator row instead. +Use this when the daily note already has a table under a heading and the table is the last block in that section. Keep **Position** as **After line...**, set **Insert after** to the heading above the table, and keep **Insert at end of section** enabled. If more content follows the table in the same section, target the table separator row instead. **Capture format:** @@ -126,9 +126,9 @@ This keeps the row attached to the table: ### Tomorrow's daily note -With **Capture to** set to `{{DAILY}}`, set **Which day** to **Custom…**, one day forward. `{{DAILY}}` follows [Which day](/docs/Choices/TemplateChoice/#date-origin), so the capture targets tomorrow's note and creates it from your daily notes template. +With **Where** set to `{{DAILY}}`, set **Which day** to **Custom…**, one day forward. `{{DAILY}}` follows [Which day](/docs/Choices/TemplateChoice/#date-origin), so the capture targets tomorrow's note and creates it from your daily notes template. -With a typed path, change **Capture to** to: +With a typed path, change **Where** to: ``` Daily/{{DATE:YYYY-MM-DD+1}}.md @@ -148,4 +148,4 @@ Turn on **Create line if not found** with placement **Bottom** (or **Top**). Qui Use **Before line...** instead of **After line...** and target the placeholder, such as ``. See [Insert before](/docs/Choices/CaptureChoice/#insert-before) for the full setting. **Capture writes to the wrong file.** -Use `{{DAILY}}` in **Capture to**, which reads the path from your Daily notes settings. With a typed path, the date pattern must match your vault's daily-note naming exactly. If your notes are named `2025.01.15.md` inside `Journal/`, use `Journal/{{DATE:YYYY.MM.DD}}.md`. +Use `{{DAILY}}` in **Where**, which reads the path from your Daily notes settings. With a typed path, the date pattern must match your vault's daily-note naming exactly. If your notes are named `2025.01.15.md` inside `Journal/`, use `Journal/{{DATE:YYYY.MM.DD}}.md`. diff --git a/docs/src/content/docs/docs/Examples/Macro_AddLocationLongLatFromAddress.md b/docs/src/content/docs/docs/Examples/Macro_AddLocationLongLatFromAddress.md index f0de93875..0c83d6b4c 100644 --- a/docs/src/content/docs/docs/Examples/Macro_AddLocationLongLatFromAddress.md +++ b/docs/src/content/docs/docs/Examples/Macro_AddLocationLongLatFromAddress.md @@ -18,8 +18,8 @@ Imported the package above? The script and the **Add location from address** mac 1. Grab the script from [this page](/scripts/getLongLatFromAddress.js). You can either click the download link, or copy the file contents and save them as `getLongLatFromAddress.js`. The `.js` extension is essential. 2. Save the file anywhere in your vault, except `.obsidian` or another hidden folder (one whose name starts with a dot). For a fuller walkthrough, see [how to add a script to a macro](/docs/UserScripts/#adding-scripts-to-macros). -3. In **Settings → QuickAdd**, click **New choice** → **Macro**. The Macro Builder opens; click its name at the top to rename it (I call mine `Mapper`). If you close the builder, click the gear (Configure) button on the choice in the list to reopen it. See [the Macro choice docs](/docs/Choices/MacroChoice/) for a full walkthrough. -4. In the Macro Builder, place your cursor in the **User scripts** field to bring up a suggester, pick `getLongLatFromAddress.js` (or click **Browse** to select the file), and click **Add**. It should appear as the first command. +3. In **Settings → QuickAdd**, click **New choice** → **Run a script**. The Macro Builder opens with one script step; set **Name** (I call mine `Mapper`). If you close the builder, click the gear (Configure) button on the choice in the list to reopen it. See [the Macro choice docs](/docs/Choices/MacroChoice/) for a full walkthrough. +4. On the script step, click **Choose file** and pick `getLongLatFromAddress.js`. The step now shows the file's path. 5. Close the QuickAdd settings. ## What you get diff --git a/docs/src/content/docs/docs/Examples/Macro_BookFinder.md b/docs/src/content/docs/docs/Examples/Macro_BookFinder.md index 9a7fd1b6f..f76f3b445 100644 --- a/docs/src/content/docs/docs/Examples/Macro_BookFinder.md +++ b/docs/src/content/docs/docs/Examples/Macro_BookFinder.md @@ -1,5 +1,5 @@ --- -title: Book Finder Script +title: "Book finder script" description: Insert book details fetched from the Google Books API into your vault using a Macro choice and template, no API key required slug: docs/Examples/Macro_BookFinder package: book-finder @@ -17,11 +17,11 @@ You can find the script here. 1. Save the script (`BookFinder.js`) to your vault. Make sure it is saved as a JavaScript file, meaning that it has the `.js` at the end. **Important:** Do not save scripts in the `.obsidian` directory - they will be ignored. Valid locations include folders like `/scripts/`, `/macros/`, or any custom folder in your vault. 2. Create a new template in your designated templates folder. Example template is provided below. -3. In **Settings → QuickAdd**, click **New choice** → **Macro**. This is what activates the macro. The Macro Builder opens; click its name at the top to rename it - you decide what to name it. I named mine `Book`. -4. Add the user script to the command list. +3. In **Settings → QuickAdd**, click **New choice** → **Run a script**. This is what activates the macro. The Macro Builder opens with one script step; set **Name** - you decide what to name it. I named mine `Book`. +4. On the script step, click **Choose file** and pick `BookFinder.js`. 5. Add a new Template step to the macro (the `Template` button in the command bar). This will be what creates the note in your vault. Settings are as follows: 1. Set the template path to the template you created. - 2. Set **File name** to `{{VALUE:fileName}}` (before QuickAdd 2.30.0, this is **File name format**; turn its toggle on first). You can specify this however you like. The `fileName` value is the name of the Book without illegal file name characters. + 2. Set **Note name** to `{{VALUE:fileName}}` (before QuickAdd 2.30.0, this is **File name format**; turn its toggle on first). You can specify this however you like. The `fileName` value is the name of the Book without illegal file name characters. 3. The remaining settings are for you to specify depending on your needs. You can now use the macro to create notes with book information in your vault. diff --git a/docs/src/content/docs/docs/Examples/Macro_BrainDump.md b/docs/src/content/docs/docs/Examples/Macro_BrainDump.md index 7dd42ba57..6e955eb4f 100644 --- a/docs/src/content/docs/docs/Examples/Macro_BrainDump.md +++ b/docs/src/content/docs/docs/Examples/Macro_BrainDump.md @@ -22,12 +22,11 @@ dump entry** Capture, and `scripts/brainDump.js`. Follow **After importing** in the card, then skip to [What you get](#what-you-get). 1. Create the Capture. In **Settings → QuickAdd**, click **New choice** → - **Capture**. Click its name at the top of the settings window and rename it - `Brain dump entry`. -2. Set **Capture to** to `Inbox.md` and turn on **Create file if it doesn't + **Add to a note**, and set **Name** to `Brain dump entry`. +2. Set **Where** to `Inbox.md` and turn on **Create note if it doesn't exist**. -3. Set **Write position** to **Bottom of file**. -4. In **Capture format**, enter (before QuickAdd 2.30.0, turn on the **Capture format** toggle first): +3. Set **Position** to **Bottom of note**. +4. In **What**, enter (before QuickAdd 2.30.0, turn on the **Capture format** toggle first): ```text - {{VALUE}} @@ -39,9 +38,8 @@ in the card, then skip to [What you get](#what-you-get). 6. Download brainDump.js and save it in your vault. QuickAdd doesn't list scripts in `.obsidian` or in other folders whose names start with a dot. -7. Click **New choice** → **Macro** and rename it `Brain dump`. In the Macro - builder, type `brainDump` in the **User scripts** box, pick the script, and - click **Add**. +7. Click **New choice** → **Run a script** and set **Name** to `Brain dump`. + On the script step, click **Choose file** and pick `brainDump.js`. 8. Turn **Add to command palette** on and click **Done**. ## What you get diff --git a/docs/src/content/docs/docs/Examples/Macro_CaptureInboxGps.md b/docs/src/content/docs/docs/Examples/Macro_CaptureInboxGps.md index 45716c46b..d0fcecf7d 100644 --- a/docs/src/content/docs/docs/Examples/Macro_CaptureInboxGps.md +++ b/docs/src/content/docs/docs/Examples/Macro_CaptureInboxGps.md @@ -1,5 +1,5 @@ --- -title: "Macro: Capture to Inbox with GPS" +title: "Macro: Capture to inbox with GPS" description: Append a timestamped inbox line with device GPS coordinates, for offline capture on Obsidian mobile 1.11+ slug: docs/Examples/Macro_CaptureInboxGps package: capture-inbox-gps @@ -31,10 +31,10 @@ run it from the command palette. 1. Save captureInboxGps.js anywhere in your vault except `.obsidian` or a hidden folder. -2. In **Settings → QuickAdd**, click **New choice** → **Macro**. The Macro - Builder opens; click its name at the top to rename it +2. In **Settings → QuickAdd**, click **New choice** → **Run a script**. The + Macro Builder opens with one script step; set **Name** to `Capture to Inbox with GPS`. -3. In the Macro Builder, add that script as a **User Script**. +3. On the script step, click **Choose file** and pick `captureInboxGps.js`. 4. Turn on the ⚡ **Command palette** toggle on the choice's row so it appears in the palette. diff --git a/docs/src/content/docs/docs/Examples/Macro_ChangePropertyInDailyNotes.md b/docs/src/content/docs/docs/Examples/Macro_ChangePropertyInDailyNotes.md index e451e7f48..5b5bc13ed 100644 --- a/docs/src/content/docs/docs/Examples/Macro_ChangePropertyInDailyNotes.md +++ b/docs/src/content/docs/docs/Examples/Macro_ChangePropertyInDailyNotes.md @@ -17,8 +17,8 @@ This macro lists every property in today's daily journal note in a menu. Pick on Imported the package above? The script and the **Change daily note property** macro are already in place; skip to step 4 to check its settings, then run it. 1. Download changeDailyProperty.js and save it somewhere in your vault (not inside the `.obsidian` folder). See [the user scripts guide](/docs/UserScripts/) for how QuickAdd loads scripts. -2. In **Settings → QuickAdd**, click **New choice** → **Macro**. The Macro Builder opens; click its name at the top to rename it (for example, `Change property`). See [the Macro choice docs](/docs/Choices/MacroChoice/) for a full walkthrough. -3. In the Macro Builder, add your script as a **User Script** command. +2. In **Settings → QuickAdd**, click **New choice** → **Run a script**. The Macro Builder opens with one script step; set **Name** (for example, `Change property`). See [the Macro choice docs](/docs/Choices/MacroChoice/) for a full walkthrough. +3. On the script step, click **Choose file** and pick `changeDailyProperty.js`. 4. Click the cog on the script step. Leave **Daily note path** empty to use the Daily notes plugin's folder and date format, or set it to where your daily notes live, with their date format, for example `bins/daily/{{DATE:YYYY-MM-DD - ddd MMM D}}.md`. Run the macro, choose a property from the menu, and enter its new value. If the old value was a number or `true`/`false` and the new text still is one, it is written back as a number or boolean; otherwise as text. List properties such as `tags` are not offered, since a one-line prompt cannot edit them. diff --git a/docs/src/content/docs/docs/Examples/Macro_LogBookToDailyJournal.md b/docs/src/content/docs/docs/Examples/Macro_LogBookToDailyJournal.md index 98c611a69..800f88659 100644 --- a/docs/src/content/docs/docs/Examples/Macro_LogBookToDailyJournal.md +++ b/docs/src/content/docs/docs/Examples/Macro_LogBookToDailyJournal.md @@ -17,8 +17,8 @@ This macro asks which book you are reading and writes your answer to the **Book* Imported the package above? The script and the **Log book** macro are already in place; skip to step 4 to check its settings, then run it. 1. Download logBook.js and save it somewhere in your vault (not inside the `.obsidian` folder). See [the user scripts guide](/docs/UserScripts/) for how QuickAdd loads scripts. -2. In **Settings → QuickAdd**, click **New choice** → **Macro**. The Macro Builder opens; click its name at the top to rename it (for example, `Log Book`). See [the Macro choice docs](/docs/Choices/MacroChoice/) for a full walkthrough. -3. In the Macro Builder, add your script as a **User Script** command. +2. In **Settings → QuickAdd**, click **New choice** → **Run a script**. The Macro Builder opens with one script step; set **Name** (for example, `Log Book`). See [the Macro choice docs](/docs/Choices/MacroChoice/) for a full walkthrough. +3. On the script step, click **Choose file** and pick `logBook.js`. 4. Click the cog on the script step. Leave **Daily note path** empty to use the Daily notes plugin's folder and date format, or set it to where your daily notes live, with their date format, for example `bins/daily/{{DATE:YYYY-MM-DD - ddd MMM D}}.md`. Change **Property name** if you want to log to something other than `Book`. Run the macro and enter a book title at the prompt. QuickAdd updates the **Book** property in today's journal note to that title. diff --git a/docs/src/content/docs/docs/Examples/Macro_MigrateDataviewProperties.md b/docs/src/content/docs/docs/Examples/Macro_MigrateDataviewProperties.md index 7ec41d50f..530543b7d 100644 --- a/docs/src/content/docs/docs/Examples/Macro_MigrateDataviewProperties.md +++ b/docs/src/content/docs/docs/Examples/Macro_MigrateDataviewProperties.md @@ -1,5 +1,5 @@ --- -title: Migrate Dataview Properties to Frontmatter +title: "Migrate Dataview properties to frontmatter" description: Migrate inline Dataview properties to YAML frontmatter with wikilink-aware comma handling and selective property lists slug: docs/Examples/Macro_MigrateDataviewProperties package: migrate-dataview-properties @@ -42,8 +42,8 @@ The inline properties are removed from the body of the note after migration. Imported the package above? The script and the **Migrate Dataview properties** macro are already in place, set to migrate only `Reference, Related`; skip to [Configuration](#configuration) to pick your properties. 1. Save the script (`migrateDataviewToFrontmatter.js`) to your vault. Make sure it is saved as a JavaScript file, meaning that it has the `.js` at the end. **Important:** Do not save scripts in the `.obsidian` directory - they will be ignored. Valid locations include folders like `/scripts/`, `/macros/`, or any custom folder in your vault. -2. In **Settings → QuickAdd**, click **New choice** → **Macro**. The Macro Builder opens; click its name at the top to rename it - I named mine `Migrate Properties`. -3. Add the user script to the macro's command list. +2. In **Settings → QuickAdd**, click **New choice** → **Run a script**. The Macro Builder opens with one script step; set **Name** - I named mine `Migrate Properties`. +3. On the script step, click **Choose file** and pick `migrateDataviewToFrontmatter.js`. 4. Click the cog ⚙ icon next to the script command to configure its settings (see Configuration below). You can download the script here: migrateDataviewToFrontmatter.js diff --git a/docs/src/content/docs/docs/Examples/Macro_MoveNotesWithATagToAFolder.md b/docs/src/content/docs/docs/Examples/Macro_MoveNotesWithATagToAFolder.md index d7e387aac..631f364e0 100644 --- a/docs/src/content/docs/docs/Examples/Macro_MoveNotesWithATagToAFolder.md +++ b/docs/src/content/docs/docs/Examples/Macro_MoveNotesWithATagToAFolder.md @@ -14,8 +14,8 @@ This macro moves every note carrying a tag you pick into a folder you pick. It m Imported the package above? The script and the **Move notes with a tag** macro are already in place; skip the setup steps and read how to run it below. 1. Save the Move notes with a tag script to your vault, for example as `scripts/moveNotesWithTag.js` (not inside the `.obsidian` folder). See [the user scripts guide](/docs/UserScripts/) for how QuickAdd loads scripts. -2. In **Settings → QuickAdd**, click **New choice** → **Macro**. The Macro Builder opens; click its name at the top to rename it (for example, `Move tagged notes`). See [the Macro choice docs](/docs/Choices/MacroChoice/) for a full walkthrough. -3. In the Macro Builder, add your script as a **User Script** command. +2. In **Settings → QuickAdd**, click **New choice** → **Run a script**. The Macro Builder opens with one script step; set **Name** (for example, `Move tagged notes`). See [the Macro choice docs](/docs/Choices/MacroChoice/) for a full walkthrough. +3. On the script step, click **Choose file** and pick `moveNotesWithTag.js`. Back up your vault before you run it: the move happens as soon as you pick the folder, with no preview or undo. Run the macro, pick a tag, choose whether nested tags count too, then pick the destination folder; the folder menu shows how many notes will move. Notes whose path contains `template` (in any case, such as `Templates/`) are left where they are. diff --git a/docs/src/content/docs/docs/Examples/Macro_MovieAndSeriesScript.md b/docs/src/content/docs/docs/Examples/Macro_MovieAndSeriesScript.md index 364d861d7..ca9c704f7 100644 --- a/docs/src/content/docs/docs/Examples/Macro_MovieAndSeriesScript.md +++ b/docs/src/content/docs/docs/Examples/Macro_MovieAndSeriesScript.md @@ -1,5 +1,5 @@ --- -title: Movie & Series Script +title: "Movie & series script" description: Insert a movie or TV show note from the OMDb API into your vault with a Macro choice and template, requires an API key slug: docs/Examples/Macro_MovieAndSeriesScript package: movie-notes @@ -29,11 +29,11 @@ You can find the script here. 1. Save the script (`movies.js`) to your vault. Make sure it is saved as a JavaScript file, meaning that it has the `.js` at the end. **Important:** Do not save scripts in the `.obsidian` directory - they will be ignored. Valid locations include folders like `/scripts/`, `/macros/`, or any custom folder in your vault. 2. Create a new template in your designated templates folder. Example template is provided below. -3. In **Settings → QuickAdd**, click **New choice** → **Macro**. This is what activates the macro. The Macro Builder opens; click its name at the top to rename it - you decide what to call it. I named mine `🎬 Movie`. -4. Add the user script to the command list. +3. In **Settings → QuickAdd**, click **New choice** → **Run a script**. This is what activates the macro. The Macro Builder opens with one script step; set **Name** - you decide what to call it. I named mine `🎬 Movie`. +4. On the script step, click **Choose file** and pick `movies.js`. 5. Add a Template command to the macro. This will be what creates the note in your vault. Settings are as follows: 1. Set the template path to the template you created. - 2. Set **File name** to `{{VALUE:fileName}}` (before QuickAdd 2.30.0, this is **File name format**; turn its toggle on first). You can specify this however you like. The `fileName` value is the name of the Movie or TV show without illegal file name characters. + 2. Set **Note name** to `{{VALUE:fileName}}` (before QuickAdd 2.30.0, this is **File name format**; turn its toggle on first). You can specify this however you like. The `fileName` value is the name of the Movie or TV show without illegal file name characters. 3. The remaining settings are for you to specify depending on your needs. 6. Click on the cog icon to the right of the script command to configure the script settings. This should allow you to enter the API key you got from OMDb; click the save icon next to it and QuickAdd keeps it in Obsidian's secret storage, not in `data.json`. diff --git a/docs/src/content/docs/docs/Examples/Macro_TogglManager.md b/docs/src/content/docs/docs/Examples/Macro_TogglManager.md index 9e3238cae..b82bc88ac 100644 --- a/docs/src/content/docs/docs/Examples/Macro_TogglManager.md +++ b/docs/src/content/docs/docs/Examples/Macro_TogglManager.md @@ -1,5 +1,5 @@ --- -title: Toggl Manager +title: "Toggl manager" description: Start preset Toggl Track time entries from a customizable menu using a macro and the Obsidian Toggl integration plugin slug: docs/Examples/Macro_TogglManager package: toggl-manager @@ -19,8 +19,8 @@ You can find the script here. Imported the package above? The script and the **Toggl manager** macro are already in place. Connect the Toggl plugin as described under **After importing** in the card if you have not yet, then skip to [Configuration](#configuration) to set up your own menu. 1. Save the script (`togglManager.js`) to your vault. Make sure it is saved as a JavaScript file, meaning that it has the `.js` at the end. **Important:** Do not save scripts in the `.obsidian` directory - they will be ignored. Valid locations include folders like `/scripts/`, `/macros/`, or any custom folder in your vault. -2. In **Settings → QuickAdd**, click **New choice** → **Macro**. The Macro Builder opens; click its name at the top to rename it - you decide what to name it. I named mine ``⏳ Toggl Manager``. -3. Add the user script to the command list. +2. In **Settings → QuickAdd**, click **New choice** → **Run a script**. The Macro Builder opens with one script step; set **Name** - you decide what to name it. I named mine ``⏳ Toggl Manager``. +3. On the script step, click **Choose file** and pick `togglManager.js`. Your Macro should look like this: diff --git a/docs/src/content/docs/docs/Examples/Template_AddAnInboxItem.md b/docs/src/content/docs/docs/Examples/Template_AddAnInboxItem.md index d225a140b..f1d1ad407 100644 --- a/docs/src/content/docs/docs/Examples/Template_AddAnInboxItem.md +++ b/docs/src/content/docs/docs/Examples/Template_AddAnInboxItem.md @@ -1,5 +1,5 @@ --- -title: "Template: Add an Inbox Item" +title: "Template: Add an inbox item" description: Create a timestamped inbox note from a template, naming the file with the current date and time plus your input slug: docs/Examples/Template_AddAnInboxItem package: inbox-item @@ -24,14 +24,14 @@ tags: [inbox] Imported the package above? Follow **After importing** in the card, then skip the manual setup below and read [What you get](#what-you-get). -1. In **Settings → QuickAdd**, click **New choice** → **Template**. The Template choice settings open; click its name at the top to rename it (for example, `Inbox Item`). For a full tour of these settings, see [the Template choice docs](/docs/Choices/TemplateChoice/). -2. Set **Template path** to your inbox template: +1. In **Settings → QuickAdd**, click **New choice** → **New note from a template**. The Template choice settings open; set **Name** (for example, `Inbox Item`). For a full tour of these settings, see [the Template choice docs](/docs/Choices/TemplateChoice/). +2. Set **Template** to your inbox template: ``` Templates/Inbox Template.md ``` -3. Set **File name** to (before QuickAdd 2.30.0, this is **File name format**; turn its toggle on first): +3. Set **Note name** to (before QuickAdd 2.30.0, this is **File name format**; turn its toggle on first): ``` {{DATE:YYYY-MM-DD-HH-mm-ss}} {{NAME}} diff --git a/docs/src/content/docs/docs/Examples/Template_AutomaticBookNotesFromReadwise.md b/docs/src/content/docs/docs/Examples/Template_AutomaticBookNotesFromReadwise.md index 1e8e1e451..fbdfb81f3 100644 --- a/docs/src/content/docs/docs/Examples/Template_AutomaticBookNotesFromReadwise.md +++ b/docs/src/content/docs/docs/Examples/Template_AutomaticBookNotesFromReadwise.md @@ -1,5 +1,5 @@ --- -title: "Template - My Book Notes template" +title: "Template - My book notes template" description: Pull a book's highlights from Readwise into a new note using a Template choice and a bundled highlight-fetching macro slug: docs/Examples/Template_AutomaticBookNotesFromReadwise package: readwise-book-notes @@ -20,10 +20,10 @@ Imported the package above? The script, the **Readwise** macro, the template and New to user scripts? See [how to add a script to a macro](/docs/UserScripts/#adding-scripts-to-macros). 1. Download the Readwise script and save it in your vault. -2. Create the macro that runs the script: in **Settings → QuickAdd**, click **New choice** → **Macro**. The Macro Builder opens; click its name at the top to rename it (I use `Readwise`). See [the Macro choice docs](/docs/Choices/MacroChoice/) for a full walkthrough. -3. In the builder, add a **User Script** command: type the name of the script you saved (or click **Browse**) and click **Add**. +2. Create the macro that runs the script: in **Settings → QuickAdd**, click **New choice** → **Run a script**. The Macro Builder opens with one script step; set **Name** (I use `Readwise`). See [the Macro choice docs](/docs/Choices/MacroChoice/) for a full walkthrough. +3. On the script step, click **Choose file** and pick the script you saved. 4. Click the cog on the script's step, paste your token into **Readwise access token**, and click the save icon next to it. QuickAdd keeps it in Obsidian's secret storage, not in `data.json`. -5. Create a [Template choice](/docs/Choices/TemplateChoice/) whose **Template path** points at the template you made from the [one below](#template). Set **File name** to `{{MACRO:Readwise::getBooks}}` (before QuickAdd 2.30.0, this is **File name format**; turn its toggle on first), so the note is named after the book you pick. Set the remaining options to your liking. The screenshot shows the packaged choice: +5. Create a [Template choice](/docs/Choices/TemplateChoice/) with **New choice** → **New note from a template**. Point its **Template** at the template you made from the [one below](#template). Set **Note name** to `{{MACRO:Readwise::getBooks}}` (before QuickAdd 2.30.0, this is **File name format**; turn its toggle on first), so the note is named after the book you pick. Set the remaining options to your liking. The screenshot shows the packaged choice: ![The Add Book Notes Template choice: template path Templates/Book Notes.md, file name {{MACRO:Readwise::getBooks}}, new notes in the Books folder, Open on in a new tab, and One-page input override set to Never](../Images/readwise_template_choice.png) diff --git a/docs/src/content/docs/docs/Examples/Template_CreateMOCNoteWithLinkDashboard.md b/docs/src/content/docs/docs/Examples/Template_CreateMOCNoteWithLinkDashboard.md index 75d88d17b..1b36775d8 100644 --- a/docs/src/content/docs/docs/Examples/Template_CreateMOCNoteWithLinkDashboard.md +++ b/docs/src/content/docs/docs/Examples/Template_CreateMOCNoteWithLinkDashboard.md @@ -1,5 +1,5 @@ --- -title: "Template: Create an MOC Note with a Link Dashboard" +title: "Template: Create an MOC note with a link dashboard" description: Create a map-of-content note with an embedded Base dashboard showing its backlinks and outgoing links, via a Template choice slug: docs/Examples/Template_CreateMOCNoteWithLinkDashboard package: moc-link-dashboard @@ -90,14 +90,14 @@ outgoing links for this note. - Start linking this note to related ideas. ```` -3. Create a **Template** choice (see [the Template choice docs](/docs/Choices/TemplateChoice/)) with settings like these: +3. Create a **Template** choice with **New choice** → **New note from a template** (see [the Template choice docs](/docs/Choices/TemplateChoice/)), with settings like these: - **Template Path**: `Templates/MOC Link Dashboard.md` -- **File name**: `{{VALUE:moc_title}}` (before QuickAdd 2.30.0, this is **File name format**; turn its toggle on first) +- **Note name**: `{{VALUE:moc_title}}` (before QuickAdd 2.30.0, this is **File name format**; turn its toggle on first) - **Create in folder**: your MOC folder, for example `MOCs` - **Open**: enabled -- **If the target file already exists**: `Create another file` -- **New file naming**: `Increment trailing number` +- **If the note already exists**: `Create another note` +- **New note naming**: `Increment trailing number` 4. Run the Template choice and enter a title such as `Alpha Project`. diff --git a/docs/src/content/docs/docs/Examples/Template_MeetingNotes.md b/docs/src/content/docs/docs/Examples/Template_MeetingNotes.md index 50f10bb80..dd603de31 100644 --- a/docs/src/content/docs/docs/Examples/Template_MeetingNotes.md +++ b/docs/src/content/docs/docs/Examples/Template_MeetingNotes.md @@ -31,22 +31,20 @@ Date: {{DATE:YYYY-MM-DD}} ## Configure the choice -1. Open **Settings → QuickAdd** and choose **New choice → Template**. -2. Click the choice name at the top of the settings window. Rename it `New meeting` and confirm with **Ok**. -3. Set **Template path** to `Templates/Meeting.md`. -4. In **File name**, enter (before QuickAdd 2.30.0, this is **File name format**; turn its toggle on first): +1. Open **Settings → QuickAdd** and choose **New choice → New note from a template**. +2. Set **Name** to `New meeting`. (Before QuickAdd 2.30.0, click the choice name at the top of the settings window, rename it, and confirm with **Ok**.) +3. Set **Template** to `Templates/Meeting.md`. +4. In **Note name**, enter (before QuickAdd 2.30.0, this is **File name format**; turn its toggle on first): ```text {{DATE:YYYY-MM-DD}} {{VALUE:Meeting}} ``` -5. Set **New note location** to **In a specific folder**. -6. Enter `Meetings` in **Folder path** and click **Add**. -7. Turn **Open** on. -8. Set **File opening location** to **Reuse current tab** and **View mode** to **Live Preview**. -9. Choose **Done** and close Settings. +5. In **Folder**, enter `Meetings`. (Before QuickAdd 2.30.0, set **New note location** to **In a specific folder**, enter `Meetings` in **Folder path**, and click **Add**.) +6. Click **More settings**. Turn **Open** on. Set **Opening location** to **Reuse current tab** and **View mode** to **Live Preview**. +7. Close Settings. Leaving the page saves it. (Before QuickAdd 2.30.0, choose **Done** first.) -![The Template choice settings with Open enabled, File opening location set to Reuse current tab, and View mode set to Live Preview](../Images/examples/meeting-open-settings.png) +![The New meeting choice's Behavior settings with Open on, Opening location set to Reuse current tab, and View mode set to Live Preview](../Images/examples/meeting-open-settings.png) ## Run it and start typing diff --git a/docs/src/content/docs/docs/Examples/index.md b/docs/src/content/docs/docs/Examples/index.md index 4a79b9437..11363d0fc 100644 --- a/docs/src/content/docs/docs/Examples/index.md +++ b/docs/src/content/docs/docs/Examples/index.md @@ -10,46 +10,48 @@ check what the **Get this workflow** card says it needs, click **Copy package**, import it in Obsidian, and follow **After importing** under **How to install** in the card. See [Install an example from the docs](/docs/Choices/Packages/#install-an-example) -for the full walkthrough. +for the full walkthrough. In Obsidian, **New choice → Browse recipes…** lists +the same workflows and adds one with a click; see +[Browse recipes in the app](/docs/Choices/Packages/#browse-recipes). | Workflow | Choice type | Setup | Prerequisites | What it creates | | --- | --- | --- | --- | --- | -| [Capture to Your Daily Note](/docs/Examples/Capture_ToDailyNote/) | Capture | Beginner | Daily note path | Timestamped entries, tasks, quotes, callouts, and table rows | -| [Capture to Inbox with GPS](/docs/Examples/Macro_CaptureInboxGps/) | Macro | Intermediate | Obsidian mobile 1.11+ for GPS | A timestamped inbox line with coordinates | +| [Capture to your daily note](/docs/Examples/Capture_ToDailyNote/) | Capture | Beginner | Daily note path | Timestamped entries, tasks, quotes, callouts, and table rows | +| [Capture to inbox with GPS](/docs/Examples/Macro_CaptureInboxGps/) | Macro | Intermediate | Obsidian mobile 1.11+ for GPS | A timestamped inbox line with coordinates | | [Brain dump](/docs/Examples/Macro_BrainDump/) | Macro and Capture | Beginner | QuickAdd 2.29.0 or later | Several inbox lines in one go, one per entry | -| [Add a Task to a Kanban Board](/docs/Examples/Capture_AddTaskToKanbanBoard/) | Capture | Beginner | Obsidian Kanban plugin | A task in a board section | -| [Fetch Tasks from Todoist](/docs/Examples/Capture_FetchTasksFromTodoist/) | Capture and Macro | Intermediate | Todoist API token | Imported Todoist tasks | -| [Canvas Capture](/docs/Examples/Capture_CanvasCapture/) | Capture | Intermediate | An Obsidian Canvas file | Text added to a selected or targeted card | -| [Add an Inbox Item](/docs/Examples/Template_AddAnInboxItem/) | Template | Beginner | Inbox folder or note | A new inbox note | +| [Add a task to a Kanban board](/docs/Examples/Capture_AddTaskToKanbanBoard/) | Capture | Beginner | Obsidian Kanban plugin | A task in a board section | +| [Fetch tasks from Todoist](/docs/Examples/Capture_FetchTasksFromTodoist/) | Capture and Macro | Intermediate | Todoist API token | Imported Todoist tasks | +| [Canvas capture](/docs/Examples/Capture_CanvasCapture/) | Capture | Intermediate | An Obsidian Canvas file | Text added to a selected or targeted card | +| [Add an inbox item](/docs/Examples/Template_AddAnInboxItem/) | Template | Beginner | Inbox folder or note | A new inbox note | | [Meeting notes and project updates](/docs/Examples/Template_MeetingNotes/) | Template | Beginner | QuickAdd 2.27.0 or later | Dated notes and updates, ready to type in | -| [Create an MOC Note with a Link Dashboard](/docs/Examples/Template_CreateMOCNoteWithLinkDashboard/) | Template | Intermediate | Base template file | A note with an embedded Base dashboard | -| [Automatic Book Notes from Readwise](/docs/Examples/Template_AutomaticBookNotesFromReadwise/) | Template and Macro | Advanced | Readwise account and access token | Book notes with highlights | -| [Book Finder](/docs/Examples/Macro_BookFinder/) | Macro | Intermediate | Book lookup script | A populated book note | -| [Movie and Series Script](/docs/Examples/Macro_MovieAndSeriesScript/) | Macro | Intermediate | OMDb API key | Media notes with metadata | -| [Move Notes with a Tag](/docs/Examples/Macro_MoveNotesWithATagToAFolder/) | Macro | Intermediate | Tagged notes | Notes moved into a target folder | +| [Create an MOC note with a link dashboard](/docs/Examples/Template_CreateMOCNoteWithLinkDashboard/) | Template | Intermediate | Base template file | A note with an embedded Base dashboard | +| [Automatic book notes from Readwise](/docs/Examples/Template_AutomaticBookNotesFromReadwise/) | Template and Macro | Advanced | Readwise account and access token | Book notes with highlights | +| [Book finder](/docs/Examples/Macro_BookFinder/) | Macro | Intermediate | Book lookup script | A populated book note | +| [Movie and series script](/docs/Examples/Macro_MovieAndSeriesScript/) | Macro | Intermediate | OMDb API key | Media notes with metadata | +| [Move notes with a tag](/docs/Examples/Macro_MoveNotesWithATagToAFolder/) | Macro | Intermediate | Tagged notes | Notes moved into a target folder | | [Zettelizer](/docs/Examples/Macro_Zettelizer/) | Macro | Intermediate | Headings in an existing note | New notes split from headings | -| [Toggl Manager](/docs/Examples/Macro_TogglManager/) | Macro | Advanced | Toggl Track account and integration plugin | Preset time entries | +| [Toggl manager](/docs/Examples/Macro_TogglManager/) | Macro | Advanced | Toggl Track account and integration plugin | Preset time entries | ## Pick by goal ### Capture information faster -Start with [Capture to Your Daily Note](/docs/Examples/Capture_ToDailyNote/) for daily-note -captures. Use [Capture to Inbox with GPS](/docs/Examples/Macro_CaptureInboxGps/) when the +Start with [Capture to your daily note](/docs/Examples/Capture_ToDailyNote/) for daily-note +captures. Use [Capture to inbox with GPS](/docs/Examples/Macro_CaptureInboxGps/) when the line should also store device coordinates. Use [Brain dump](/docs/Examples/Macro_BrainDump/) to type several entries in a row without reopening the Capture. Move to -[Canvas Capture](/docs/Examples/Capture_CanvasCapture/) when your target is a Canvas card +[Canvas capture](/docs/Examples/Capture_CanvasCapture/) when your target is a Canvas card instead of a Markdown note. ### Create structured notes -Start with [Add an Inbox Item](/docs/Examples/Template_AddAnInboxItem/) for a small template. +Start with [Add an inbox item](/docs/Examples/Template_AddAnInboxItem/) for a small template. Try [meeting notes and project updates](/docs/Examples/Template_MeetingNotes/) to create a dated note or add a section, then start typing where you put the cursor marker. -Use [Create an MOC Note with a Link Dashboard](/docs/Examples/Template_CreateMOCNoteWithLinkDashboard/) +Use [Create an MOC note with a link dashboard](/docs/Examples/Template_CreateMOCNoteWithLinkDashboard/) when you want a generated note to include a live Base dashboard. ### Run scripted workflows -Start with [Book Finder](/docs/Examples/Macro_BookFinder/) to see the common macro pattern: +Start with [Book finder](/docs/Examples/Macro_BookFinder/) to see the common macro pattern: prompt for input, call a script, write a note, and open the result. diff --git a/docs/src/content/docs/docs/FormatSyntax.md b/docs/src/content/docs/docs/FormatSyntax.md index 553b2bfc6..9d6b08b06 100644 --- a/docs/src/content/docs/docs/FormatSyntax.md +++ b/docs/src/content/docs/docs/FormatSyntax.md @@ -11,8 +11,8 @@ answer you type, a link to the note you came from. You can use placeholders anywhere QuickAdd asks for a format: file name fields, capture formats, folder paths, "Insert after" targets, and inside template files. -In the format fields of a choice's settings (such as **Capture format**, -**Capture to**, **File name**, and the insert after/before targets), type +In the format fields of a choice's settings (such as **What**, +**Where**, **Note name**, and the insert after/before targets), type `{{` to get a list of placeholders, then keep typing to filter it. Template files and folder pickers don't show this list. Press Enter to insert the highlighted one. For placeholders that take an argument, like `{{VDATE:}}`, the cursor lands inside so you can finish it. @@ -83,13 +83,19 @@ text. A Templater tag in the format itself can still use an answer, as in | [`{{TITLE}}`](#title) | The new note's file name | | [`{{FOLDER}}`](#folder) | The folder the new note lands in | +**The note this run wrote** + +| Placeholder | What you get | +| --- | --- | +| [`{{NOTE}}`](#note) | Its path, like `Notes/Idea.md` | + **Other content** | Placeholder | What it inserts | | --- | --- | | [`{{CLIPBOARD}}`](#clipboard) | Whatever you copied last | | [`{{TEMPLATE:Templates/Meeting.md}}`](#template) | The contents of a template file | -| [`{{MACRO:My Macro}}`](#macro) | Whatever a macro returns | +| [`{{ACTION:Generate summary}}`](#macro) | What another choice returns | | [`{{GLOBAL_VAR:Header}}`](#global-var) | A snippet you defined in settings | | [`{{RANDOM:6}}`](#random) | A random ID like `x7k2p9` | @@ -241,7 +247,7 @@ use `{{DATE:HH:mm}}` with an offset, or ask for one with _Requires QuickAdd 2.30.0 or later._ `{{DAILY}}` is the path of the day's daily note, from the folder and date -format in Obsidian's **Daily notes** settings. Set **Capture to** to +format in Obsidian's **Daily notes** settings. Set **Where** to `{{DAILY}}` and entries land in the note **Open today's daily note** opens, even after you change those settings. @@ -256,7 +262,7 @@ plugin manages daily notes, `{{DAILY}}` uses its settings instead. Good to know: - The day follows the choice's [Which day](/docs/Choices/TemplateChoice/#date-origin), so picking yesterday captures to yesterday's daily note. -- If the daily note doesn't exist and **Create file if it doesn't exist** is on, a Capture creates it from the daily notes template, filled the way that plugin fills it, not with QuickAdd's format syntax. With Daily notes, `{{date}}` and `{{time}}` are when the note is created and `{{title}}` is its name. With Periodic Notes, `{{date}}` is the note's day, and `{{yesterday}}` and `{{tomorrow}}` work too. Turn on **Create file with a template** to use a QuickAdd template instead. +- If the daily note doesn't exist and **Create note if it doesn't exist** is on, a Capture creates it from the daily notes template, filled the way that plugin fills it, not with QuickAdd's format syntax. With Daily notes, `{{date}}` and `{{time}}` are when the note is created and `{{title}}` is its name. With Periodic Notes, `{{date}}` is the note's day, and `{{yesterday}}` and `{{tomorrow}}` work too. Turn on **Create note with a template** to use a QuickAdd template instead. - `|link` follows your link settings. A daily note that doesn't exist yet is linked by its full path, so following the link creates it in your daily notes folder. In front matter, quote it: `day: "{{DAILY|link}}"`. - If neither plugin manages daily notes, or the daily note template is missing, the run stops with an error instead of writing somewhere else. @@ -552,7 +558,7 @@ wikilinks. Good to know: - The picks become a real YAML list **inside front matter**. In a note body they become comma-separated text. -- In a **Capture**, a whole multi-select token with the default `|format:auto` stays a list with [**Write position → Property**](/docs/Choices/CaptureChoice/#property). Into a list property, a multi-select token on its own line adds one item per pick, even next to other lines. `|format:markdown` and `|format:yaml` add no dashes or brackets there, and `|format:inline` or `|format:spaced` joins the picks into one item. Capturing into a brand-new note's frontmatter also produces a list when **Create file if it doesn't exist** is enabled without a template. Captures into an existing note's body write comma-separated text. +- In a **Capture**, a whole multi-select token with the default `|format:auto` stays a list with [**Position → Property**](/docs/Choices/CaptureChoice/#property). Into a list property, a multi-select token on its own line adds one item per pick, even next to other lines. `|format:markdown` and `|format:yaml` add no dashes or brackets there, and `|format:inline` or `|format:spaced` joins the picks into one item. Capturing into a brand-new note's frontmatter also produces a list when **Create note if it doesn't exist** is enabled without a template. Captures into an existing note's body write comma-separated text. - With the [one-page input form](/docs/Advanced/onePageInputs/), avoid commas inside a single option (like `|text:"High, urgent"`) on a `|multi` placeholder - the one-page picker can't round-trip them. The default one-prompt-at-a-time picker handles them correctly. #### Reuse the pick elsewhere: `|name:` {#value-name} @@ -786,7 +792,7 @@ The active note's folder, as a vault-relative path with no trailing slash confused with [`{{FOLDER}}`](#folder), which is the folder a *new* note is being created in. -This makes per-project captures work without a macro. With **Capture to** set +This makes per-project captures work without a macro. With **Where** set to: ```text @@ -834,7 +840,7 @@ Where it has a value: - **Capture** - in the capture body, where it becomes the destination file's folder. - **Apply template to a note** - the target note's folder. -Where it stays empty: the capture **Capture to** field (that field is what +Where it stays empty: the capture **Where** field (that field is what *chooses* the folder, so there is nothing to reference yet), the `format` JavaScript API, and macro file-path commands. @@ -861,6 +867,22 @@ to. Handy as the note's top heading: # {{TITLE}} ``` +## The note this run wrote + +### The note the last step ended on: `{{NOTE}}` {#note} + +The note the run's last Template or Capture step ended on, for the steps +after it: the note it created, added to, or found already there and opened. +In a macro whose first step captures to a note, an Open file step with the +path `{{NOTE}}` opens that note, and a Capture to `{{NOTE}}` adds to it again. +A Link it or Run Templater step on `{{NOTE}}` links that note or runs +Templater on it. Scripts get the same note as `params.note`. + +`{{NOTE}}` is the note's path with its extension. `{{NOTE|link}}` is a link to +it, `{{NOTE|name}}` its file name without the extension, and +`{{NOTE|folder}}` its folder. Before the run writes a note, every form is +empty. + ## Pull data from your vault ### Suggest values a property already has: `{{FIELD:}}` {#field} @@ -896,7 +918,7 @@ topics: Inside front matter, `|multi` writes a real YAML list when the placeholder is the property's whole value. With the default `|format:auto`, it also stays a list when the entire Capture format is the token and -[**Write position → Property**](/docs/Choices/CaptureChoice/#property) is selected. +[**Position → Property**](/docs/Choices/CaptureChoice/#property) is selected. In note bodies, file names, and other text positions it writes comma-separated text. Combines with the same filters and defaults as single-value FIELD prompts: @@ -1200,9 +1222,12 @@ capture body in a template file and set the format to `{{TEMPLATE:Templates/Capture Format.md}}`. QuickAdd inserts the file and then runs the usual formatting passes on the result. -### A macro's result: `{{MACRO:}}` {#macro} +### Another choice's result: `{{ACTION:}}` {#macro} -`{{MACRO:Generate summary}}` runs that macro and inserts its return value. +`{{ACTION:Generate summary}}` runs that choice and inserts its result. It runs +any choice: a macro gives back its return value, and a Capture or Template +gives back the path of the note it ended on. `{{MACRO:}}` is its older name +and keeps working. #### Label the macro's prompt: `|label:` {#macro-label} diff --git a/docs/src/content/docs/docs/GlobalVariables.md b/docs/src/content/docs/docs/GlobalVariables.md index 02482383f..fceb25fae 100644 --- a/docs/src/content/docs/docs/GlobalVariables.md +++ b/docs/src/content/docs/docs/GlobalVariables.md @@ -50,8 +50,8 @@ Good places to use one: ![The Global variables section of QuickAdd settings with two variables: Signature, whose value is "Logged by QuickAdd on {{DATE:YYYY-MM-DD}}", and MyProjects, whose value is "{{VALUE:Inbox,Work,Personal,Archive}}"](./Images/settings-global-variables.png) :::tip -In the format fields of a choice's settings, such as **Capture format** or -**File name**, type `{{glob` to get suggestions for the variables you've +In the format fields of a choice's settings, such as **What** or +**Note name**, type `{{glob` to get suggestions for the variables you've defined, then pick one to insert it. Use descriptive names, and avoid two names that differ only by case. ::: diff --git a/docs/src/content/docs/docs/Images/AI_Assistant_Macro.gif b/docs/src/content/docs/docs/Images/AI_Assistant_Macro.gif index 653168ba9..888859a42 100644 Binary files a/docs/src/content/docs/docs/Images/AI_Assistant_Macro.gif and b/docs/src/content/docs/docs/Images/AI_Assistant_Macro.gif differ diff --git a/docs/src/content/docs/docs/Images/choices/capture-builder.png b/docs/src/content/docs/docs/Images/choices/capture-builder.png index 93d4211eb..0db3a9472 100644 Binary files a/docs/src/content/docs/docs/Images/choices/capture-builder.png and b/docs/src/content/docs/docs/Images/choices/capture-builder.png differ diff --git a/docs/src/content/docs/docs/Images/choices/macro-builder.png b/docs/src/content/docs/docs/Images/choices/macro-builder.png index a7a3a55bc..33c2c95d2 100644 Binary files a/docs/src/content/docs/docs/Images/choices/macro-builder.png and b/docs/src/content/docs/docs/Images/choices/macro-builder.png differ diff --git a/docs/src/content/docs/docs/Images/choices/template-builder.png b/docs/src/content/docs/docs/Images/choices/template-builder.png index 2c3251420..28a2fe382 100644 Binary files a/docs/src/content/docs/docs/Images/choices/template-builder.png and b/docs/src/content/docs/docs/Images/choices/template-builder.png differ diff --git a/docs/src/content/docs/docs/Images/examples/meeting-open-settings.png b/docs/src/content/docs/docs/Images/examples/meeting-open-settings.png index a7bcad6a2..92474a85a 100644 Binary files a/docs/src/content/docs/docs/Images/examples/meeting-open-settings.png and b/docs/src/content/docs/docs/Images/examples/meeting-open-settings.png differ diff --git a/docs/src/content/docs/docs/Images/getting-started-add-to-journal.gif b/docs/src/content/docs/docs/Images/getting-started-add-to-journal.gif index 4a763dc6e..92e30369a 100644 Binary files a/docs/src/content/docs/docs/Images/getting-started-add-to-journal.gif and b/docs/src/content/docs/docs/Images/getting-started-add-to-journal.gif differ diff --git a/docs/src/content/docs/docs/Settings.md b/docs/src/content/docs/docs/Settings.md index 87d42beaf..2824d233f 100644 --- a/docs/src/content/docs/docs/Settings.md +++ b/docs/src/content/docs/docs/Settings.md @@ -10,9 +10,13 @@ This page is a reference for the QuickAdd settings tab, one group at a time. Eac ![The Choices & packages section of QuickAdd settings: a filterable list of choices with Projects and Reading folders, row actions shown on hover for Add to journal, the New folder and New choice buttons, and the Export package… and Import package… buttons](./Images/settings-choices-and-packages.png) -- **Choices** - build and organize your QuickAdd choices. This is the main list you add to, reorder, and configure. Click the gear on a choice's row (on a phone, **⋮** → **Configure**) to open its settings as a page of this window; going back or closing Settings saves them (QuickAdd 2.30.0 or later; earlier versions open a dialog). See [Template Choices](/docs/Choices/TemplateChoice/), [Capture Choices](/docs/Choices/CaptureChoice/), [Macro Choices](/docs/Choices/MacroChoice/), and [Multi Choices](/docs/Choices/MultiChoice/). +- **Choices** - build and organize your QuickAdd choices. This is the main list you add to, reorder, and configure. **New choice** offers [presets](/docs/Choices/Presets/) by outcome, such as **Log with a timestamp** or **Run a script**, and **New folder** adds a folder. Under each choice's name, a one-line summary says what it does, for example *Adds a line under ## Log in today's daily note*; a folder shows how many choices it holds. Click the gear on a choice's row (on a phone, **⋮** → **Configure**) to open its settings as a page of this window; going back or closing Settings saves them (QuickAdd 2.30.0 or later; earlier versions open a dialog). See [Template Choices](/docs/Choices/TemplateChoice/), [Capture Choices](/docs/Choices/CaptureChoice/), [Macro Choices](/docs/Choices/MacroChoice/), and [Multi Choices](/docs/Choices/MultiChoice/). - **Packages** - share a set of choices with someone else, or bring theirs in. Use **Export package…** to bundle your choices into a file, and **Import package…** to add someone else's. See [Share QuickAdd Packages](/docs/Choices/Packages/). +## Run log {#run-log} + +The last 50 runs on this device: when each ran, the choice, what it did, and a link to the note it wrote to. **Clear** empties the list. The log is kept in `run-log.json` in QuickAdd's plugin folder, not in its settings, so it does not sync between devices. + ## Input {#input} - **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). @@ -25,7 +29,7 @@ Tell QuickAdd where your templates live, so it can suggest them when you configu ## Notifications {#notifications} - **Announce updates** - see what changed when a new version installs, including new features, demo videos, and bug fixes. Choose *Every release*, *Feature releases* (default; new features and breaking changes, not bug-fix-only releases), or *Never*. -- **Show capture notifications** - get a confirmation that a capture landed. When on, QuickAdd shows a notice after content is captured successfully. +- **Show capture notifications** - after a choice writes to a note, QuickAdd shows one notice saying what it did and where, such as `Log: added to 'Inbox'`. **Open** opens the note; **Undo** puts the note back the way it was, or moves a note the run created to the trash. If the note changed since the run, Undo leaves it alone and opens it instead. Runs started from an `obsidian://quickadd` link with callbacks, or from the command line, report their result to their caller and show no notice. ## AI & online {#ai--online} @@ -41,7 +45,7 @@ Settings most vaults never change are on the **Advanced** page, the last entry i ### Choice picker {#choice-picker} -The choice picker is the list you see when you run **QuickAdd: Run**. +The choice picker is the list you see when you run **QuickAdd: Run**. Each choice shows the same one-line summary under its name as in the settings list. ![The QuickAdd choice picker searching for "new": root choices New person and New meeting note, followed by New book note from the Reading folder and New project from the Projects folder, each nested match labelled with its folder](./Images/choice-picker-nested-search.png) @@ -74,6 +78,7 @@ The choice picker is the list you see when you run **QuickAdd: Run**. ## Choice icons {#choice-icons} - **Automatic choice icons** - QuickAdd gives each choice type a default [Lucide](https://lucide.dev) icon: `file-text` for Template, `pencil` for Capture, `terminal` for Macro, and `folder` for Multi. These show in the QuickAdd launcher, inside Multi choice pickers, and on registered commands in the command palette and mobile editing toolbar. +- **Preset icons** - a choice made from a [preset](/docs/Choices/Presets/) starts with that preset's icon, for example `clock` for **Log with a timestamp**, instead of its type's default. - **Override a choice's icon** - give a single choice its own icon. Open the choice's configuration and set **Icon** to any Lucide icon id (for example `star`); leave it empty to fall back to the choice type default. Icons take the active Obsidian theme's color; QuickAdd does not set per-choice icon colors. _Choice icons introduced in QuickAdd 2.14.0._ diff --git a/docs/src/content/docs/docs/UserScripts.md b/docs/src/content/docs/docs/UserScripts.md index 6fcae6248..0bbeec053 100644 --- a/docs/src/content/docs/docs/UserScripts.md +++ b/docs/src/content/docs/docs/UserScripts.md @@ -68,13 +68,34 @@ in the script picker. - Any path within a folder starting with a dot (.) ::: -In the Macro Builder, **Browse** opens QuickAdd's picker of discovered scripts -(both `.js` files and notes that contain a code block); it is not a native file -picker. If you add a script manually, type a `.js` script's basename - for -`scripts/my-script.js`, enter `my-script` (or its vault path, if another `.js` -file shares that name) - or, for a note, type its vault path -(e.g. `Scripts/my-script.md`). For a specific export, append a member expression -such as `my-script::start` (or `Scripts/my-script.md::start`). +In the macro builder, **Add a step** → **Run a script** opens QuickAdd's picker +of discovered scripts (both `.js` files and notes that contain a code block); it +is not a native file picker. For a specific export, type the script with a +member expression and press Enter: a `.js` script's basename such as +`my-script::start` for `scripts/my-script.js` (or its vault path, if another +`.js` file shares that name), or a note's vault path such as +`Scripts/my-script.md::start`. + +### The script step {#script-step} + +In the macro, a script step shows its name and, under it, the path of the file +it runs. When the step has no usable file, that line says why instead: + +- **No file chosen** - the step has no file yet. A macro made from the + **Run a script** [preset](/docs/Choices/Presets/) starts this way. +- **Can't find** followed by the path - the file was moved, renamed, or + deleted. +- **Not a script:** followed by the path - the file exists but is not a `.js` + file or a note. + +In each case the step offers **Choose file**, which opens the script picker. +Once the file is found, the step has a gear instead. It opens the script's +settings: first **Script file**, with the path and a **Change** button, then +the script's own [options](#configurable-options), if it has any. + +Changing the file starts the step over. It takes the new script's name, and +the settings you set for the old script are cleared, including any secrets it +kept in Obsidian's secret storage. Picking the same file again changes nothing. ### Keep a script in a note, for mobile {#scripts-in-a-note-code-block} @@ -155,7 +176,8 @@ The script is called with up to two arguments: `params` (always) and `settings` quickAddApi: QuickAddApi, // QuickAdd API methods (documented below) variables: {}, // Variables object for sharing data between scripts and templates obsidian: obsidian, // Obsidian module with all classes and utilities - abort: (message) => never // Abort macro execution with optional message + abort: (message) => never, // Abort macro execution with optional message + note: TFile | null // The note this run last created or wrote to, like {{NOTE}} } ``` diff --git a/docs/src/content/docs/docs/VariablesDataFlow.md b/docs/src/content/docs/docs/VariablesDataFlow.md index f7370c335..50682e052 100644 --- a/docs/src/content/docs/docs/VariablesDataFlow.md +++ b/docs/src/content/docs/docs/VariablesDataFlow.md @@ -184,10 +184,10 @@ the same Macro run. ## `executeChoice` is a trigger, not a function call {#executechoice-is-a-trigger} -The Macro Builder's **Choice** command and the API method +The macro builder's **Run a choice** step and the API method `quickAddApi.executeChoice` look similar but behave differently. -A **Choice** command added inside a Macro runs as part of that Macro's sequence +A **Run a choice** step added inside a Macro runs as part of that Macro's sequence and shares the Macro's scratchpad with the steps after it. `quickAddApi.executeChoice(choiceName, variables)` is a **one-way trigger**. It diff --git a/docs/src/content/docs/docs/index.md b/docs/src/content/docs/docs/index.md index 9ac0baa99..c0abf7f20 100644 --- a/docs/src/content/docs/docs/index.md +++ b/docs/src/content/docs/docs/index.md @@ -9,7 +9,8 @@ template, logging a line to your journal, running a script - into single commands you trigger with a hotkey. Set a workflow up once, then run it in a keystroke from anywhere in your vault. -New here? Build your [first workflow](#first-workflow) below in about a minute. +New here? Let QuickAdd [set up your first choices](#first-run), or build your +[first workflow](#first-workflow) below in about a minute. ## Install QuickAdd @@ -32,28 +33,54 @@ Most workflows start with either a Template choice or a Capture choice. Add a Macro choice when you need scripting, multiple steps, or data from another plugin or API. +You don't pick the type directly. **New choice** in the settings list offers +[presets](/docs/Choices/Presets/) named after what you want to happen, such as +**Log with a timestamp** or **Run a script**. Each one creates a choice of the +right type, already set up. + +## Your first choices {#first-run} + +An empty list in **Settings → QuickAdd** asks **What do you do in +Obsidian?** Click every answer that fits, such as **Keep a daily journal** or +**Meeting and people notes**, then click **Create choices**. QuickAdd adds +ready-to-run choices for each answer, set up for your vault: with daily notes +on, the journal and task choices write to today's daily note, and without +them to a dated note in `Journal/`. Each card says what it adds before you +pick it, and the [presets page](/docs/Choices/Presets/#first-run) lists them +all. + +Run one from the command palette (Ctrl/Cmd+P) with **QuickAdd: Run**, or put +it on a [button in a note](/docs/Choices/NoteButtons/) to run it with a click +or a tap. To build a choice yourself instead, click **New choice** under the +question, or follow the first workflow below. + ## First workflow Let's build a capture that adds a timestamped line to your daily journal. It takes about a minute. -1. Open **Settings → QuickAdd**, click **New choice**, and pick **Capture**. Its - settings open right away, as a page of the settings window. +1. Open **Settings → QuickAdd**, click **New choice**, and pick **Add to a + note**. It creates a Capture choice and opens its settings right away, as a + page of the settings window. 2. Set **Name** to `Add to journal`. (Before QuickAdd 2.30.0, the settings open in a dialog; click the name at the top to rename it.) -3. Set **Capture to** to `Journal/{{DATE}}.md` - the note today's entries land in. -4. Turn on **Create file if it doesn't exist**, so the first capture of the day - creates today's note instead of stopping with a "Target file missing" notice. -5. In **Capture format**, enter `- {{DATE:HH:mm}} {{VALUE}}` - the shape +3. Set **Where** to `Journal/{{DATE}}.md` - the note today's entries land in. +4. Click **More settings** and turn on **Create note if it doesn't exist**, so + the first capture of the day creates today's note instead of stopping with + a notice that the note does not exist. +5. In **What**, enter `- {{DATE:HH:mm}} {{VALUE}}` - the shape of one entry. (Before QuickAdd 2.30.0, turn on the **Capture format** toggle first.) 6. Close the settings. Open the command palette (Ctrl/Cmd+P), run **QuickAdd: Run**, pick `Add to journal`, and type your entry. -QuickAdd writes a line like `- 09:42 Standup moved to Wednesday` to the top of -today's journal note, without opening it. +QuickAdd writes a line like `- 09:42 Standup moved to Wednesday` at the bottom +of today's journal note, without opening it. + +![Running QuickAdd: Run from the command palette, picking Add to journal, typing "Standup moved to Wednesday", and the timestamped line appearing in today's journal note](./Images/getting-started-add-to-journal.gif) -![Running QuickAdd: Run from the command palette, picking Add to journal, typing "Standup moved to Wednesday", and the timestamped line appearing at the top of today's journal note](./Images/getting-started-add-to-journal.gif) +Under the choice's name, the settings list and the launcher now show what it +does: *Adds a line at the bottom of Journal/{date}*. Once it works the way you want, click the ⚡ icon next to the choice to add it to the command palette, then give it a hotkey in Obsidian's **Settings → @@ -75,9 +102,9 @@ type, difficulty, prerequisites, and outcome. Good first examples: - [Capture: Add entries to your daily note](/docs/Examples/Capture_ToDailyNote/) -- [Template: Add an Inbox Item](/docs/Examples/Template_AddAnInboxItem/) +- [Template: Add an inbox item](/docs/Examples/Template_AddAnInboxItem/) - [Macro: Book Finder](/docs/Examples/Macro_BookFinder/) -- [Capture: Canvas Capture](/docs/Examples/Capture_CanvasCapture/) +- [Capture: Canvas capture](/docs/Examples/Capture_CanvasCapture/) ### I want to automate with scripts diff --git a/package.json b/package.json index c3130c1b9..8f6637681 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "build-with-lint": "tsc -noEmit -skipLibCheck && pnpm lint && node esbuild.config.mjs production", "check": "svelte-check --threshold error", "packages:build": "node docs/packages/build.mjs", + "build:recipes": "node scripts/build-recipes.mjs", "obsidian:e2e": "obsidian-e2e run", "provision:e2e-vault": "obsidian-e2e provision", "start:e2e-obsidian": "obsidian-e2e start", diff --git a/scripts/build-recipes.mjs b/scripts/build-recipes.mjs new file mode 100644 index 000000000..89c8bef21 --- /dev/null +++ b/scripts/build-recipes.mjs @@ -0,0 +1,143 @@ +// @ts-check +/** + * Builds the recipe catalogue the plugin bundles for its Recipes gallery from + * the docs' example pages and their packages: + * + * docs/src/content/docs/docs/Examples/*.md (with `package: `) + * + docs/packages//package.json (the manifest's install block) + * + docs/public/packages/.quickadd.json (the built package) + * -> src/gui/recipes/catalog.generated.json + * + * Run it from the repo root with `pnpm run build:recipes`; `pnpm run + * packages:build` runs it too. A unit test fails when + * the committed catalogue is stale (tests/buildRecipes.test.ts). + */ +import { readdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const EXAMPLES_DIR = path.join(repoRoot, "docs/src/content/docs/docs/Examples"); +const PACKAGES_DIR = path.join(repoRoot, "docs/packages"); +const BUILT_DIR = path.join(repoRoot, "docs/public/packages"); +export const CATALOG_PATH = path.join(repoRoot, "src/gui/recipes/catalog.generated.json"); + +/** + * @typedef {object} Recipe + * @property {string} id + * @property {string} title + * @property {string} description + * @property {string} slug + * @property {string[]} requires + * @property {string[]} afterImport + * @property {{ name: string, type: string }[]} choices + * @property {number} scripts + * @property {number} templates + * @property {any} package + */ + +/** + * The flat `key: value` frontmatter the docs pages use; quoted values lose + * their quotes. + * @param {string} text + * @returns {Record} + */ +export function parseFrontmatter(text) { + const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text); + /** @type {Record} */ + const fields = {}; + if (!match) return fields; + for (const line of match[1].split(/\r?\n/)) { + const field = /^([A-Za-z]+):\s*(.*)$/.exec(line); + if (!field) continue; + const value = field[2].trim(); + fields[field[1]] = /^".*"$/.test(value) ? JSON.parse(value) : value; + } + return fields; +} + +/** + * A page title without the choice-type prefix the docs sort by. + * @param {string} title + */ +export function cleanTitle(title) { + return title.replace(/^(?:(?:Capture|Macro|Template):|Template -)\s*/, ""); +} + +/** Words a title keeps capitalized: names of products, plugins and things in Obsidian. */ +const PROPER_NOUNS = new Set([ + "Obsidian", "QuickAdd", "Todoist", "Readwise", "Toggl", "GPS", "MOC", "Dataview", "Kanban", "Canvas", "Base", +]); + +/** + * A title in sentence case: its first word as written, the rest lowercase + * unless they are proper nouns. + * @param {string} title + */ +export function sentenceCase(title) { + return title + .split(" ") + .map((word, index) => (index === 0 || PROPER_NOUNS.has(word) ? word : word.toLowerCase())) + .join(" "); +} + +/** + * One catalogue entry from a page's frontmatter, its manifest, and its built package. + * @param {string} id + * @param {Record} page + * @param {any} manifest + * @param {any} pkg + * @returns {Recipe} + */ +export function recipeFrom(id, page, manifest, pkg) { + /** @param {string[]} kinds */ + const count = (kinds) => pkg.assets.filter((/** @type {any} */ asset) => kinds.includes(asset.kind)).length; + return { + id, + title: sentenceCase(cleanTitle(page.title)), + description: page.description, + slug: page.slug, + requires: manifest.install?.requires ?? [], + afterImport: manifest.install?.afterImport ?? [], + choices: pkg.choices + .map((/** @type {any} */ entry) => entry.choice) + .filter((/** @type {any} */ choice) => choice.type !== "Multi") + .map((/** @type {any} */ choice) => ({ name: choice.name, type: choice.type })), + scripts: count(["user-script", "conditional-script"]), + templates: count(["template", "capture-template"]), + package: pkg, + }; +} + +/** @param {string} file */ +function readJson(file) { + return JSON.parse(readFileSync(file, "utf8")); +} + +/** The catalogue as the JSON text that gets committed. */ +export function buildCatalogJson() { + const recipes = readdirSync(EXAMPLES_DIR) + .filter((name) => name.endsWith(".md")) + .map((name) => parseFrontmatter(readFileSync(path.join(EXAMPLES_DIR, name), "utf8"))) + .filter((page) => page.package) + .map((page) => { + const id = page.package; + return recipeFrom( + id, + page, + readJson(path.join(PACKAGES_DIR, id, "package.json")), + readJson(path.join(BUILT_DIR, `${id}.quickadd.json`)), + ); + }) + .sort((a, b) => a.title.localeCompare(b.title, "en")); + return JSON.stringify(recipes, null, "\t") + "\n"; +} + +export function writeRecipeCatalog() { + writeFileSync(CATALOG_PATH, buildCatalogJson()); + console.log(`wrote ${path.relative(process.cwd(), CATALOG_PATH)}`); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + writeRecipeCatalog(); +} diff --git a/src/IChoiceExecutor.ts b/src/IChoiceExecutor.ts index 4fc46d726..cc749024a 100644 --- a/src/IChoiceExecutor.ts +++ b/src/IChoiceExecutor.ts @@ -1,3 +1,4 @@ +import type { TFile } from "obsidian"; import type IChoice from "./types/choices/IChoice"; import type ITemplateChoice from "./types/choices/ITemplateChoice"; import type ICaptureChoice from "./types/choices/ICaptureChoice"; @@ -12,14 +13,17 @@ import type { ICommand } from "./types/macros/ICommand"; import type { PreparedChoiceInputState } from "./preflight/preparedChoiceInputs"; import type { LoadedUserScript } from "./utils/userScript"; import type { ChoiceChain } from "./engine/choiceChain"; +import type { Action } from "./v3/model"; export interface IChoiceExecutor { /** * Runs `choice`. A run started from inside another passes that run's chain * as `ancestry`, and the call fails with the cycle when `choice` is already - * in it. + * in it. `inline` is the action a Macro `choice` was lowered from when no + * stored action holds it on its own (an inline action step's): a sequence + * runs its steps rather than the commands. */ - execute(choice: IChoice, ancestry?: ChoiceChain): Promise; + execute(choice: IChoice, ancestry?: ChoiceChain, inline?: Action): Promise; prepareMacroInputs(choice: IMacroChoice, commands: ICommand[]): Promise; readonly preparedInputs: PreparedChoiceInputState; /** @@ -102,6 +106,18 @@ export interface IChoiceExecutor { * unaffected; absent/undefined means "no trigger-derived default". */ triggerContext?: QuickAddTriggerContext | null; + /** + * The run note, `{{NOTE}}`: the note this outermost run last created or + * wrote to, or null before its first write. A nested choice runs through + * the same executor, so a macro sees the note its nested Capture wrote. + */ + runNote?: TFile | null; + /** + * Runs `run` and gives the note it ended on: the run note once `run` has + * recorded one, null when it recorded none. The run note itself is left as + * it is throughout, so `{{NOTE}}` inside `run` still sees the outer note. + */ + noteEndedOn?(run: () => Promise): Promise; /** * Records the structured outcome of the current execution so an orchestrator * (the URI x-callback handler, via {@link ChoiceExecutor.executeWithOutcome}) can diff --git a/src/ai/AIAssistant.ts b/src/ai/AIAssistant.ts index 1ae277532..8aeb0ebbe 100644 --- a/src/ai/AIAssistant.ts +++ b/src/ai/AIAssistant.ts @@ -13,6 +13,7 @@ import type { OpenAIModelParameters } from "./OpenAIModelParameters"; import { OpenAIRequest } from "./OpenAIRequest"; import type { AIProvider, Model } from "./Provider"; import { makeNoticeHandler } from "./makeNoticeHandler"; +import { onlineFeaturesOffRefusal } from "./aiRefusals"; export * from "./requestLog"; export { ChunkedPrompt, RateLimiter } from "./chunkedPrompt"; @@ -104,9 +105,7 @@ export async function runAIAssistant( formatter: (input: string) => Promise ) { if (settingsStore.getState().disableOnlineFeatures) { - throw new Error( - "Online features are disabled in settings. Enable them to use the AI Assistant." - ); + throw onlineFeaturesOffRefusal(); } const notice = makeNoticeHandler(settings.showAssistantMessages); @@ -183,9 +182,7 @@ export async function Prompt( formatter: (input: string) => Promise ) { if (settingsStore.getState().disableOnlineFeatures) { - throw new Error( - "Online features are disabled in settings. Enable them to use the AI Assistant." - ); + throw onlineFeaturesOffRefusal(); } const notice = makeNoticeHandler(settings.showAssistantMessages); diff --git a/src/ai/OpenAIRequest.audit-cleanup.test.ts b/src/ai/OpenAIRequest.audit-cleanup.test.ts index 33309586e..32fefb965 100644 --- a/src/ai/OpenAIRequest.audit-cleanup.test.ts +++ b/src/ai/OpenAIRequest.audit-cleanup.test.ts @@ -38,7 +38,7 @@ describe("OpenAIRequest disable-online-features guard wording", () => { ); await expect(makeRequest("prompt")).rejects.toThrow( - "Blocking request: Online features are disabled in settings." + "Online features are off, so the AI request was not sent." ); await expect(makeRequest("prompt")).rejects.not.toThrow(/OpenAI/); expect(requestUrlMock).not.toHaveBeenCalled(); diff --git a/src/ai/OpenAIRequest.test.ts b/src/ai/OpenAIRequest.test.ts index 431f6e289..d18616470 100644 --- a/src/ai/OpenAIRequest.test.ts +++ b/src/ai/OpenAIRequest.test.ts @@ -140,7 +140,7 @@ describe("OpenAIRequest", () => { ); await expect(makeRequest("prompt")).rejects.toThrow( - "Online features are disabled in settings." + "Online features are off, so the AI request was not sent." ); expect(requestUrlMock).not.toHaveBeenCalled(); expect(beginAIRequestLogEntryMock).not.toHaveBeenCalled(); diff --git a/src/ai/OpenAIRequest.ts b/src/ai/OpenAIRequest.ts index 8ec507370..56853d65c 100644 --- a/src/ai/OpenAIRequest.ts +++ b/src/ai/OpenAIRequest.ts @@ -33,6 +33,7 @@ import { export type { CommonResponse } from "./providerRequest"; export { anthropicMaxTokens } from "./providerRequest"; import { anthropicMaxTokens, dispatchProviderRequest, requestPrompt, type CommonResponse } from "./providerRequest"; +import { onlineFeaturesOffRefusal } from "./aiRefusals"; export function OpenAIRequest( app: App, @@ -50,9 +51,7 @@ export function OpenAIRequest( ): (prompt: string) => Promise { return async function makeRequest(prompt: string): Promise { if (settingsStore.getState().disableOnlineFeatures) { - throw new Error( - "Blocking request: Online features are disabled in settings." - ); + throw onlineFeaturesOffRefusal(); } const estimatedTokenCount = @@ -174,9 +173,7 @@ export async function chatRequest( ): Promise { void app; // cursor handling is owned by the caller (Agent) for the whole loop if (settingsStore.getState().disableOnlineFeatures) { - throw new Error( - "Blocking request: Online features are disabled in settings.", - ); + throw onlineFeaturesOffRefusal(); } const wire = getChatWire(modelProvider); diff --git a/src/ai/aiRefusals.ts b/src/ai/aiRefusals.ts new file mode 100644 index 000000000..f11f599c9 --- /dev/null +++ b/src/ai/aiRefusals.ts @@ -0,0 +1,11 @@ +import { refuse } from "../errors/RefusalError"; + +/** An AI request while the user has turned online features off. */ +export function onlineFeaturesOffRefusal() { + return refuse("online features are off", "the AI request was not sent", "Turn off \"Disable AI & online features\" in QuickAdd's settings."); +} + +/** An AI step whose model no provider offers. */ +export function unknownModelRefusal(model: string) { + return refuse(`no AI provider offers the model ${model}`, "the AI request was not sent", "Pick a model on the step's row."); +} diff --git a/src/ai/chunkedPrompt.ts b/src/ai/chunkedPrompt.ts index fa55611b4..6fddbb510 100644 --- a/src/ai/chunkedPrompt.ts +++ b/src/ai/chunkedPrompt.ts @@ -15,6 +15,7 @@ import { import { findInlineScriptSpans } from "src/formatters/helpers/inlineScriptSpans"; import { transformCase } from "src/utils/caseTransform"; import { outputVariables, trackPrompt } from "./promptProgress"; +import { onlineFeaturesOffRefusal } from "./aiRefusals"; export class RateLimiter { private queue: (() => Promise)[] = []; @@ -399,9 +400,7 @@ export async function ChunkedPrompt( ) => Promise ) { if (settingsStore.getState().disableOnlineFeatures) { - throw new Error( - "Online features are disabled in settings. Enable them to use the AI Assistant." - ); + throw onlineFeaturesOffRefusal(); } const notice = makeNoticeHandler(settings.showAssistantMessages); diff --git a/src/ai/tools/Agent.ts b/src/ai/tools/Agent.ts index 377aa2c6b..3b192db56 100644 --- a/src/ai/tools/Agent.ts +++ b/src/ai/tools/Agent.ts @@ -50,6 +50,7 @@ import type { PublicToolCall, PublicToolResult, } from "./aiToolTypes"; +import { onlineFeaturesOffRefusal } from "../aiRefusals"; const TOOL_NAME_RE = /^[a-zA-Z0-9_-]{1,64}$/; const DEFAULT_MAX_STEPS = 20; @@ -152,9 +153,7 @@ export class Agent { this.approveAllThisRun = false; const pluginSettings = settingsStore.getState(); if (pluginSettings.disableOnlineFeatures) { - throw new Error( - "Rejecting AI request: Online features are disabled in settings.", - ); + throw onlineFeaturesOffRefusal(); } const { model, provider: modelProvider } = resolveModelInputOrThrow( diff --git a/src/api/aiApi.ts b/src/api/aiApi.ts index 568409b3a..d04331ded 100644 --- a/src/api/aiApi.ts +++ b/src/api/aiApi.ts @@ -17,6 +17,7 @@ import type { BuiltinGroupOptions } from "../ai/tools/builtins/shared"; import { settingsStore } from "../settingsStore"; import { reportError } from "../utils/errorUtils"; import { log } from "../logger/logManager"; +import { onlineFeaturesOffRefusal } from "../ai/aiRefusals"; type Format = (text: string, variables?: Record, clearVariables?: boolean) => Promise; type PromptSettings = Partial<{ @@ -33,7 +34,7 @@ export function createAiApi(app: App, plugin: QuickAdd, choiceExecutor: IChoiceE async function promptOptions(model: ScriptModelInput, settings?: PromptSettings) { const pluginSettings = settingsStore.getState(); if (pluginSettings.disableOnlineFeatures) { - throw new Error("Rejecting request to `prompt` via API AI module. Online features are disabled in settings."); + throw onlineFeaturesOffRefusal(); } const { model: resolvedModel, provider } = resolveModelInputOrThrow(model); const apiKey = await resolveProviderApiKey(app, provider); diff --git a/src/choiceExecutor.finishRun.test.ts b/src/choiceExecutor.finishRun.test.ts new file mode 100644 index 000000000..e6d207f29 --- /dev/null +++ b/src/choiceExecutor.finishRun.test.ts @@ -0,0 +1,190 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Notice, type TFile } from "obsidian"; +import type ICaptureChoice from "./types/choices/ICaptureChoice"; +import type IChoice from "./types/choices/IChoice"; +import type IMacroChoice from "./types/choices/IMacroChoice"; +import type { ICommand } from "./types/macros/ICommand"; +import { CommandType } from "./types/macros/CommandType"; +import type { IChoiceExecutor } from "./IChoiceExecutor"; +import { UserCancelError } from "./errors/UserCancelError"; +import { claimRefusal, refuse } from "./errors/RefusalError"; +import { runLog } from "./runLog"; + +type NoticeStub = { instances: { messageEl: HTMLElement }[] }; + +const { settings } = vi.hoisted(() => ({ + settings: { onePageInputEnabled: false, ai: {}, disableOnlineFeatures: true, showCaptureNotification: true }, +})); + +vi.mock("./gui/choiceList/ChoiceView.svelte", () => ({})); +vi.mock("./gui/GlobalVariables/GlobalVariablesView.svelte", () => ({})); +vi.mock("./main", () => ({ __esModule: true, default: class QuickAddMock {} })); +vi.mock("./quickAddSettingsTab", () => ({ + DEFAULT_SETTINGS: {}, + QuickAddSettingsTab: class {}, +})); +vi.mock("./settingsStore", () => ({ settingsStore: { getState: () => settings } })); +vi.mock("./utils/frontmatterPropertyLinks", () => ({ + getFocusedPropertyTarget: vi.fn(() => null), +})); +vi.mock("./utils/fileOpening", async (importOriginal) => ({ + ...(await importOriginal()), + getOpenFileOriginLeaf: vi.fn(() => null), +})); +// A Capture whose target says what it does: write, fail, throw or be cancelled. +vi.mock("./engine/CaptureChoiceEngine", () => ({ + CaptureChoiceEngine: class { + constructor( + _app: unknown, + _plugin: unknown, + private choice: ICaptureChoice, + private executor: IChoiceExecutor, + ) {} + async run() { + const target = this.choice.captureTo; + if (target === "throw") throw new Error("boom"); + if (target === "cancel") return this.executor.signalAbort?.(new UserCancelError("Input cancelled by user")); + if (target === "refuse") { + const refusal = refuse("no note is open", "there is nothing to add to"); + this.executor.recordExecutionResult?.({ status: "error", reason: claimRefusal(refusal, this.choice.name) }); + return this.executor.signalAbort?.(refusal); + } + if (target.startsWith("unchanged:")) { + return this.executor.recordExecutionResult?.({ status: "success", file: fileAt(target.slice("unchanged:".length)), effect: "unchanged" }); + } + this.executor.recordExecutionResult?.( + target === "fail" + ? { status: "error", reason: "failed" } + : { + status: "success", file: fileAt(target), effect: "changed", + write: { path: target, before: "", after: "- new" }, + }, + ); + } + }, +})); + +function fileAt(path: string): TFile { + return { path, basename: path.replace(/\.md$/, "") } as TFile; +} + +const { ChoiceExecutor } = await import("./choiceExecutor"); + +const app = { workspace: { getActiveFile: () => null } } as never; +const plugin = { app, settings: { choices: [] } } as never; + +function capture(target: string): ICaptureChoice { + return { id: `capture-${target}`, name: `Capture ${target}`, type: "Capture", captureTo: target } as ICaptureChoice; +} + +function nested(choice: IChoice): ICommand { + return { id: `nested-${choice.id}`, name: choice.name, type: CommandType.NestedChoice, choice } as ICommand; +} + +function macro(name: string, commands: ICommand[]): IMacroChoice { + return { + id: name, + name, + type: "Macro", + command: false, + runOnStartup: false, + macro: { id: `${name}-macro`, name, commands }, + }; +} + +const notices = () => (Notice as unknown as NoticeStub).instances.map((notice) => notice.messageEl.textContent); + +describe("ChoiceExecutor result notice", () => { + beforeEach(() => { + (Notice as unknown as NoticeStub).instances.length = 0; + settings.showCaptureNotification = true; + runLog.clear(); + }); + + it("shows one notice for an outermost run, with Open and Undo", async () => { + await new ChoiceExecutor(app, plugin).execute(capture("a.md")); + expect(notices()).toEqual(["Capture a.md: added to 'a'OpenUndo"]); + }); + + it("shows one notice for a macro, for its last write, and none for the runs nested in it", async () => { + await new ChoiceExecutor(app, plugin).execute(macro("M", [nested(capture("a.md")), nested(capture("b.md"))])); + expect(notices()).toEqual(["M: added to 'b'OpenUndo"]); + }); + + it("describes the run's last write, not a later step that left its note alone", async () => { + await new ChoiceExecutor(app, plugin).execute(macro("M", [nested(capture("a.md")), nested(capture("unchanged:b.md"))])); + expect(notices()).toEqual(["M: added to 'a'OpenUndo"]); + }); + + it("still says when a run had nothing to add", async () => { + await new ChoiceExecutor(app, plugin).execute(capture("unchanged:b.md")); + expect(notices()).toEqual(["Capture unchanged:b.md: nothing to add to 'b'Open"]); + }); + + it("shows nothing when the run recorded no success", async () => { + const executor = new ChoiceExecutor(app, plugin); + await executor.execute(capture("fail")); + await executor.execute(capture("cancel")); + await expect(executor.execute(capture("throw"))).rejects.toThrow("boom"); + expect(notices()).toEqual([]); + }); + + it("shows nothing when capture notifications are off", async () => { + settings.showCaptureNotification = false; + await new ChoiceExecutor(app, plugin).execute(capture("a.md")); + expect(notices()).toEqual([]); + }); + + it("shows nothing for a run that returns its outcome to the caller", async () => { + const outcome = await new ChoiceExecutor(app, plugin).executeWithOutcome(capture("a.md")); + expect(outcome).toMatchObject({ status: "success", effect: "changed" }); + expect(notices()).toEqual([]); + }); +}); + +describe("ChoiceExecutor run log", () => { + beforeEach(() => { + settings.showCaptureNotification = false; + runLog.clear(); + }); + + it("logs one entry per outermost run, with the note the run last wrote", async () => { + await new ChoiceExecutor(app, plugin).execute(macro("M", [nested(capture("a.md")), nested(capture("b.md"))])); + expect(runLog.list()).toEqual([expect.objectContaining({ + choiceId: "M", choiceName: "M", status: "success", effect: "changed", path: "b.md", + at: expect.any(String), durationMs: expect.any(Number), + })]); + }); + + it("logs a failed, a cancelled and a thrown run", async () => { + const executor = new ChoiceExecutor(app, plugin); + await executor.execute(capture("fail")); + await executor.execute(capture("cancel")); + await expect(executor.execute(capture("throw"))).rejects.toThrow("boom"); + expect(runLog.list().map(({ choiceName, status, reason }) => ({ choiceName, status, reason }))).toEqual([ + { choiceName: "Capture throw", status: "error", reason: "boom" }, + { choiceName: "Capture cancel", status: "cancelled", reason: undefined }, + { choiceName: "Capture fail", status: "error", reason: "failed" }, + ]); + }); + + it("logs a sequence stopped by a step's refusal as a failure in the step's words", async () => { + await new ChoiceExecutor(app, plugin).execute(macro("M", [nested(capture("refuse")), nested(capture("a.md"))])); + expect(runLog.list().map(({ choiceName, status, reason, path }) => ({ choiceName, status, reason, path }))).toEqual([ + { choiceName: "M", status: "error", reason: "Capture refuse: no note is open, so there is nothing to add to.", path: undefined }, + ]); + }); + + it("returns a refusal to the caller as a failure with the sentence", async () => { + await expect(new ChoiceExecutor(app, plugin).executeWithOutcome(capture("refuse"))).resolves.toEqual({ + status: "error", reason: "Capture refuse: no note is open, so there is nothing to add to.", + }); + }); + + it("logs a run that returns its outcome to the caller", async () => { + await new ChoiceExecutor(app, plugin).executeWithOutcome(capture("a.md")); + expect(runLog.list()).toEqual([expect.objectContaining({ + choiceName: "Capture a.md", status: "success", effect: "changed", path: "a.md", + })]); + }); +}); diff --git a/src/choiceExecutor.onePageGate.test.ts b/src/choiceExecutor.onePageGate.test.ts index 5ad73adbd..4de199c95 100644 --- a/src/choiceExecutor.onePageGate.test.ts +++ b/src/choiceExecutor.onePageGate.test.ts @@ -32,6 +32,9 @@ vi.mock("./engine/TemplateChoiceEngine", () => ({ async run() { await runTemplate(this.choice); } }, })); +// Every template these runs name is there. +const checkTemplateSource = vi.fn<(app: unknown, choice: IChoice) => void>(); +vi.mock("./engine/templateSource", () => ({ checkTemplateSource })); vi.mock("./utils/frontmatterPropertyLinks", () => ({ getFocusedPropertyTarget: vi.fn(() => null), })); @@ -161,6 +164,23 @@ describe("ChoiceExecutor one-page preflight gate", () => { ); }); + it("execute() checks a Template's template before the form asks anything", async () => { + onePageInputEnabled = true; + runTemplate.mockClear(); + const { refuse } = await import("./errors/RefusalError"); + checkTemplateSource.mockImplementationOnce(() => { + throw refuse("the template T.md does not exist", "no note was created"); + }); + const executor = makeExecutor() as unknown as InstanceType; + const templateChoice = choice("Template"); + await expect(executor.execute(templateChoice as never)).rejects.toThrow( + "Gate test: the template T.md does not exist, so no note was created.", + ); + expect(checkTemplateSource).toHaveBeenCalledWith(expect.anything(), templateChoice); + expect(runOnePagePreflight).not.toHaveBeenCalled(); + expect(runTemplate).not.toHaveBeenCalled(); + }); + it("rethrows non-cancellation preflight errors unchanged", async () => { onePageInputEnabled = true; const boom = new Error("collection exploded"); diff --git a/src/choiceExecutor.preparedInputs.test.ts b/src/choiceExecutor.preparedInputs.test.ts index d64560797..519e09eda 100644 --- a/src/choiceExecutor.preparedInputs.test.ts +++ b/src/choiceExecutor.preparedInputs.test.ts @@ -24,6 +24,8 @@ vi.mock("./settingsStore", () => ({ settingsStore: { getState: () => ({ onePageInputEnabled: false, ai: {}, disableOnlineFeatures: true }) }, })); vi.mock("./preflight/runOnePagePreflight", () => ({ runOnePagePreflight: vi.fn() })); +// Every template these runs name is there. +vi.mock("./engine/templateSource", () => ({ checkTemplateSource: vi.fn() })); vi.mock("./utils/frontmatterPropertyLinks", () => ({ getFocusedPropertyTarget: () => null })); vi.mock("./utils/fileOpening", async (importOriginal) => ({ ...await importOriginal>(), diff --git a/src/choiceExecutor.reentry.test.ts b/src/choiceExecutor.reentry.test.ts index a73e5b033..1f830b80b 100644 --- a/src/choiceExecutor.reentry.test.ts +++ b/src/choiceExecutor.reentry.test.ts @@ -38,6 +38,7 @@ vi.mock("./utils/userScript", async (importOriginal) => ({ })); const { ChoiceExecutor } = await import("./choiceExecutor"); +const { CaptureChoice } = await import("./types/choices/CaptureChoice"); const { StartupMacroEngine } = await import("./engine/StartupMacroEngine"); const { log } = await import("./logger/logManager"); const { default: ChoiceSuggester } = await import("./gui/suggesters/choiceSuggester"); @@ -148,6 +149,38 @@ describe("ChoiceExecutor re-entry guard", () => { expect(ran).toEqual(["a"]); }); + it("runs {{ACTION:}} and {{action:}} as it runs {{MACRO:}}", async () => { + const b = macro("B", [script("b", () => "out")]); + let formatted = ""; + const a = macro("A", [ + script("a", async ({ quickAddApi }) => { + formatted = await quickAddApi.format("{{ACTION:B}} {{action:B}} {{MACRO:B}}"); + }), + ]); + choices = [a, b]; + + await new ChoiceExecutor(app, plugin).execute(a); + + expect(formatted).toBe("out out out"); + expect(ran).toEqual(["a", "b", "b", "b"]); + }); + + it("refuses a Capture that reaches itself through {{ACTION:}}", async () => { + const capture = new CaptureChoice("Self"); + capture.captureTo = "{{ACTION:Self}}"; + choices = [capture]; + + const logError = vi.spyOn(log, "logError").mockImplementation(() => {}); + + // The Capture reports its own failure rather than throwing. + await new ChoiceExecutor(app, plugin).execute(capture); + + expect(logError.mock.calls.map((call) => String(call[0]))).toEqual([ + 'Error: Error running capture choice "Self": Capture "Self" calls itself: Self -> Self', + ]); + logError.mockRestore(); + }); + it("refuses a script that runs the macro it is part of", async () => { const a = macro("A", [script("a", ({ quickAddApi }) => quickAddApi.executeChoice("A"))]); choices = [a]; diff --git a/src/choiceExecutor.runNote.test.ts b/src/choiceExecutor.runNote.test.ts new file mode 100644 index 000000000..bd601df33 --- /dev/null +++ b/src/choiceExecutor.runNote.test.ts @@ -0,0 +1,160 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { TFile } from "obsidian"; +import type ICaptureChoice from "./types/choices/ICaptureChoice"; +import type IChoice from "./types/choices/IChoice"; +import type IMacroChoice from "./types/choices/IMacroChoice"; +import type { ICommand } from "./types/macros/ICommand"; +import { CommandType } from "./types/macros/CommandType"; +import type { IUserScript } from "./types/macros/IUserScript"; +import type { IChoiceExecutor } from "./IChoiceExecutor"; + +const { scripts } = vi.hoisted(() => ({ + scripts: new Map unknown>(), +})); + +vi.mock("./gui/choiceList/ChoiceView.svelte", () => ({})); +vi.mock("./gui/GlobalVariables/GlobalVariablesView.svelte", () => ({})); +vi.mock("./main", () => ({ __esModule: true, default: class QuickAddMock {} })); +vi.mock("./quickAddSettingsTab", () => ({ + DEFAULT_SETTINGS: {}, + QuickAddSettingsTab: class {}, +})); +vi.mock("./settingsStore", () => ({ + settingsStore: { + getState: () => ({ onePageInputEnabled: false, ai: {}, disableOnlineFeatures: true }), + }, +})); +vi.mock("./utils/frontmatterPropertyLinks", () => ({ + getFocusedPropertyTarget: vi.fn(() => null), +})); +vi.mock("./utils/fileOpening", async (importOriginal) => ({ + ...(await importOriginal()), + getOpenFileOriginLeaf: vi.fn(() => null), +})); +vi.mock("./utils/userScript", async (importOriginal) => ({ + ...(await importOriginal()), + loadUserScript: async (command: IUserScript) => ({ + script: scripts.get(command.path), + settings: undefined, + }), +})); +// A Capture that reports writing to its target, or failing when the target is "fail". +vi.mock("./engine/CaptureChoiceEngine", () => ({ + CaptureChoiceEngine: class { + constructor( + _app: unknown, + _plugin: unknown, + private choice: ICaptureChoice, + private executor: IChoiceExecutor, + ) {} + async run() { + this.executor.recordExecutionResult?.( + this.choice.captureTo === "fail" + ? { status: "error", reason: "failed" } + : { status: "success", file: fileAt(this.choice.captureTo), effect: "changed" }, + ); + } + }, +})); + +function fileAt(path: string): TFile { + return { path, basename: path.replace(/\.md$/, "") } as TFile; +} + +const { ChoiceExecutor } = await import("./choiceExecutor"); + +const app = { workspace: { getActiveFile: () => null } } as never; +const plugin = { app, settings: { choices: [] } } as never; + +let seen: (string | null)[] = []; + +function readsRunNote(executor: IChoiceExecutor, name: string, then?: () => Promise): ICommand { + const path = `${name}.js`; + scripts.set(path, async () => { + seen.push(executor.runNote?.path ?? null); + await then?.(); + }); + return { id: `${name}-step`, name, type: CommandType.UserScript, path, settings: {} } as IUserScript; +} + +function capture(target: string): ICaptureChoice { + return { id: `capture-${target}`, name: target, type: "Capture", captureTo: target } as ICaptureChoice; +} + +function nested(choice: IChoice): ICommand { + return { id: `nested-${choice.id}`, name: choice.name, type: CommandType.NestedChoice, choice } as ICommand; +} + +function macro(name: string, commands: ICommand[]): IMacroChoice { + return { + id: name, + name, + type: "Macro", + command: false, + runOnStartup: false, + macro: { id: `${name}-macro`, name, commands }, + }; +} + +describe("ChoiceExecutor run note", () => { + beforeEach(() => { + scripts.clear(); + seen = []; + }); + + it("is the note the last write in the run wrote to", async () => { + const executor = new ChoiceExecutor(app, plugin); + await executor.execute(macro("M", [ + readsRunNote(executor, "before"), + nested(capture("a.md")), + readsRunNote(executor, "afterA"), + nested(capture("b.md")), + readsRunNote(executor, "afterB"), + ])); + expect(seen).toEqual([null, "a.md", "b.md"]); + }); + + it("keeps the last written note when a later write fails", async () => { + const executor = new ChoiceExecutor(app, plugin); + await executor.execute(macro("M", [ + nested(capture("a.md")), + nested(capture("fail")), + readsRunNote(executor, "after"), + ])); + expect(seen).toEqual(["a.md"]); + }); + + it("is visible to a nested run", async () => { + const executor = new ChoiceExecutor(app, plugin); + await executor.execute(macro("Outer", [ + nested(capture("a.md")), + nested(macro("Inner", [readsRunNote(executor, "inner")])), + ])); + expect(seen).toEqual(["a.md"]); + }); + + it("tells what note a run ended on without touching the run note", async () => { + const executor = new ChoiceExecutor(app, plugin); + await executor.execute(macro("Outer", [ + nested(capture("a.md")), + readsRunNote(executor, "step", async () => { + const wrote = await executor.noteEndedOn(() => executor.execute(capture("b.md"))); + const same = await executor.noteEndedOn(() => executor.execute(capture("b.md"))); + const none = await executor.noteEndedOn(() => executor.execute(macro("Scripts", [readsRunNote(executor, "inner")]))); + seen.push(wrote?.path ?? null, same?.path ?? null, none?.path ?? null, executor.runNote?.path ?? null); + }), + ])); + // The step saw a.md; the inner script saw b.md; then: wrote, same note again, none, and the run note untouched. + expect(seen).toEqual(["a.md", "b.md", "b.md", "b.md", null, "b.md"]); + }); + + it("starts empty for every outermost run and is cleared when it ends", async () => { + const executor = new ChoiceExecutor(app, plugin); + await executor.execute(capture("a.md")); + expect(executor.runNote).toBeNull(); + + executor.runNote = fileAt("stale.md"); + await executor.execute(macro("M", [readsRunNote(executor, "first")])); + expect(seen).toEqual([null]); + }); +}); diff --git a/src/choiceExecutor.sequence.test.ts b/src/choiceExecutor.sequence.test.ts new file mode 100644 index 000000000..d099bf069 --- /dev/null +++ b/src/choiceExecutor.sequence.test.ts @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type ICaptureChoice from "./types/choices/ICaptureChoice"; +import type IChoice from "./types/choices/IChoice"; +import type IMacroChoice from "./types/choices/IMacroChoice"; +import { CaptureChoice } from "./types/choices/CaptureChoice"; +import { lowerNode } from "./v3/lower"; +import { migrateChoice } from "./v3/migrate"; +import type { Action, ActionNode } from "./v3/model"; + +const { state, runSteps, captured } = vi.hoisted(() => ({ + state: { actions: [] as ActionNode[], choices: [] as IChoice[] }, + runSteps: vi.fn(async () => {}), + captured: [] as string[], +})); + +vi.mock("./gui/choiceList/ChoiceView.svelte", () => ({})); +vi.mock("./gui/GlobalVariables/GlobalVariablesView.svelte", () => ({})); +vi.mock("./main", () => ({ __esModule: true, default: class QuickAddMock {} })); +vi.mock("./quickAddSettingsTab", () => ({ DEFAULT_SETTINGS: {}, QuickAddSettingsTab: class {} })); +vi.mock("./settingsStore", () => ({ + settingsStore: { + getState: () => ({ + onePageInputEnabled: false, + ai: {}, + disableOnlineFeatures: true, + migrations: { migrateToV3Actions: true }, + actions: state.actions, + choices: state.choices, + }), + }, +})); +vi.mock("./utils/frontmatterPropertyLinks", () => ({ getFocusedPropertyTarget: vi.fn(() => null) })); +vi.mock("./utils/fileOpening", async (importOriginal) => ({ + ...(await importOriginal()), + getOpenFileOriginLeaf: vi.fn(() => null), +})); +vi.mock("./v3/run/stepRunner", () => ({ runSteps })); +vi.mock("./engine/CaptureChoiceEngine", () => ({ + CaptureChoiceEngine: class { + constructor(_app: unknown, _plugin: unknown, private choice: ICaptureChoice) {} + async run() { + captured.push(this.choice.captureTo); + } + }, +})); + +const { ChoiceExecutor } = await import("./choiceExecutor"); + +const app = { workspace: { getActiveFile: () => null } } as never; +const plugin = { app, settings: { choices: [] } } as never; + +/** Stores the actions, and the choices they lower to as loading makes them. */ +function store(...actions: Action[]): IChoice[] { + state.actions = actions; + state.choices = JSON.parse(JSON.stringify(actions.map(lowerNode))); + return state.choices; +} + +function captureAction(target: string): Action { + const choice = new CaptureChoice("Log"); + choice.captureTo = target; + return migrateChoice(choice).node as Action; +} + +describe("ChoiceExecutor running a Macro", () => { + beforeEach(() => { + store(); + captured.length = 0; + runSteps.mockClear(); + }); + + it("runs a stored action that is more than one write through the step runner", async () => { + const write = captureAction("Inbox.md"); + const action: Action = { ...write, steps: [...write.steps, { id: "wait", type: "wait", time: 0 }] }; + const [choice] = store(action) as IMacroChoice[]; + const executor = new ChoiceExecutor(app, plugin); + + await executor.execute(choice!); + + expect(runSteps).toHaveBeenCalledTimes(1); + expect(runSteps).toHaveBeenCalledWith(action.steps, expect.objectContaining({ executor, action, chain: [choice] })); + expect(captured).toEqual([]); + }); + + it("runs the steps as the builder left them, before a save stores them", async () => { + const write = captureAction("Inbox.md"); + const action: Action = { ...write, steps: [...write.steps, { id: "wait", type: "wait", time: 0 }] }; + const [choice] = store(action) as IMacroChoice[]; + // The builder edits the choice; the stored action changes on the next save. + (choice!.macro.commands[1] as unknown as { time: number }).time = 500; + + await new ChoiceExecutor(app, plugin).execute(choice!); + + expect(runSteps).toHaveBeenCalledWith( + [action.steps[0], { id: "wait", type: "wait", time: 500 }], + expect.objectContaining({ action: expect.objectContaining({ id: action.id }) }), + ); + }); + + it("runs a Macro with no stored action on the macro engine", async () => { + const write = captureAction("Inbox.md"); + const choice = lowerNode({ ...write, steps: [...write.steps, { id: "wait", type: "wait", time: 0 }] }) as IMacroChoice; + + await new ChoiceExecutor(app, plugin).execute(choice); + + expect(runSteps).not.toHaveBeenCalled(); + expect(captured).toEqual(["Inbox.md"]); + }); + + it("runs a stored action that is one write, kept as a Macro, on the macro engine", async () => { + const action: Action = { ...captureAction("Inbox.md"), provenance: { migratedFrom: "Macro" } }; + const [choice] = store(action) as IMacroChoice[]; + expect(choice!.type).toBe("Macro"); + + await new ChoiceExecutor(app, plugin).execute(choice!); + + expect(runSteps).not.toHaveBeenCalled(); + expect(captured).toEqual(["Inbox.md"]); + }); +}); diff --git a/src/choiceExecutor.ts b/src/choiceExecutor.ts index 1c40c3fed..7e74274d2 100644 --- a/src/choiceExecutor.ts +++ b/src/choiceExecutor.ts @@ -1,4 +1,4 @@ -import { Notice, type App, type WorkspaceLeaf } from "obsidian"; +import { Notice, type App, type TFile, type WorkspaceLeaf } from "obsidian"; import { currentDispatchChain, enterChoice, type ChoiceChain } from "./engine/choiceChain"; import type QuickAdd from "./main"; import type IChoice from "./types/choices/IChoice"; @@ -6,6 +6,7 @@ import type ITemplateChoice from "./types/choices/ITemplateChoice"; import type ICaptureChoice from "./types/choices/ICaptureChoice"; import type IMacroChoice from "./types/choices/IMacroChoice"; import { TemplateChoiceEngine } from "./engine/TemplateChoiceEngine"; +import { checkTemplateSource } from "./engine/templateSource"; import { CaptureChoiceEngine } from "./engine/CaptureChoiceEngine"; import { MacroChoiceEngine } from "./engine/MacroChoiceEngine"; import type { IChoiceExecutor } from "./IChoiceExecutor"; @@ -19,7 +20,11 @@ import { runOnePagePreflight } from "./preflight/runOnePagePreflight"; import { MacroAbortError } from "./errors/MacroAbortError"; import { ChoiceAbortError } from "./errors/ChoiceAbortError"; import { UserCancelError } from "./errors/UserCancelError"; -import { isCancellationError, reportError } from "./utils/errorUtils"; +import { isCancellationError, reportError, reportRefusal } from "./utils/errorUtils"; +import { RefusalError } from "./errors/RefusalError"; +import { failureReason } from "./engine/choiceOutcomeRecorder"; +import { showResultNotice } from "./gui/resultNotice"; +import { runLog } from "./runLog"; import { getOpenFileOriginLeaf } from "./utils/fileOpening"; import { InputPromptDraftStore } from "./utils/InputPromptDraftStore"; import type { ChoiceOutcome } from "./types/ChoiceOutcome"; @@ -41,6 +46,27 @@ import type { LoadedUserScript } from "./utils/userScript"; import { withPreparedChoiceInputs, clearPreparedChoiceInputs, createPreparedChoiceInputState } from "./preflight/preparedChoiceInputs"; import { isTemplateChoice } from "./types/choices/choiceType"; import { shouldRunTemplateNoteDiscovery } from "./utils/templateNoteDiscoveryEligibility"; +import { currentActions, findAction } from "./v3/storage"; +import { compactGroup } from "./v3/lower"; +import type { Action } from "./v3/model"; +import { runSteps } from "./v3/run/stepRunner"; + +type RunWrite = Extract & { file: TFile }; +/** A run's result for the log; a bare success recorded nothing. */ +type RunResult = ChoiceOutcome | { status: "success"; effect?: undefined; file?: undefined }; + +/** The outcome of a run that threw, or stopped with an abort signal. */ +function outcomeOfThrow(error: unknown): ChoiceOutcome { + // A refusal stops the run like an abort, but the run did not do its job. + if (error instanceof RefusalError) return { status: "error", reason: error.message }; + if (error instanceof UserCancelError || isCancellationError(error)) { + return { status: "cancelled", cancelKind: "user" }; + } + if (error instanceof MacroAbortError) { + return { status: "cancelled", cancelKind: "aborted", reason: error.message }; + } + return { status: "error", reason: failureReason(error) }; +} export class ChoiceExecutor implements IChoiceExecutor { public variables: Map = new Map(); @@ -62,8 +88,16 @@ export class ChoiceExecutor implements IChoiceExecutor { public triggerContext: QuickAddTriggerContext | null = null; public clocks?: RunClocks; public pickDate = false; + public runNote: TFile | null = null; + /** How many times a run has recorded the note it ended on; noteEndedOn reads it. */ + private notesRecorded = 0; private pendingAbort: MacroAbortError | null = null; private pendingResult: ChoiceOutcome | null = null; + /** The latest result any choice of the outermost run recorded. */ + private lastResult: ChoiceOutcome | null = null; + /** The latest success with a note in the outermost run: what made the run note. */ + private lastWrite: RunWrite | null = null; + private runStartedAt = 0; private executionDepth = 0; /** * Ancestry of a run started without one: the dispatching run's chain when @@ -88,10 +122,30 @@ export class ChoiceExecutor implements IChoiceExecutor { recordExecutionResult(result: ChoiceOutcome) { this.pendingResult = result; + this.lastResult = result; + if (result.status === "success" && result.file) { + this.runNote = result.file; + this.notesRecorded++; + // The notice and the log describe the run's last write; a later step + // that left its note alone does not take its place. + if (result.effect !== "unchanged" || this.lastWrite === null || this.lastWrite.effect === "unchanged") { + this.lastWrite = { ...result, file: result.file }; + } + } + } + + async noteEndedOn(run: () => Promise): Promise { + const seen = this.notesRecorded; + await run(); + return this.notesRecorded === seen ? null : this.runNote; } private beginExecutionContext(): void { if (this.executionDepth === 0) { + this.runNote = null; + this.lastResult = null; + this.lastWrite = null; + this.runStartedAt = Date.now(); this.focusedProperty = this.focusedPropertyOverride !== undefined ? this.focusedPropertyOverride @@ -129,6 +183,9 @@ export class ChoiceExecutor implements IChoiceExecutor { this.triggerContext = null; this.clocks = undefined; this.pickDate = false; + this.runNote = null; + this.lastResult = null; + this.lastWrite = null; // Preloaded script modules are scoped to ONE outermost execution: a // cancelled/aborted run must not strand its entries, or a later // trigger on a long-lived executor (api.executeChoice callers reuse @@ -143,6 +200,7 @@ export class ChoiceExecutor implements IChoiceExecutor { async execute( choice: IChoice, ancestry: ChoiceChain = this.dispatchAncestry, + inline?: Action, ): Promise { const chain = enterChoice(choice, ancestry); this.pendingAbort = null; @@ -151,11 +209,16 @@ export class ChoiceExecutor implements IChoiceExecutor { // enclosing executeWithOutcome(): snapshot and restore pendingResult so the nested // choice's recorded result never leaks into the outer choice's reported outcome. const savedResult = this.pendingResult; + this.pendingResult = null; + const outermost = this.executionDepth === 0; + let thrown: { error: unknown } | null = null; this.beginExecutionContext(); const originLeaf = getOpenFileOriginLeaf(this.app); const promptDraftStore = InputPromptDraftStore.getInstance(); const draftScope = promptDraftStore.beginExecutionScope(); try { + // A Template checks its template before the form or the date asks anything. + if (isTemplateChoice(choice)) checkTemplateSource(this.app, choice); await this.runOnePagePreflightIfEnabled(choice); await withPreparedChoiceInputs(this, choice.id, async () => { await this.applyDateOrigin(choice); @@ -174,7 +237,12 @@ export class ChoiceExecutor implements IChoiceExecutor { } case "Macro": { const macroChoice: IMacroChoice = choice as IMacroChoice; - await this.onChooseMacroType(macroChoice, originLeaf, chain); + const action = inline ?? findAction(currentActions(settingsStore.getState()), choice.id); + if (action && !compactGroup(action)) { + await this.onChooseSequence(macroChoice, action, originLeaf, chain); + } else { + await this.onChooseMacroType(macroChoice, originLeaf, chain); + } break; } case "Multi": { @@ -195,13 +263,57 @@ export class ChoiceExecutor implements IChoiceExecutor { }); } catch (error) { promptDraftStore.rollbackExecutionScope(draftScope); + // A refusal from outside the engines (the one-page form, the date origin). + if (error instanceof RefusalError) reportRefusal(error, choice.name); + thrown = { error }; throw error; } finally { + if (outermost) this.finishRun(choice, thrown ? outcomeOfThrow(thrown.error) : this.settledOutcome(choice)); this.pendingResult = savedResult; this.endExecutionContext(); } } + /** + * What an outermost execute() did. A Template or Capture records its own result; a + * Macro or folder succeeds unless the last choice it ran failed. Null is a success + * that recorded nothing, such as a macro of scripts. + */ + private settledOutcome(choice: IChoice): ChoiceOutcome | null { + if (this.pendingAbort) return outcomeOfThrow(this.pendingAbort); + if (choice.type === "Template" || choice.type === "Capture") { + // Nothing recorded and no abort: the engine swallowed a failure. + return this.pendingResult ?? { status: "error" }; + } + return this.lastResult?.status === "error" ? this.lastResult : null; + } + + /** + * Logs an outermost execute() and, when it wrote a note, says what it did and where. + * The outcome-returning entry point (URI callbacks, the CLI) reports to its caller + * instead, so it only logs. + */ + private finishRun(choice: IChoice, outcome: ChoiceOutcome | null): void { + const write = !outcome || outcome.status === "success" ? this.lastWrite : null; + this.logRun(choice, write ?? outcome ?? { status: "success" }); + if (write && settingsStore.getState().showCaptureNotification) { + showResultNotice(this.app, choice.name, write); + } + } + + private logRun(choice: IChoice, result: RunResult): void { + runLog.append({ + at: new Date().toISOString(), + choiceId: choice.id, + choiceName: choice.name, + status: result.status, + ...(result.status === "success" + ? { effect: result.effect, path: result.file?.path } + : { reason: result.reason }), + durationMs: Date.now() - this.runStartedAt, + }); + } + async executeWithFocusedProperty( choice: IChoice, focusedProperty: FrontmatterPropertyTarget | null, @@ -236,6 +348,15 @@ export class ChoiceExecutor implements IChoiceExecutor { */ async executeWithOutcome( choice: ITemplateChoice | ICaptureChoice, + ): Promise { + const outermost = this.executionDepth === 0; + const outcome = await this.runWithOutcome(choice); + if (outermost) this.logRun(choice, outcome); + return outcome; + } + + private async runWithOutcome( + choice: ITemplateChoice | ICaptureChoice, ): Promise { const chain = enterChoice(choice, this.dispatchAncestry); this.pendingAbort = null; @@ -245,6 +366,8 @@ export class ChoiceExecutor implements IChoiceExecutor { const promptDraftStore = InputPromptDraftStore.getInstance(); const draftScope = promptDraftStore.beginExecutionScope(); try { + // A Template checks its template before the form or the date asks anything. + if (isTemplateChoice(choice)) checkTemplateSource(this.app, choice); await this.runOnePagePreflightIfEnabled(choice); return await withPreparedChoiceInputs(this, choice.id, async (): Promise => { await this.applyDateOrigin(choice); @@ -255,18 +378,12 @@ export class ChoiceExecutor implements IChoiceExecutor { await this.onChooseCaptureType(choice as ICaptureChoice, originLeaf, chain); } - if (this.pendingAbort) { + const abort = this.consumeAbortSignal(); + if (abort) { promptDraftStore.rollbackExecutionScope(draftScope); - const abort = this.consumeAbortSignal(); - const isUser = abort instanceof UserCancelError; - return { - status: "cancelled", - cancelKind: isUser ? "user" : "aborted", - // Only surface the message for an involuntary abort (e.g. the - // non-interactive prompt guards). A user dismissal keeps its stable - // "cancelled by user" text and leaks no internals. - reason: isUser ? undefined : abort?.message, - }; + // A user dismissal keeps its stable "cancelled by user" text and leaks + // no internals; an involuntary abort or a refusal says why. + return outcomeOfThrow(abort); } promptDraftStore.commitExecutionScope(draftScope); @@ -277,13 +394,8 @@ export class ChoiceExecutor implements IChoiceExecutor { }); } catch (error) { promptDraftStore.rollbackExecutionScope(draftScope); - if (error instanceof UserCancelError) { - // Stable user-facing text; no internal message surfaced. - return { status: "cancelled", cancelKind: "user" }; - } - if (error instanceof MacroAbortError) { - return { status: "cancelled", cancelKind: "aborted", reason: error.message }; - } + if (error instanceof RefusalError) reportRefusal(error, choice.name); + if (error instanceof MacroAbortError) return outcomeOfThrow(error); reportError(error, "Error executing choice from URI"); return { status: "error", @@ -453,6 +565,35 @@ export class ChoiceExecutor implements IChoiceExecutor { macroChoice: IMacroChoice, originLeaf: WorkspaceLeaf | null, chain: ChoiceChain, + ) { + await this.withMacroEngine(macroChoice, originLeaf, chain, (macroEngine) => macroEngine.run()); + } + + /** A stored action that is more than one write runs step by step. */ + private async onChooseSequence( + macroChoice: IMacroChoice, + action: Action, + originLeaf: WorkspaceLeaf | null, + chain: ChoiceChain, + ) { + await this.withMacroEngine(macroChoice, originLeaf, chain, (macroEngine) => + runSteps(action.steps, { + app: this.app, + plugin: this.plugin, + executor: this, + action, + chain, + originLeaf, + macroEngine, + }), + ); + } + + private async withMacroEngine( + macroChoice: IMacroChoice, + originLeaf: WorkspaceLeaf | null, + chain: ChoiceChain, + run: (macroEngine: MacroChoiceEngine) => Promise, ) { const macroEngine = new MacroChoiceEngine( this.app, @@ -468,7 +609,7 @@ export class ChoiceExecutor implements IChoiceExecutor { const previousOverride = this.macroOnePageInput; this.macroOnePageInput = macroChoice.onePageInput ?? previousOverride; try { - await macroEngine.run(); + await run(macroEngine); } finally { this.macroOnePageInput = previousOverride; } diff --git a/src/cli/executeChoice.test.ts b/src/cli/executeChoice.test.ts index a4da4de85..92a264db4 100644 --- a/src/cli/executeChoice.test.ts +++ b/src/cli/executeChoice.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { createChoiceExecutor } from "../../tests/helpers/createChoiceExecutor"; import { ChoiceAbortError } from "../errors/ChoiceAbortError"; import { UserCancelError } from "../errors/UserCancelError"; +import { claimRefusal, refuse } from "../errors/RefusalError"; import type { IChoiceExecutor } from "../IChoiceExecutor"; import type IChoice from "../types/choices/IChoice"; import { executeChoice } from "./executeChoice"; @@ -52,6 +53,17 @@ describe("CLI executeChoice without verify", () => { }); }); + it("reports a sequence's refusal as a failure with the sentence, not an abort", async () => { + const refusal = refuse("no note is open", "there is nothing to add to"); + claimRefusal(refusal, "Quick capture"); + const run = executor(() => Promise.resolve(), refusal); + + await expect(executeChoice(run, macro, false)).resolves.toEqual({ + ok: false, + error: "Quick capture: no note is open, so there is nothing to add to.", + }); + }); + it("lets a real failure through so the CLI reports it as an error", async () => { const failure = new Error("Template file not found"); const run = executor(() => Promise.reject(failure)); diff --git a/src/cli/executeChoice.ts b/src/cli/executeChoice.ts index 91ed91d90..d61b4f00c 100644 --- a/src/cli/executeChoice.ts +++ b/src/cli/executeChoice.ts @@ -1,4 +1,5 @@ import { MacroAbortError } from "../errors/MacroAbortError"; +import { RefusalError } from "../errors/RefusalError"; import type { IChoiceExecutor } from "../IChoiceExecutor"; import type { ChoiceEffect } from "../types/ChoiceOutcome"; import type IChoice from "../types/choices/IChoice"; @@ -48,6 +49,7 @@ export async function executeChoice( if (!(error instanceof MacroAbortError)) throw error; aborted = error; } + if (aborted instanceof RefusalError) return { ok: false, error: aborted.message }; return aborted ? { ok: false, aborted: true, error: aborted.message || "Choice execution aborted" } : { ok: true, verified: false, effect: "unknown" }; diff --git a/src/cli/inspectChoices.test.ts b/src/cli/inspectChoices.test.ts new file mode 100644 index 000000000..b10aa60e8 --- /dev/null +++ b/src/cli/inspectChoices.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from "vitest"; +import { TFile, type App } from "obsidian"; +import type QuickAdd from "../main"; +import { createTemplateChoice } from "../../tests/helpers/preflight/choices"; + +// Mock the heavy leaves of the executor's import graph (mirrors +// choiceExecutor.onePageGate.test.ts). +vi.mock("../gui/choiceList/ChoiceView.svelte", () => ({})); +vi.mock("../gui/GlobalVariables/GlobalVariablesView.svelte", () => ({})); +vi.mock("../main", () => ({ __esModule: true, default: class QuickAddMock {} })); +vi.mock("../quickAddSettingsTab", () => ({ DEFAULT_SETTINGS: {}, QuickAddSettingsTab: class {} })); + +const { checkChoiceHandler } = await import("./inspectChoices"); + +describe("quickadd:check", () => { + it("leaves out a template file's {{VALUE:title}}, which the note's title fills", async () => { + const template = Object.assign(new TFile(), { path: "Templates/Meeting.md", extension: "md" }); + const app = { + workspace: { getActiveFile: () => null }, + vault: { + getAbstractFileByPath: (path: string) => (path === template.path ? template : null), + cachedRead: async () => "# {{VALUE:Title}}\nWith {{VALUE:Who}}", + }, + metadataCache: { getFileCache: () => null }, + } as unknown as App; + const choice = { + ...createTemplateChoice(template.path), + name: "Meeting note", + fileNameFormat: { enabled: true, format: "{{VALUE}}" }, + }; + const plugin = { + app, + settings: { inputPrompt: "single-line", globalVariables: {}, choices: [choice] }, + getChoiceByName: () => choice, + } as unknown as QuickAdd; + + const result = await checkChoiceHandler(plugin, { choice: "Meeting note" }); + + expect(result).toMatchObject({ missingFlags: ["value-value=", "value-Who="] }); + }); +}); diff --git a/src/cli/inspectChoices.ts b/src/cli/inspectChoices.ts index 3887e0f7b..f707f9a24 100644 --- a/src/cli/inspectChoices.ts +++ b/src/cli/inspectChoices.ts @@ -6,6 +6,9 @@ import type IMacroChoice from "../types/choices/IMacroChoice"; import type ICaptureChoice from "../types/choices/ICaptureChoice"; import type ITemplateChoice from "../types/choices/ITemplateChoice"; import { getWritePosition } from "../engine/captureAction"; +import { checkTemplateSource } from "../engine/templateSource"; +import { claimRefusal, RefusalError } from "../errors/RefusalError"; +import { isTemplateChoice } from "../types/choices/choiceType"; import { deriveFolderMode } from "../gui/ChoiceBuilder/folderMode"; import { childChoicesOf, isChoiceLike, rootChoicesOf } from "../utils/choiceUtils"; import { collectChoiceRequirements, getUnresolvedRequirements, listDeferredMacroSteps } from "../preflight/collectChoiceRequirements"; @@ -135,12 +138,20 @@ export async function checkChoiceHandler( ); setExecutorVariables(choiceExecutor, variables); - const requirements = await collectChoiceRequirements( - plugin.app, - plugin, - choiceExecutor, - choice, - ); + let requirements; + try { + // A template that is not there is the one thing to report: nothing is asked. + if (isTemplateChoice(choice)) checkTemplateSource(plugin.app, choice); + requirements = await collectChoiceRequirements( + plugin.app, + plugin, + choiceExecutor, + choice, + ); + } catch (error) { + if (!(error instanceof RefusalError)) throw error; + return { ok: false, error: claimRefusal(error, choice.name), choice: describeChoice(choice) }; + } const unresolved = getUnresolvedRequirements( requirements, choiceExecutor.variables, diff --git a/src/cli/registerQuickAddCliHandlers.test.ts b/src/cli/registerQuickAddCliHandlers.test.ts index 752d4f1c5..830815163 100644 --- a/src/cli/registerQuickAddCliHandlers.test.ts +++ b/src/cli/registerQuickAddCliHandlers.test.ts @@ -76,7 +76,12 @@ function createPlugin(choices: IChoice[]) { const { byName, byId } = flattenChoices(choices); const plugin = { - app: {}, + app: { + vault: { + getAbstractFileByPath: (path: string) => + path === "Templates/T.md" ? Object.assign(new TFile(), { path }) : null, + }, + }, settings: { choices, }, @@ -120,7 +125,8 @@ describe("registerQuickAddCliHandlers", () => { name: "Template Choice", type: "Template", command: true, - }; + templatePath: "Templates/T.md", + } as IChoice; const nestedCaptureChoice: IChoice = { id: "capture-id", @@ -325,6 +331,24 @@ describe("registerQuickAddCliHandlers", () => { expect(executors[0].execute).not.toHaveBeenCalled(); }); + it("refuses a Template whose template is not there before listing missing inputs", async () => { + const gone = { ...templateChoice, id: "gone", name: "Gone", templatePath: "Templates/Gone.md" } as IChoice; + const { plugin, handlers } = createPlugin([gone]); + registerQuickAddCliHandlers(plugin); + const run = handlers.find((handler) => handler.command === "quickadd:run"); + collectChoiceRequirementsMock.mockClear(); + + const payload = JSON.parse(String(await run!.handler({ choice: "Gone" }))); + + expect(payload).toMatchObject({ + ok: false, + error: "Gone: the template Templates/Gone.md does not exist, so no note was created. Pick a template on the choice's page.", + }); + expect(payload.missing).toBeUndefined(); + expect(collectChoiceRequirementsMock).not.toHaveBeenCalled(); + expect(executors[0].execute).not.toHaveBeenCalled(); + }); + function withTemplateFile(plugin: QuickAdd, existingPath: string) { (plugin as unknown as { app: unknown }).app = { vault: { @@ -872,6 +896,25 @@ describe("registerQuickAddCliHandlers", () => { expect(executors[0].execute).not.toHaveBeenCalled(); }); + it("reports a Template whose template is not there instead of its inputs", async () => { + const gone = { ...templateChoice, id: "gone", name: "Gone", templatePath: "Templates/Gone.md" } as IChoice; + const { plugin, handlers } = createPlugin([gone]); + registerQuickAddCliHandlers(plugin); + const check = handlers.find((handler) => handler.command === "quickadd:check"); + collectChoiceRequirementsMock.mockClear(); + + const payload = JSON.parse(String(await check!.handler({ choice: "Gone" }))); + + expect(payload).toMatchObject({ + ok: false, + error: "Gone: the template Templates/Gone.md does not exist, so no note was created. Pick a template on the choice's page.", + choice: { id: "gone", name: "Gone" }, + }); + expect(payload.missing).toBeUndefined(); + expect(payload.requiredInputCount).toBeUndefined(); + expect(collectChoiceRequirementsMock).not.toHaveBeenCalled(); + }); + it("includes deferred macro steps in quickadd:check", async () => { const { plugin, handlers } = createPlugin([macroChoice]); registerQuickAddCliHandlers(plugin); diff --git a/src/cli/runChoice.ts b/src/cli/runChoice.ts index 345ce32ff..fe1bb1c53 100644 --- a/src/cli/runChoice.ts +++ b/src/cli/runChoice.ts @@ -2,11 +2,13 @@ import type { CliData } from "obsidian"; import { ChoiceExecutor } from "../choiceExecutor"; import { QA_INTERNAL_DATE_ORIGIN } from "../constants"; import { createFolderTemplateChoice } from "../engine/runTemplateFromFolder"; +import { checkTemplateSource } from "../engine/templateSource"; import { interactivePromptServer } from "../interactive/interactivePromptServer"; import { RemotePromptProvider } from "../interactive/promptProvider"; import type QuickAdd from "../main"; import { collectChoiceRequirements, getUnresolvedRequirements } from "../preflight/collectChoiceRequirements"; import type IChoice from "../types/choices/IChoice"; +import { isTemplateChoice } from "../types/choices/choiceType"; import { getTemplateFile } from "../utils/templateFolderUtils"; import { applyInvocationDate } from "../utils/resolveDateOrigin"; import { executeChoice } from "./executeChoice"; @@ -15,6 +17,8 @@ import { RESERVED_INTERACTIVE_PARAMS, RESERVED_RUN_PARAMS, RESERVED_RUN_TEMPLATE_PARAMS, setExecutorVariables, toMissingFieldSummary, } from "./params"; +import { RefusalError } from "../errors/RefusalError"; +import { reportRefusal } from "../utils/errorUtils"; async function runResolvedChoice( plugin: QuickAdd, @@ -37,9 +41,19 @@ async function runResolvedChoice( executor.interactive = isTruthy(params.ui); if (!executor.interactive) { // Reuse loaded script modules: collecting inputs can execute their top level. - const requirements = await collectChoiceRequirements(plugin.app, plugin, executor, choice, { - preloadedUserScripts: executor.preloadedUserScripts, - }); + let requirements; + try { + // Nothing is asked for a template that is not there. + if (isTemplateChoice(choice)) checkTemplateSource(plugin.app, choice); + requirements = await collectChoiceRequirements(plugin.app, plugin, executor, choice, { + preloadedUserScripts: executor.preloadedUserScripts, + }); + } catch (error) { + // Reading the inputs can already meet what is not set up, such as {{DAILY}}. + if (!(error instanceof RefusalError)) throw error; + reportRefusal(error, choice.name); + return { ok: false, error: error.message, choice: summary }; + } const unresolved = getUnresolvedRequirements(requirements, executor.variables); if (unresolved.length) { return { diff --git a/src/constants.ts b/src/constants.ts index c399bdf08..48ab2fc2d 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -106,7 +106,8 @@ export const MARKDOWN_FILE_EXTENSION_REGEX = new RegExp(/\.md$/i); export const CANVAS_FILE_EXTENSION_REGEX = new RegExp(/\.canvas$/i); export const BASE_FILE_EXTENSION_REGEX = new RegExp(/\.base$/i); export const JAVASCRIPT_FILE_EXTENSION_REGEX = new RegExp(/\.js$/i); -export const MACRO_REGEX = new RegExp(/{{MACRO:([^\n\r}]*)}}/i); +// {{ACTION:name}} runs any choice; {{MACRO:name}} is its older name and stays. +export const MACRO_REGEX = new RegExp(/{{(?:MACRO|ACTION):([^\n\r}]*)}}/i); export const TEMPLATE_REGEX = new RegExp( /{{TEMPLATE:([^\n\r}]*\.(?:md|canvas|base))}}/i, ); @@ -186,6 +187,9 @@ export const TEMPLATE_SYNTAX_SUGGEST_REGEX = new RegExp( export const MACRO_SYNTAX_SUGGEST_REGEX = new RegExp( /{{[M]?[A]?[C]?[R]?[O]?[:]?$|{{MACRO:[^\n\r}]*}}$/i, ); +export const ACTION_SYNTAX_SUGGEST_REGEX = new RegExp( + /{{[A]?[C]?[T]?[I]?[O]?[N]?[:]?$|{{ACTION:[^\n\r}]*}}$/i, +); export const MATH_VALUE_SYNTAX_SUGGEST_REGEX = new RegExp( /{{[M]?[V]?[A]?[L]?[U]?[E]?[}]?[}]?/i, ); @@ -202,6 +206,9 @@ export const SELECTED_SYNTAX_SUGGEST_REGEX = new RegExp( export const CLIPBOARD_SYNTAX_SUGGEST_REGEX = new RegExp( /{{[C]?[L]?[I]?[P]?[B]?[O]?[A]?[R]?[D]?[}]?[}]?$/i, ); +export const NOTE_SYNTAX_SUGGEST_REGEX = new RegExp( + /{{[N]?[O]?[T]?[E]?[}]?[}]?$|{{NOTE\|[l]?[i]?[n]?[k]?[}]?[}]?$/i, +); export const DAILY_SYNTAX_SUGGEST_REGEX = new RegExp( /{{[D]?[A]?[I]?[L]?[Y]?[}]?[}]?$|{{DAILY\|[l]?[i]?[n]?[k]?[}]?[}]?$/i, ); diff --git a/src/docs.ts b/src/docs.ts index ceb636885..b3249851c 100644 --- a/src/docs.ts +++ b/src/docs.ts @@ -23,6 +23,11 @@ export const DOCS_URLS = { packages: `${DOCS_BASE_URL}/docs/Choices/Packages/`, } as const; +/** A docs site path (`/docs/...` or `docs/...`) as a full URL. */ +export function docsUrl(path: string): string { + return `${DOCS_BASE_URL}/${path.replace(/^\//, "")}`; +} + /** * Open a documentation URL in the user's browser. * diff --git a/src/engine/CaptureChoiceEngine.audit-capture.test.ts b/src/engine/CaptureChoiceEngine.audit-capture.test.ts index 6f3ee9dd7..2905aab0d 100644 --- a/src/engine/CaptureChoiceEngine.audit-capture.test.ts +++ b/src/engine/CaptureChoiceEngine.audit-capture.test.ts @@ -65,17 +65,18 @@ vi.mock("../formatters/captureChoiceFormatter", () => { return formatContentOnlyMock(content); } async insertFormattedContent(...args: unknown[]) { - return { content: await insertFormattedContentMock(...(args as [])), captureContent: args[0], cursor: { kind: "none" } }; + const result: unknown = await insertFormattedContentMock(...(args as [])); + return typeof result === "string" ? { content: result, captureContent: args[0], cursor: { kind: "none" } } : result; } async formatFileName(name: string) { return name; } + async withTemplatePropertyCollection(work: () => Promise) { + return work(); + } getAndClearTemplatePropertyVars() { return new Map(); } - getResolvedInsertAfterHeading() { - return null; - } consumeCreatedClipboardAttachmentPaths() { return []; } @@ -292,11 +293,15 @@ describe("CaptureChoiceEngine heading picker create affordance gating", () => { }); }); +const recordedOutcome = (engine: CaptureChoiceEngine) => + vi.mocked((engine as unknown as { choiceExecutor: IChoiceExecutor }).choiceExecutor.recordExecutionResult!) + .mock.calls.at(-1)?.[0]; + // --------------------------------------------------------------------------- // Finding: capture-empty-content-no-op — an empty/whitespace capture must not -// show a confident "Captured to …" notice. +// report a write. The executor's result notice reads the recorded effect. // --------------------------------------------------------------------------- -describe("CaptureChoiceEngine empty-capture no-op notice", () => { +describe("CaptureChoiceEngine empty-capture no-op outcome", () => { beforeEach(() => { noticeClass.instances.length = 0; formatContentOnlyMock.mockReset(); @@ -307,7 +312,7 @@ describe("CaptureChoiceEngine empty-capture no-op notice", () => { getAppendLinkDestinationFileMock.mockReset(); }); - it("shows a 'nothing to capture' notice (not 'Captured to') when the payload is empty", async () => { + it("records an unchanged run with nothing to undo when the payload is empty", async () => { const captureFile = createTestFile("Daily/Test.md"); const app = createRunApp(captureFile, "existing body"); // Empty payload: first pass resolves to "", and the with-file pass returns @@ -318,13 +323,11 @@ describe("CaptureChoiceEngine empty-capture no-op notice", () => { await engine.run(); - expect(noticeClass.instances).toHaveLength(1); - const message = noticeClass.instances[0]!.message; - expect(message).toMatch(/nothing to capture/i); - expect(message).not.toMatch(/^Captured to/); + expect(recordedOutcome(engine)).toEqual({ status: "success", file: captureFile, effect: "unchanged" }); + expect(noticeClass.instances).toHaveLength(0); }); - it("still shows the normal success notice when the payload is non-empty", async () => { + it("records the write a non-empty capture made, for Undo", async () => { const captureFile = createTestFile("Daily/Test.md"); const app = createRunApp(captureFile, "existing body"); formatContentOnlyMock.mockResolvedValue("new line"); @@ -333,10 +336,126 @@ describe("CaptureChoiceEngine empty-capture no-op notice", () => { await engine.run(); - expect(noticeClass.instances).toHaveLength(1); - const message = noticeClass.instances[0]!.message; - expect(message).toMatch(/Captured to/); - expect(message).not.toMatch(/nothing to capture/i); + expect(recordedOutcome(engine)).toEqual({ + status: "success", + file: captureFile, + effect: "changed", + write: { path: "Daily/Test.md", before: "existing body", after: "existing body\nnew line" }, + }); + expect(noticeClass.instances).toHaveLength(0); + }); + + it("records, as what the run left, the note after a link was added to it as well", async () => { + const captureFile = createTestFile("Daily/Test.md"); + const app = createRunApp(captureFile, "existing body"); + formatContentOnlyMock.mockResolvedValue("new line"); + insertFormattedContentMock.mockResolvedValue("existing body\nnew line"); + const choice = createCaptureChoice(); + choice.appendLink = { enabled: true, placement: "newLine", requireActiveFile: false }; + (app.workspace.getActiveFile as ReturnType).mockReturnValue(captureFile); + const engine = buildRunEngine(choice, app); + // The link lands in the captured note itself. + vi.spyOn(engine as unknown as { insertCaptureLink: () => Promise }, "insertCaptureLink").mockImplementation(async () => { + (app.vault.read as ReturnType).mockResolvedValue("existing body\nnew line\n[[Test]]"); + }); + + await engine.run(); + + expect(recordedOutcome(engine)).toMatchObject({ + write: { path: "Daily/Test.md", before: "existing body", after: "existing body\nnew line\n[[Test]]" }, + }); + }); + + it("reports a run that had nothing to add but put its link in the note as changed, with the link to undo", async () => { + const captureFile = createTestFile("Daily/Test.md"); + const app = createRunApp(captureFile, "existing body"); + formatContentOnlyMock.mockResolvedValue(""); + insertFormattedContentMock.mockResolvedValue("existing body"); + const choice = createCaptureChoice(); + choice.appendLink = { enabled: true, placement: "newLine", requireActiveFile: false }; + (app.workspace.getActiveFile as ReturnType).mockReturnValue(captureFile); + const engine = buildRunEngine(choice, app); + vi.spyOn(engine as unknown as { insertCaptureLink: () => Promise }, "insertCaptureLink").mockImplementation(async () => { + (app.vault.read as ReturnType).mockResolvedValue("existing body\n[[Test]]"); + }); + + await engine.run(); + + expect(recordedOutcome(engine)).toEqual({ + status: "success", + file: captureFile, + effect: "changed", + write: { path: "Daily/Test.md", before: "existing body", after: "existing body\n[[Test]]" }, + }); + }); + + it("keeps the snapshot as written when the link went into another note", async () => { + const captureFile = createTestFile("Daily/Test.md"); + const app = createRunApp(captureFile, "existing body"); + formatContentOnlyMock.mockResolvedValue("new line"); + insertFormattedContentMock.mockResolvedValue("existing body\nnew line"); + const choice = createCaptureChoice(); + choice.appendLink = { enabled: true, placement: "newLine", requireActiveFile: false }; + (app.workspace.getActiveFile as ReturnType).mockReturnValue(createTestFile("Daily/Other.md")); + const engine = buildRunEngine(choice, app); + // Someone else edits the captured note while the link goes into the other one. + vi.spyOn(engine as unknown as { insertCaptureLink: () => Promise }, "insertCaptureLink").mockImplementation(async () => { + (app.vault.read as ReturnType).mockResolvedValue("existing body\nnew line\ntheir edit"); + }); + + await engine.run(); + + expect(recordedOutcome(engine)).toMatchObject({ + effect: "changed", + write: { path: "Daily/Test.md", before: "existing body", after: "existing body\nnew line" }, + }); + }); + + it("records the note a marker-only capture created, so Undo can take it back", async () => { + const newFile = createTestFile("Daily/New.md"); + const app = createRunApp(newFile, "template body"); + (app.vault.adapter.exists as ReturnType).mockResolvedValue(false); + (app.vault.getAbstractFileByPath as ReturnType).mockReturnValue(null); + (app.vault.create as ReturnType).mockResolvedValue(newFile); + (app.vault as unknown as { createFolder: unknown }).createFolder = vi.fn(async () => {}); + formatContentOnlyMock.mockResolvedValue("typed"); + // The with-file pass finds the user typed {{CURSOR}} literally: nothing to place. + insertFormattedContentMock.mockResolvedValue({ content: "template body", captureContent: "", cursor: { kind: "none" }, markerOnly: true } as never); + const engine = buildRunEngine(createCaptureChoice({ + captureTo: "Daily/New.md", + createFileIfItDoesntExist: { enabled: true, createWithTemplate: false, template: "" }, + }), app); + + await engine.run(); + + expect(recordedOutcome(engine)).toEqual({ + status: "success", + file: newFile, + effect: "created", + write: { path: "Daily/New.md", before: null, after: "template body" }, + }); + }); + + it("refuses a capture to {{NOTE}} when nothing in the run has written a note yet", async () => { + const captureFile = createTestFile("Daily/Test.md"); + const app = createRunApp(captureFile, "existing body"); + formatContentOnlyMock.mockResolvedValue("new line"); + const choice = createCaptureChoice({ name: "Log" }); + choice.captureTo = "{{NOTE}}"; + const engine = buildRunEngine(choice, app); + noticeClass.instances.length = 0; + const logError = vi.spyOn(log, "logError"); + + await engine.run(); + + // A refusal, not an error: one sentence naming the choice, the same for a CLI or + // URI caller as for the user, with no error report around it. + expect(recordedOutcome(engine)).toEqual({ status: "error", reason: "Log: nothing has written a note yet, so there is no {{NOTE}} to add to." }); + expect(noticeClass.instances.map((notice) => notice.message)) + .toEqual(["Log: nothing has written a note yet, so there is no {{NOTE}} to add to."]); + expect(logError).not.toHaveBeenCalled(); + expect(insertFormattedContentMock).not.toHaveBeenCalled(); + logError.mockRestore(); }); it("treats a whitespace-only payload as a no-op", async () => { @@ -349,7 +468,7 @@ describe("CaptureChoiceEngine empty-capture no-op notice", () => { await engine.run(); - expect(noticeClass.instances[0]!.message).toMatch(/nothing to capture/i); + expect(recordedOutcome(engine)).toMatchObject({ status: "success", effect: "unchanged" }); }); // Codex re-review: an empty payload on an editor-insertion action must NOT @@ -374,7 +493,7 @@ describe("CaptureChoiceEngine empty-capture no-op notice", () => { await engine.run(); expect(insertOnNewLineBelowMock).not.toHaveBeenCalled(); - expect(noticeClass.instances[0]!.message).toMatch(/nothing to capture/i); + expect(recordedOutcome(engine)).toMatchObject({ status: "success", effect: "unchanged" }); }); it("still inserts into the editor on a non-empty newLine capture", async () => { diff --git a/src/engine/CaptureChoiceEngine.contextLine.test.ts b/src/engine/CaptureChoiceEngine.contextLine.test.ts new file mode 100644 index 000000000..c81441558 --- /dev/null +++ b/src/engine/CaptureChoiceEngine.contextLine.test.ts @@ -0,0 +1,76 @@ +import realMoment from "moment"; +import { afterAll, beforeAll, expect, it, vi } from "vitest"; +import type { App, TFile } from "obsidian"; +import { TFile as ObsidianTFile } from "obsidian"; +import { createChoiceExecutor } from "../../tests/helpers/createChoiceExecutor"; +import { logCapture } from "../gui/choiceList/presets"; +import { CaptureChoiceEngine } from "./CaptureChoiceEngine"; + +// The real CaptureChoiceFormatter runs here; only the prompt is a recorder. +const { prompts } = vi.hoisted(() => ({ + prompts: [] as Array<{ header: string; contextLine?: string }>, +})); + +vi.mock("../quickAddSettingsTab", () => ({ DEFAULT_SETTINGS: {}, QuickAddSettingsTab: class {} })); +vi.mock("../gui/choiceList/ChoiceView.svelte", () => ({ default: class {} })); +vi.mock("../gui/InputPrompt", () => ({ + default: class { + factory() { + return { + Prompt: async (...args: unknown[]) => { + const options = args.at(-1) as { contextLine?: string } | undefined; + prompts.push({ header: args[1] as string, contextLine: options?.contextLine }); + return "Planted the tomatoes"; + }, + }; + } + }, +})); + +const originalMoment = (window as unknown as { moment?: unknown }).moment; +beforeAll(() => { + realMoment.locale("en"); + (window as unknown as { moment: unknown }).moment = realMoment; + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-10-06T10:00:00")); +}); +afterAll(() => { + (window as unknown as { moment?: unknown }).moment = originalMoment; + vi.useRealTimers(); +}); + +it("tells the last prompt of a Log to {{DAILY}} the note and heading it adds under", async () => { + const files = new Map(); + const app = { + vault: { + adapter: { exists: vi.fn(async (path: string) => files.has(path)) }, + getAbstractFileByPath: vi.fn(() => null), + read: vi.fn(async (file: TFile) => files.get(file.path) ?? ""), + createFolder: vi.fn(), + create: vi.fn(async (path: string, content: string) => { + files.set(path, content); + const file = { path, name: path.split("/").pop(), basename: "2026-10-06", extension: "md" } as TFile; + return Object.setPrototypeOf(file, ObsidianTFile.prototype) as TFile; + }), + process: vi.fn(async (file: TFile, fn: (content: string) => string) => { + files.set(file.path, fn(files.get(file.path) ?? "")); + return files.get(file.path); + }), + }, + fileManager: { generateMarkdownLink: vi.fn(() => "") }, + workspace: { getActiveFile: () => null, getActiveViewOfType: () => null, getLeavesOfType: () => [] }, + metadataCache: { getFileCache: () => null }, + plugins: { plugins: {} }, + internalPlugins: { + plugins: { "daily-notes": { enabled: true, instance: { options: { folder: "Journal", format: "YYYY-MM-DD" } } } }, + }, + } as unknown as App; + const plugin = { settings: { globalVariables: {}, useSelectionAsCaptureValue: false, choices: [] } } as never; + + await new CaptureChoiceEngine(app, plugin, logCapture("Log"), createChoiceExecutor()).run(); + + // The form's preview row says "Adds to Journal/2026-10-06.md under ## Log"; + // the prompt asks for the text, and its line names the choice and the note. + expect(prompts).toEqual([{ header: "Text to capture", contextLine: "Log → Journal/2026-10-06.md under ## Log" }]); + expect(files.get("Journal/2026-10-06.md")).toBe("## Log\n- 10:00 Planted the tomatoes"); +}); diff --git a/src/engine/CaptureChoiceEngine.heading-picker.test.ts b/src/engine/CaptureChoiceEngine.heading-picker.test.ts index 4ea82e6ab..26faed404 100644 --- a/src/engine/CaptureChoiceEngine.heading-picker.test.ts +++ b/src/engine/CaptureChoiceEngine.heading-picker.test.ts @@ -208,8 +208,6 @@ describe("CaptureChoiceEngine 'Under heading…' runtime picker (#738)", () => { await (engine as any).maybeResolveInsertAfterHeading(HEADING_NOTE); expect(setInsertAfterTargetOverrideMock).toHaveBeenCalledWith("## Tasks"); - // Notice copy uses the heading TEXT (no '#'). - expect((engine as any).resolvedInsertAfterHeading).toBe("Tasks"); }); it("does nothing when promptHeading is off (plain 'After line…')", async () => { @@ -249,7 +247,6 @@ describe("CaptureChoiceEngine 'Under heading…' runtime picker (#738)", () => { ); expect(setInsertAfterTargetOverrideMock).toHaveBeenCalledWith("## Card Heading"); - expect((engine as any).resolvedInsertAfterHeading).toBe("Card Heading"); }); it("lets the user type a heading when the content has no headings (custom value)", async () => { @@ -263,8 +260,6 @@ describe("CaptureChoiceEngine 'Under heading…' runtime picker (#738)", () => { await (engine as any).maybeResolveInsertAfterHeading("just body text\nmore"); expect(setInsertAfterTargetOverrideMock).toHaveBeenCalledWith("## New Heading"); - // Custom value isn't a known heading line → notice falls back to the raw value. - expect((engine as any).resolvedInsertAfterHeading).toBe("## New Heading"); }); it("aborts (without opening the picker) on a non-interactive run", async () => { diff --git a/src/engine/CaptureChoiceEngine.notice.test.ts b/src/engine/CaptureChoiceEngine.notice.test.ts index c4ce5f94a..2b04cc07d 100644 --- a/src/engine/CaptureChoiceEngine.notice.test.ts +++ b/src/engine/CaptureChoiceEngine.notice.test.ts @@ -262,12 +262,13 @@ describe("CaptureChoiceEngine cancellation notices", () => { ); }); - it("shows a notice when the target file is missing and create is disabled", async () => { - settingsStore.setState({ - ...settingsStore.getState(), - showInputCancellationNotification: false, - }); - + it.each([ + { + captureToActiveFile: false, + reason: "the note Daily/Test.md does not exist, so nothing was added. Turn on \"Create note if it doesn't exist\" on the choice's page.", + }, + { captureToActiveFile: true, reason: "no note is open, so there is nothing to add to." }, + ])("refuses a capture to a note that is not there (active: $captureToActiveFile)", async ({ captureToActiveFile, reason }) => { const app = { vault: { adapter: { @@ -292,21 +293,23 @@ describe("CaptureChoiceEngine cancellation notices", () => { ...createChoiceExecutor(), execute: vi.fn(), variables: new Map(), + recordExecutionResult: vi.fn(), + signalAbort: vi.fn(), }; const engine = new CaptureChoiceEngine( app, plugin, - createCaptureChoice(), + { ...createCaptureChoice(), captureToActiveFile }, choiceExecutor, ); await engine.run(); - expect(noticeClass.instances).toHaveLength(1); - expect(noticeClass.instances[0]?.message).toContain( - "Capture execution aborted: Target file missing", - ); + const sentence = `Test Capture Choice: ${reason}`; + expect(noticeClass.instances.map((notice) => notice.message)).toEqual([sentence]); + expect(choiceExecutor.recordExecutionResult).toHaveBeenCalledWith({ status: "error", reason: sentence }); + expect(vi.mocked(choiceExecutor.signalAbort!).mock.calls[0]?.[0]?.message).toBe(sentence); }); }); @@ -388,11 +391,11 @@ describe("CaptureChoiceEngine append-link destination", () => { await engine.run(); expect(disk.content).toBe(""); - expect(choiceExecutor.recordExecutionResult).toHaveBeenCalledWith({ + expect(choiceExecutor.recordExecutionResult).toHaveBeenCalledWith(expect.objectContaining({ status: "success", file: captureFile, effect: "changed", - }); + })); expect(copyFileLinkToClipboardMock).toHaveBeenCalledWith(captureFile); expect(appendFileLinkToDestinationFileMock).not.toHaveBeenCalled(); }); @@ -410,11 +413,11 @@ describe("CaptureChoiceEngine append-link destination", () => { expect(disk.content).toBe(""); expect(copyFileLinkToClipboardMock).toHaveBeenCalledWith(captureFile); - expect(choiceExecutor.recordExecutionResult).toHaveBeenCalledWith({ + expect(choiceExecutor.recordExecutionResult).toHaveBeenCalledWith(expect.objectContaining({ status: "success", file: captureFile, effect: "changed", - }); + })); }); it("appends the captured file link to a specified destination without an active editor", async () => { @@ -425,11 +428,11 @@ describe("CaptureChoiceEngine append-link destination", () => { await engine.run(); expect(disk.content).toBe(""); - expect(choiceExecutor.recordExecutionResult).toHaveBeenCalledWith({ + expect(choiceExecutor.recordExecutionResult).toHaveBeenCalledWith(expect.objectContaining({ status: "success", file: captureFile, effect: "changed", - }); + })); expect(appendFileLinkToDestinationFileMock).toHaveBeenCalledWith( app, captureFile, diff --git a/src/engine/CaptureChoiceEngine.property.test.ts b/src/engine/CaptureChoiceEngine.property.test.ts index d1e9d12a4..298b8e776 100644 --- a/src/engine/CaptureChoiceEngine.property.test.ts +++ b/src/engine/CaptureChoiceEngine.property.test.ts @@ -119,10 +119,30 @@ describe("Capture property writes", () => { await test.run(); expect(readCaptureFrontmatter(test.read() ?? "")).toEqual({ status: "done", other: 42 }); expect(test.read()).toMatch(/---\nBody\n$/); - expect(test.executor.recordExecutionResult).toHaveBeenCalledWith({ status: "success", file: test.file, effect: "changed" }); + expect(test.executor.recordExecutionResult).toHaveBeenCalledWith({ + status: "success", file: test.file, effect: "changed", + write: { path: test.file.path, before: "---\nstatus: active\nother: 42\n---\nBody\n", after: test.read() }, + }); expect(mocks.picker).not.toHaveBeenCalled(); }); + it("records, as what the run left, the note after its link went into it as well", async () => { + const test = fixture("---\nstatus: active\n---\nBody\n"); + test.choice.appendLink = { enabled: true, placement: "newLine", requireActiveFile: false }; + const link = vi.spyOn(CaptureChoiceEngine.prototype as unknown as { insertCaptureLink: () => Promise }, "insertCaptureLink") + .mockImplementation(async () => test.overwrite(`${test.read()}[[Inbox]]\n`)); + try { + await test.run(); + } finally { + link.mockRestore(); + } + expect(test.read()).toMatch(/\[\[Inbox\]\]\n$/); + expect(test.executor.recordExecutionResult).toHaveBeenCalledWith(expect.objectContaining({ + effect: "changed", + write: { path: test.file.path, before: "---\nstatus: active\n---\nBody\n", after: test.read() }, + })); + }); + it("uses the active Markdown file without requiring an editor insertion", async () => { const test = fixture("Body\n"); test.choice.captureToActiveFile = true; @@ -184,7 +204,10 @@ describe("Capture property writes", () => { mocks.value.mockResolvedValue("work\n personal \n\nold\n"); await test.run(); expect(readCaptureFrontmatter(test.read() ?? "")).toEqual({ status: ["old", "work", "personal"] }); - expect(test.executor.recordExecutionResult).toHaveBeenCalledWith({ status: "success", file: test.file, effect: "changed" }); + expect(test.executor.recordExecutionResult).toHaveBeenCalledWith({ + status: "success", file: test.file, effect: "changed", + write: { path: test.file.path, before: "---\nstatus: [old]\n---\nBody\n", after: test.read() }, + }); }); it("seeds property variables and compose-writes when the format contains {{PROPERTY}}", async () => { @@ -294,7 +317,10 @@ describe("Capture property writes", () => { expect(readCaptureFrontmatter(test.read() ?? "")).toEqual({ status: "done" }); expect(test.read()).toMatch(/---\nTemplater body\n$/); expect(test.create).toHaveBeenCalledTimes(1); - expect(test.executor.recordExecutionResult).toHaveBeenCalledWith({ status: "success", file: test.file, effect: "created" }); + expect(test.executor.recordExecutionResult).toHaveBeenCalledWith({ + status: "success", file: test.file, effect: "created", + write: { path: test.file.path, before: null, after: test.read() }, + }); }); it("reports failure when Templater makes the final property update invalid", async () => { diff --git a/src/engine/CaptureChoiceEngine.selection.test.ts b/src/engine/CaptureChoiceEngine.selection.test.ts index 90cfa25b4..55181280a 100644 --- a/src/engine/CaptureChoiceEngine.selection.test.ts +++ b/src/engine/CaptureChoiceEngine.selection.test.ts @@ -1688,11 +1688,11 @@ describe("CaptureChoiceEngine capture target resolution", () => { await engine.run(); expect(app.vault.modify).toHaveBeenCalledWith(linkedFile, "updated"); - expect(executor.recordExecutionResult).toHaveBeenCalledWith({ + expect(executor.recordExecutionResult).toHaveBeenCalledWith(expect.objectContaining({ status: "success", file: linkedFile, effect: "changed", - }); + })); expect(insertFileLinkToActiveView).toHaveBeenCalledWith( app, linkedFile, diff --git a/src/engine/CaptureChoiceEngine.ts b/src/engine/CaptureChoiceEngine.ts index 457ec2803..0bfcf9a41 100644 --- a/src/engine/CaptureChoiceEngine.ts +++ b/src/engine/CaptureChoiceEngine.ts @@ -4,7 +4,7 @@ import { insertCaptureInEditor, setMarkdownCursorsAtOffsets, appendToCurrentLine import { mapEditorCursorPlacement, type EditorCursorPlacement, type EditorTextMutationObserver } from "../utils/editorCursorPlacement"; import { normalizeFileOpening } from "../utils/fileOpeningDefaults"; import { getAppendLinkDestinationFile } from "../utils/fileLinks"; -import { appendLinkDestinationError, insertChoiceFileLink, copyChoiceFileLink, openChoiceFile } from "./choiceFileActions"; +import { appendLinkDestinationError, insertChoiceFileLink, copyChoiceFileLink, openChoiceFile, linkDestinationFile } from "./choiceFileActions"; import { Notice, TFile, @@ -18,7 +18,6 @@ import { processNote, readNote, writeNote } from "../utils/noteContent"; import type { IChoiceExecutor } from "../IChoiceExecutor"; import { CANVAS_FILE_EXTENSION_REGEX, - CREATE_IF_NOT_FOUND_ORDERED, MARKDOWN_FILE_EXTENSION_REGEX, VALUE_SYNTAX, } from "../constants"; @@ -54,11 +53,12 @@ import { } from "../types/linkPlacement"; import { createNoteAfterTemplaterTrigger, isTemplaterTriggerOnCreateEnabled, jumpToNextTemplaterCursorIfPossible, overwriteTemplaterOnce, templaterParseTemplate } from "../utils/templaterIntegration"; import { reportError } from "../utils/errorUtils"; +import { refuse, RefusalError } from "../errors/RefusalError"; import { ChoiceOutcomeRecorder, failureReason, } from "./choiceOutcomeRecorder"; -import type { ChoiceEffect } from "../types/ChoiceOutcome"; +import type { ChoiceEffect, NoteWrite } from "../types/ChoiceOutcome"; import { routePrompt } from "../interactive/routePrompt"; import { promptEngineChoice } from "../interactive/engineChoice"; import { InputPromptDraftStore } from "../utils/InputPromptDraftStore"; @@ -83,6 +83,7 @@ import { } from "./canvasCapture"; import { handleMacroAbort } from "../utils/macroAbortHandler"; import type { ChoiceChain } from "./choiceChain"; +import { captureHeading } from "../preflight/resolvedTarget"; import { getPeriodicNoteSettings, type Period, @@ -130,6 +131,11 @@ type CaptureWriteResult = { markerOnly?: boolean; }; +/** A capture into a note that is not there, with creating it turned off. */ +function missingTargetRefusal(filePath: string) { + return refuse(`the note ${filePath} does not exist`, "nothing was added", `Turn on "Create note if it doesn't exist" on the choice's page.`); +} + export class CaptureChoiceEngine extends CaptureTargetEngine { choice: ICaptureChoice; protected formatter: CaptureChoiceFormatter; @@ -142,10 +148,6 @@ export class CaptureChoiceEngine extends CaptureTargetEngine { // suppressed in that case so collected containers aren't stranded as "[]" // placeholders (and written to the wrong note's front matter). See run(). private suppressFrontmatterCollection = false; - // Set when the "Choose heading when capturing" picker resolves a heading. Holds the heading - // TEXT (without '#' markers) for the success notice; the verbatim line goes to the - // formatter override. Null when not in heading mode. - private resolvedInsertAfterHeading: string | null = null; private readonly outcome: ChoiceOutcomeRecorder; constructor( @@ -162,113 +164,15 @@ export class CaptureChoiceEngine extends CaptureTargetEngine { this.outcome = new ChoiceOutcomeRecorder(choiceExecutor); this.formatter = new CaptureChoiceFormatter(app, plugin, choiceExecutor); this.formatter.choiceChain = chain; - // Every prompt this run opens can say which choice is asking (issue #1546). + // Every prompt this run opens can say which choice is asking (issue #1546), + // and once the target note is known, where in it the text lands. this.formatter.setPromptRunContext({ draftScopeId: choice.id, choiceName: choice.name, + heading: captureHeading(choice), }); } - /** - * For ordered captures (the "ordered" create-if-not-found location), copy the - * formatter's resolved insert-after heading (e.g. `## 2026-06-16`) so the - * success notice names the real heading instead of the raw `{{DATE:…}}` token. - * Called after the format pass; a no-op for every other capture (so existing - * insert-after notice behaviour is unchanged). - */ - private captureResolvedOrderedHeading(): void { - if ( - this.choice.insertAfter?.enabled && - this.choice.insertAfter.createIfNotFoundLocation === - CREATE_IF_NOT_FOUND_ORDERED - ) { - const resolved = this.formatter.getResolvedInsertAfterHeading(); - if (resolved) this.resolvedInsertAfterHeading = resolved; - } - } - - private showSuccessNotice( - file: TFile, - { wasNewFile, action }: { wasNewFile: boolean; action: CaptureAction }, - ) { - const fileName = `'${file.basename}'`; - - if (wasNewFile) { - new Notice( - `Created and captured to ${fileName}`, - DEFAULT_NOTICE_DURATION, - ); - return; - } - - const shouldAppendToBottom = - this.choice.prepend || - (this.choice.captureToActiveFile && - this.choice.activeFileWritePosition === "bottom"); - - let msg = ""; - switch (action) { - case "currentLine": - msg = `Captured to current line in ${fileName}`; - break; - case "newLineAbove": - msg = `Captured on a new line above cursor in ${fileName}`; - break; - case "newLineBelow": - msg = `Captured on a new line below cursor in ${fileName}`; - break; - case "activeFileTop": - msg = `Captured to top of ${fileName}`; - break; - case "prepend": - case "append": - msg = shouldAppendToBottom - ? `Captured to bottom of ${fileName}` - : `Captured to top of ${fileName}`; - break; - case "insertAfter": { - const heading = - this.resolvedInsertAfterHeading ?? this.choice.insertAfter.after; - msg = heading - ? `Captured to ${fileName} under '${heading}'` - : `Captured to ${fileName}`; - break; - } - case "insertBefore": { - const heading = this.choice.insertBefore?.before; - msg = heading - ? `Captured to ${fileName} before '${heading}'` - : `Captured to ${fileName}`; - break; - } - default: - msg = `Captured to ${fileName}`; - break; - } - - new Notice(msg, DEFAULT_NOTICE_DURATION); - } - - /** - * Shown instead of the success notice when the formatted capture payload is - * empty/whitespace-only: the file is unchanged (the formatter returns it as-is, - * and editor insertion replaces the selection with an empty string), so a - * confident "Captured to …" would be misleading. `wasNewFile` keeps the "note - * created" fact honest when create-if-not-found still made an empty file. - */ - private showNothingToCaptureNotice( - file: TFile, - { wasNewFile }: { wasNewFile: boolean }, - ) { - const fileName = `'${file.basename}'`; - new Notice( - wasNewFile - ? `Created ${fileName} — nothing to capture (no content)` - : `Nothing to capture — ${fileName} unchanged`, - DEFAULT_NOTICE_DURATION, - ); - } - private hasActiveMarkdownCaptureContext(): boolean { const hasActiveFile = !!this.app.workspace.getActiveFile(); const hasActiveMarkdownView = !!getActiveMarkdownEditorView(this.app); @@ -358,7 +262,7 @@ export class CaptureChoiceEngine extends CaptureTargetEngine { | { kind: "canvasText"; canvas: CanvasTextCaptureTarget } | { kind: "note"; filePath: string; isCanvasTriggered: boolean } > { - // An active canvas target only exists with Capture to active file, and a + // An active canvas target only exists with Capture to active note, and a // configured one only without it. const canvasTarget = (this.choice.captureToActiveFile @@ -466,9 +370,7 @@ export class CaptureChoiceEngine extends CaptureTargetEngine { } if (!fileAlreadyExists && !this.choice?.createFileIfItDoesntExist?.enabled) { - throw new ChoiceAbortError( - `Target file missing: ${filePath}. Enable "Create file if it doesn't exist" or choose an existing file.`, - ); + throw missingTargetRefusal(filePath); } } @@ -537,20 +439,17 @@ export class CaptureChoiceEngine extends CaptureTargetEngine { } if (write.markerOnly) { contentCommitted = !fileAlreadyExists; - if (this.plugin.settings.showCaptureNotification) { - this.showNothingToCaptureNotice(write.file, { wasNewFile: !fileAlreadyExists }); - } - this.outcome.success(write.file, fileAlreadyExists ? "unchanged" : "created"); + if (fileAlreadyExists) this.outcome.success(write.file, "unchanged"); + else this.outcome.success(write.file, "created", { path: write.file.path, before: null, after: await readNote(this.app, write.file) }); return; } - this.captureResolvedOrderedHeading(); const committed = await this.commitCapture(write, { action, fileAlreadyExists, onCommit: () => { contentCommitted = true; }, }); if (!committed) return; await this.finishCapture(write.file, { - ...committed, action, wasNewFile: !fileAlreadyExists, linkOptions, isCanvasTriggered, + ...committed, linkOptions, isCanvasTriggered, }); } catch (err) { if (!contentCommitted) { @@ -558,11 +457,13 @@ export class CaptureChoiceEngine extends CaptureTargetEngine { } if ( handleMacroAbort(err, { + choiceName: this.choice.name, logPrefix: "Capture execution aborted", noticePrefix: "Capture execution aborted", defaultReason: "Capture aborted", }) ) { + if (err instanceof RefusalError) this.outcome.failure(err.message); this.choiceExecutor.signalAbort?.(err); return; } @@ -583,38 +484,22 @@ export class CaptureChoiceEngine extends CaptureTargetEngine { /** * Everything after the capture is written, for a note and a Canvas text card: - * record the outcome, tell the user, copy and insert the link, open the file + * record the outcome, copy and insert the link, open the file * and place the cursor. `cursor` is where a `{{CURSOR}}` marker (`marked`) or * the insertion left it, or null. */ private async finishCapture(file: TFile, result: { effect: ChoiceEffect; - captureIsNoOp: boolean; + write?: NoteWrite; cursor: EditorCursorPlacement | null; marked: boolean; - action: CaptureAction; - wasNewFile: boolean; linkOptions: NormalizedAppendLinkOptions; isCanvasTriggered: boolean; }): Promise { - const { captureIsNoOp, marked, action, wasNewFile, linkOptions, isCanvasTriggered } = result; + const { marked, linkOptions, isCanvasTriggered } = result; let cursor = result.cursor; // Commit success before links/navigation so later failures cannot invite duplicate writes. - this.outcome.success(file, result.effect); - - // Show success notification - if (this.plugin.settings.showCaptureNotification) { - if (captureIsNoOp) { - this.showNothingToCaptureNotice(file, { - wasNewFile, - }); - } else { - this.showSuccessNotice(file, { - wasNewFile, - action, - }); - } - } + this.outcome.success(file, result.effect, result.write); await this.copyCapturedFileLinkToClipboard(file); @@ -632,6 +517,7 @@ export class CaptureChoiceEngine extends CaptureTargetEngine { if (marked && cursor && mutation.filePath === file.path) cursor = mapEditorCursorPlacement(cursor, mutation); } : undefined, }); + await this.recordNoteAfterLink(file, result.effect, result.write, linkOptions); let focus = normalizeFileOpening(this.choice.fileOpening).focus ?? true; if (this.choice.openFile) { @@ -649,15 +535,38 @@ export class CaptureChoiceEngine extends CaptureTargetEngine { } } + /** + * When the link went into the captured note itself: Undo compares the note + * with what the run left, so the recorded write (the same object the + * outcome holds) takes the text as it is after the link, and a run that had + * nothing to add but did add its link changed the note after all. A link + * that went elsewhere leaves the snapshot as written, so an edit someone + * else makes to the note meanwhile is not taken for the capture's. + */ + private async recordNoteAfterLink( + file: TFile, + effect: ChoiceEffect, + write: NoteWrite | undefined, + linkOptions: AppendLinkOptions, + ): Promise { + if (!write || !linkOptions.enabled || linkDestinationFile(this.app, linkOptions, this.choiceExecutor.focusedProperty)?.path !== file.path) return; + const after = await readNote(this.app, file); + if (effect === "unchanged" && after !== write.after) { + this.outcome.success(file, "changed", { ...write, after }); + return; + } + write.after = after; + } + private async commitCapture(write: CaptureWriteResult, options: { action: CaptureAction; fileAlreadyExists: boolean; onCommit: () => void; }): Promise<{ effect: ChoiceEffect; - captureIsNoOp: boolean; cursor: EditorCursorPlacement | null; marked: boolean; + write?: NoteWrite; } | null> { const { file, captureContent, newFileContent, priorContent, cursor: placement } = write; let captureIsNoOp = isCaptureContentEmpty(captureContent); @@ -666,9 +575,6 @@ export class CaptureChoiceEngine extends CaptureTargetEngine { const parsed = captureIsNoOp ? captureContent : await templaterParseTemplate(this.app, captureContent, file); const payload = restoreUserTextInCapture(prepareCapture(parsed)); if (payload.cursor.kind === "none" && /{{CURSOR}}/i.test(parsed)) { - if (this.plugin.settings.showCaptureNotification) { - this.showNothingToCaptureNotice(file, { wasNewFile: !options.fileAlreadyExists }); - } this.outcome.success(file, "unchanged"); return null; } @@ -687,27 +593,31 @@ export class CaptureChoiceEngine extends CaptureTargetEngine { } } options.onCommit(); - return { effect: captureIsNoOp ? "unchanged" : "changed", captureIsNoOp, cursor, marked }; + return { effect: captureIsNoOp ? "unchanged" : "changed", cursor, marked }; } - const { content: written, merged } = await writeNote(this.app, file, priorContent, newFileContent); + const { content: written, merged, before } = await writeNote(this.app, file, priorContent, newFileContent); options.onCommit(); const wholeFileTemplater = this.choice.templater?.afterCapture === "wholeFile"; if (wholeFileTemplater) { warnDeprecatedOnce( `templater-whole-file:${this.choice.id}`, - `'${this.choice.name}' uses "Run Templater on entire destination file after capture", which is deprecated and will be removed in a future release. QuickAdd already runs Templater in what it captures. Turn the option off in the Capture's settings.`, + `'${this.choice.name}' uses "Run Templater on entire destination note after capture", which is deprecated and will be removed in a future release. QuickAdd already runs Templater in what it captures. Turn the option off in the Capture's settings.`, ); await overwriteTemplaterOnce(this.app, file); } const postProcessed = await this.applyCapturePropertyVars(file); + const after = wholeFileTemplater || postProcessed ? await this.app.vault.read(file) : written; // Only a pass that actually rewrote the note invalidates the cursor offsets. - const rewritten = (wholeFileTemplater || postProcessed) && await this.app.vault.read(file) !== written; + const rewritten = after !== written; const effect: ChoiceEffect = !options.fileAlreadyExists ? "created" : newFileContent !== priorContent || rewritten ? "changed" : "unchanged"; const cursor = !merged && !rewritten && placement.kind === "offset" ? { offsets: [placement.value], content: newFileContent } : null; - return { effect, captureIsNoOp, cursor, marked: placement.kind === "offset" && placement.source === "marker" }; + return { + effect, cursor, marked: placement.kind === "offset" && placement.source === "marker", + write: { path: file.path, before: options.fileAlreadyExists ? before : null, after }, + }; } private async captureToProperty(args: { @@ -723,7 +633,7 @@ export class CaptureChoiceEngine extends CaptureTargetEngine { throw new ChoiceAbortError("Property capture requires a Markdown note."); } if (!fileAlreadyExists && !this.choice.createFileIfItDoesntExist.enabled) { - throw new ChoiceAbortError(`Target file missing: ${filePath}. Enable "Create file if it doesn't exist" or choose an existing file.`); + throw missingTargetRefusal(filePath); } let file = fileAlreadyExists ? this.getFileByPath(filePath) : undefined; this.formatter.setTitle(basenameWithoutMdOrCanvas(filePath)); @@ -809,12 +719,12 @@ export class CaptureChoiceEngine extends CaptureTargetEngine { }); } const persistedContent = await this.app.vault.read(file); - this.outcome.success(file, !fileAlreadyExists ? "created" : persistedContent === priorContent ? "unchanged" : "changed"); - if (this.plugin.settings.showCaptureNotification) { - new Notice(`Captured to '${key}' in '${file.basename}'`, DEFAULT_NOTICE_DURATION); - } + const effect: ChoiceEffect = !fileAlreadyExists ? "created" : persistedContent === priorContent ? "unchanged" : "changed"; + const write: NoteWrite = { path: file.path, before: fileAlreadyExists ? priorContent : null, after: persistedContent }; + this.outcome.success(file, effect, write); await this.copyCapturedFileLinkToClipboard(file); await this.insertCaptureLink(file, linkOptions, { isCanvasTriggered: args.isCanvasTriggered }); + await this.recordNoteAfterLink(file, effect, write, linkOptions); if (this.choice.openFile) { await openChoiceFile({ app: this.app, file, opening: this.choice.fileOpening, originLeaf: this.originLeaf, @@ -917,13 +827,9 @@ export class CaptureChoiceEngine extends CaptureTargetEngine { ); if (markerOnly) { - if (this.plugin.settings.showCaptureNotification) { - this.showNothingToCaptureNotice(file, { wasNewFile: false }); - } this.outcome.success(file, "unchanged"); return; } - this.captureResolvedOrderedHeading(); // An empty/whitespace capture leaves the card text unchanged (the formatter // returns existingText as-is) — surface a no-op notice instead of a false @@ -940,8 +846,8 @@ export class CaptureChoiceEngine extends CaptureTargetEngine { markContentCommitted(); await this.finishCapture(file, { - effect: captureIsNoOp ? "unchanged" : "changed", captureIsNoOp, cursor: null, marked: false, - action, wasNewFile: false, linkOptions, isCanvasTriggered: true, + effect: captureIsNoOp ? "unchanged" : "changed", cursor: null, marked: false, + linkOptions, isCanvasTriggered: true, }); } @@ -1007,7 +913,6 @@ export class CaptureChoiceEngine extends CaptureTargetEngine { const headingDisplay = headings.map( (h) => `${" ".repeat(Math.max(0, h.level - 1))}${h.heading}`, ); - const headingTexts = headings.map((h) => h.heading); const placeholder = "Choose a heading to insert under"; const chosen = String( @@ -1051,12 +956,6 @@ export class CaptureChoiceEngine extends CaptureTargetEngine { ); this.formatter.setInsertAfterTargetOverride(chosen); - - // Notice copy: show the heading TEXT (no '#') for a picked heading; fall back to - // the raw typed value for a custom entry. - const pickedIndex = headingLines.indexOf(chosen); - this.resolvedInsertAfterHeading = - pickedIndex >= 0 ? headingTexts[pickedIndex] : chosen; } /** diff --git a/src/engine/CaptureChoiceEngine.user-text.test.ts b/src/engine/CaptureChoiceEngine.user-text.test.ts index 62c17a5cb..01c388a0f 100644 --- a/src/engine/CaptureChoiceEngine.user-text.test.ts +++ b/src/engine/CaptureChoiceEngine.user-text.test.ts @@ -171,3 +171,20 @@ describe("Capture keeps tokens and Templater tags inside user text literal", () expect(written).toBe("# Inbox\nX <% Y %> {{TITLE}} <% z %>"); }); }); + +describe("Capture with {{LINKCURRENT}} and no note open", () => { + it("refuses in one sentence, which is the run's outcome", async () => { + const { app, plugin, executor, files } = setup({}); + const recordExecutionResult = vi.fn(); + const choice = captureChoice("- {{LINKCURRENT}}"); + choice.name = "Log"; + + await new CaptureChoiceEngine(app, plugin, choice, { ...executor, recordExecutionResult }).run(); + + expect(recordExecutionResult).toHaveBeenCalledWith({ + status: "error", + reason: "Log: no note is open, so {{LINKCURRENT}} has nothing to link to.", + }); + expect(files.get(TARGET)).toBe("# Inbox\n"); + }); +}); diff --git a/src/engine/CaptureTargetEngine.ts b/src/engine/CaptureTargetEngine.ts index fdb1e34ba..a7e60d44a 100644 --- a/src/engine/CaptureTargetEngine.ts +++ b/src/engine/CaptureTargetEngine.ts @@ -16,6 +16,7 @@ import type { FieldFilter } from "../utils/FieldSuggestionParser"; import { routePrompt } from "../interactive/routePrompt"; import { promptEngineChoice } from "../interactive/engineChoice"; import { ChoiceAbortError } from "../errors/ChoiceAbortError"; +import { refuse } from "../errors/RefusalError"; import { captureCandidates, captureScopeFiles } from "./helpers/captureCandidates"; import { itemWithAlias } from "../utils/fileSyntax"; import { classifyCaptureTargetScope, markdownFilePathForFolderCandidate, type CaptureTargetScope } from "./helpers/captureTargetScope"; @@ -41,10 +42,16 @@ export abstract class CaptureTargetEngine extends QuickAddChoiceEngine { ): Promise { if (shouldCaptureToActiveFile) { const activeFile = this.app.workspace.getActiveFile(); - invariant(activeFile, "Cannot capture to active file - no active file."); + if (!activeFile) throw refuse("no note is open", "there is nothing to add to"); return activeFile.path; } + // The run note is the note an earlier step ended on; with none, {{NOTE}} + // would be an empty target, which opens the vault-wide picker and asks + // the user to decide what the run should have decided. + if (isRunNoteToken(this.choice.captureTo) && !this.choiceExecutor?.runNote) { + throw refuse("nothing has written a note yet", "there is no {{NOTE}} to add to"); + } // A preselected capture target (the trusted one-page preflight pick, or a // non-interactive CLI `value-__qa.captureTargetFilePath`) is honoured ONLY @@ -487,3 +494,8 @@ export abstract class CaptureTargetEngine extends QuickAddChoiceEngine { } } + +/** A configured path that is the run note token, in any case and spacing. */ +export function isRunNoteToken(path: unknown): boolean { + return typeof path === "string" && path.trim().toUpperCase() === "{{NOTE}}"; +} diff --git a/src/engine/MacroChoiceEngine.audit-cleanup.test.ts b/src/engine/MacroChoiceEngine.audit-cleanup.test.ts index ab74e3f2e..797663509 100644 --- a/src/engine/MacroChoiceEngine.audit-cleanup.test.ts +++ b/src/engine/MacroChoiceEngine.audit-cleanup.test.ts @@ -122,15 +122,17 @@ describe("MacroChoiceEngine executeAIAssistant disable-online-features guard", ( storeState.ai = {}; }); - it("blocks with a provider-neutral message that does not name OpenAI", async () => { + it("refuses in one sentence that names the choice and not a provider", async () => { storeState.disableOnlineFeatures = true; const engine = createEngine([makeAIAssistantCommand()]); - await expect(engine.run()).rejects.toThrow( - "Blocking request: Online features are disabled in settings." + await engine.run(); + const { choiceExecutor } = engine as unknown as { choiceExecutor: IChoiceExecutor }; + const signalAbort = vi.mocked(choiceExecutor.signalAbort!); + expect(signalAbort.mock.calls[0]?.[0]?.message).toBe( + "Test choice: online features are off, so the AI request was not sent. Turn off \"Disable AI & online features\" in QuickAdd's settings.", ); - await expect(engine.run()).rejects.not.toThrow(/OpenAI/); // The guard short-circuits before the assistant ever runs. expect(runAIAssistantMock).not.toHaveBeenCalled(); }); diff --git a/src/engine/MacroChoiceEngine.conditional.test.ts b/src/engine/MacroChoiceEngine.conditional.test.ts index 78e6d8739..cc6a5615d 100644 --- a/src/engine/MacroChoiceEngine.conditional.test.ts +++ b/src/engine/MacroChoiceEngine.conditional.test.ts @@ -236,12 +236,16 @@ afterAll(() => { }); it("stops the macro when a user script is missing", async () => { - const { engine, executeCommandById } = createEngine( + const { engine, executeCommandById, choiceExecutor } = createEngine( [new UserScript("gone", "scripts/gone.js"), new ObsidianCommand("After", "after-id")], {}, ); + choiceExecutor.signalAbort = vi.fn(); - await expect(engine.run()).rejects.toThrow("QuickAdd could not find scripts/gone.js."); + await engine.run(); + expect(vi.mocked(choiceExecutor.signalAbort).mock.calls[0]?.[0]?.message).toBe( + "Test choice: the script scripts/gone.js does not exist, so the step did not run. Choose a file on the step's row.", + ); expect(executeCommandById).not.toHaveBeenCalled(); }); @@ -251,9 +255,13 @@ afterAll(() => { "then-id", "else-id" ); - const { engine, executeCommandById } = createEngine(conditional, {}); + const { engine, executeCommandById, choiceExecutor } = createEngine(conditional, {}); + choiceExecutor.signalAbort = vi.fn(); - await expect(engine.run()).rejects.toThrow("QuickAdd could not find scripts/gone.js."); + await engine.run(); + expect(vi.mocked(choiceExecutor.signalAbort).mock.calls[0]?.[0]?.message).toBe( + "Test choice: the script scripts/gone.js does not exist, so the step did not run. Choose a file on the step's row.", + ); expect(executeCommandById).not.toHaveBeenCalled(); }); }); diff --git a/src/engine/MacroChoiceEngine.entry.test.ts b/src/engine/MacroChoiceEngine.entry.test.ts index c249106e7..86743761a 100644 --- a/src/engine/MacroChoiceEngine.entry.test.ts +++ b/src/engine/MacroChoiceEngine.entry.test.ts @@ -7,7 +7,7 @@ import type IMacroChoice from "../types/choices/IMacroChoice"; import type { IMacro } from "../types/macros/IMacro"; import type { IUserScript } from "../types/macros/IUserScript"; import { CommandType } from "../types/macros/CommandType"; -import type { App } from "obsidian"; +import type { App, TFile } from "obsidian"; import type { IChoiceCommand } from "../types/macros/IChoiceCommand"; import type { INestedChoiceCommand } from "../types/macros/QuickCommands/INestedChoiceCommand"; import type IChoice from "../types/choices/IChoice"; @@ -182,17 +182,24 @@ describe("MacroChoiceEngine user script entry handling", () => { }; }); - it.each([ - { name: "retains previous output when the script cannot be loaded", callable: false, expected: "previous" }, - { name: "clears previous output when the script returns undefined", callable: true, expected: undefined }, - ])("$name", async ({ callable, expected }) => { + it("refuses a script that exports nothing and keeps the previous output", async () => { + mockLoadModuleExports.mockResolvedValue(undefined); + const engine = new MacroChoiceEngine(app, plugin, macroChoice, choiceExecutor, variables); + engine.setOutput("previous"); + await expect(engine["executeUserScript"](userScriptCommand)).rejects.toThrow( + `The script ${userScriptCommand.path} exports nothing to run, so the step did not run. Export a function from it.`, + ); + expect(engine.getOutput()).toBe("previous"); + }); + + it("clears previous output when the script returns undefined", async () => { const script = vi.fn().mockResolvedValue(undefined); - mockLoadModuleExports.mockResolvedValue(callable ? script : undefined); + mockLoadModuleExports.mockResolvedValue(script); const engine = new MacroChoiceEngine(app, plugin, macroChoice, choiceExecutor, variables); engine.setOutput("previous"); await engine["executeUserScript"](userScriptCommand); - expect(engine.getOutput()).toBe(expected); - expect(script).toHaveBeenCalledTimes(callable ? 1 : 0); + expect(engine.getOutput()).toBeUndefined(); + expect(script).toHaveBeenCalledTimes(1); }); it("runs the entry export without prompting when no settings are defined", async () => { @@ -539,6 +546,17 @@ describe("MacroChoiceEngine user script variable propagation", () => { expect(engine["choiceExecutor"].variables).toBe(providedVariables); }); + it("gives scripts the run note as params.note when they read it", () => { + const executor = { ...choiceExecutor, runNote: null as TFile | null }; + const engine = new MacroChoiceEngine(app, plugin, macroChoice, executor, variables); + const params = engine["params"] as unknown as { note: TFile | null }; + + expect(params.note).toBeNull(); + const note = { path: "notes/run-note.md" } as TFile; + executor.runNote = note; + expect(params.note).toBe(note); + }); + it("treats `params.variables = {...}` as replacing the backing map", async () => { const assignScript: IUserScript = { id: "assign-script", diff --git a/src/engine/MacroChoiceEngine.notice.test.ts b/src/engine/MacroChoiceEngine.notice.test.ts index a0b46dbf9..d7324097e 100644 --- a/src/engine/MacroChoiceEngine.notice.test.ts +++ b/src/engine/MacroChoiceEngine.notice.test.ts @@ -25,6 +25,7 @@ import { MacroChoiceEngine } from "./MacroChoiceEngine"; import { MacroAbortError } from "../errors/MacroAbortError"; import { UserCancelError } from "../errors/UserCancelError"; import { handleMacroAbort } from "../utils/macroAbortHandler"; +import { refuse } from "../errors/RefusalError"; import { settingsStore } from "../settingsStore"; import type IChoice from "../types/choices/IChoice"; @@ -185,6 +186,7 @@ describe("MacroChoiceEngine cancellation notices", () => { async (executor) => { const error = new MacroAbortError("Target file missing: Inbox.md"); handleMacroAbort(error, { + choiceName: "Capture", logPrefix: "Capture execution aborted", defaultReason: "Capture aborted", }); @@ -199,6 +201,33 @@ describe("MacroChoiceEngine cancellation notices", () => { ]); }); + it("stops at a step's refusal and names the step's choice, not the sequence", async () => { + const log: IChoice = { id: "log", name: "Log", type: "Capture", command: false }; + const after = vi.fn(); + const engine = createTestEngine( + "unused", + [ + { id: "step", name: "Log", type: CommandType.NestedChoice, choice: log } as any, + { id: "after", name: "After", type: CommandType.NestedChoice, choice: { ...log, id: "after" } } as any, + ], + async (executor) => { + after(); + if (after.mock.calls.length > 1) return; + // What CaptureChoiceEngine does when a guard refuses. + const error = refuse("the Daily notes core plugin is off", "{{DAILY}} has no note to point at", "Turn it on in Settings > Core plugins."); + handleMacroAbort(error, { choiceName: "Log", logPrefix: "Capture execution aborted", defaultReason: "Capture aborted" }); + executor.signalAbort?.(error); + }, + ); + + await engine.run(); + + expect(after).toHaveBeenCalledTimes(1); + expect(noticeClass.instances.map((notice) => notice.message)).toEqual([ + "Log: the Daily notes core plugin is off, so {{DAILY}} has no note to point at. Turn it on in Settings > Core plugins.", + ]); + }); + describe("MacroChoiceEngine nested choice propagation", () => { it("halts subsequent commands when a nested choice cancels", async () => { const app = {} as App; diff --git a/src/engine/MacroChoiceEngine.openFilePath.audit-macro.test.ts b/src/engine/MacroChoiceEngine.openFilePath.audit-macro.test.ts index 4114952be..08c7b46d4 100644 --- a/src/engine/MacroChoiceEngine.openFilePath.audit-macro.test.ts +++ b/src/engine/MacroChoiceEngine.openFilePath.audit-macro.test.ts @@ -1,4 +1,5 @@ import { createChoiceExecutor } from "../../tests/helpers/createChoiceExecutor"; +import { log } from "../logger/logManager"; import { describe, expect, it, vi, beforeEach, afterEach, afterAll } from "vitest"; const { formatFileNameMock, openFileMock, setPromptRunContextMock } = @@ -67,7 +68,7 @@ vi.mock("../ai/aiHelpers", () => ({ })); import type { App } from "obsidian"; -import { TFile } from "obsidian"; +import { Notice, TFile } from "obsidian"; import { MacroChoiceEngine } from "./MacroChoiceEngine"; import { OpenFileCommand } from "../types/macros/QuickCommands/OpenFileCommand"; import type { IMacro } from "../types/macros/IMacro"; @@ -75,6 +76,8 @@ import type IMacroChoice from "../types/choices/IMacroChoice"; import type { IChoiceExecutor } from "../IChoiceExecutor"; import { QuickAddApi } from "../quickAddApi"; +const noticeClass = Notice as unknown as { instances: Array<{ message: string }> }; + const getApiMock = QuickAddApi.GetApi as unknown as ReturnType; function makeFile(path: string): TFile { @@ -158,6 +161,21 @@ describe("MacroChoiceEngine executeOpenFile path validation", () => { expect(openFileMock.mock.calls[0][1]).toBe(file); }); + it("refuses to open {{NOTE}} when nothing in the run has written a note yet", async () => { + const logError = vi.spyOn(log, "logError").mockImplementation(() => {}); + const { engine } = createEngine("{{NOTE}}", {}); + noticeClass.instances.length = 0; + + await engine.run(); + + expect(noticeClass.instances.map((notice) => notice.message)) + .toEqual(["Test choice: nothing has written a note yet, so there is no {{NOTE}} to open."]); + expect(logError).not.toHaveBeenCalled(); + expect(formatFileNameMock).not.toHaveBeenCalled(); + expect(openFileMock).not.toHaveBeenCalled(); + logError.mockRestore(); + }); + it("still rejects an actual '..' traversal segment", async () => { const file = makeFile("../secret.md"); const { engine } = createEngine("../secret.md", { diff --git a/src/engine/MacroChoiceEngine.ts b/src/engine/MacroChoiceEngine.ts index 55e9a349c..83729cdfc 100644 --- a/src/engine/MacroChoiceEngine.ts +++ b/src/engine/MacroChoiceEngine.ts @@ -9,6 +9,7 @@ import type { IUserScript } from "../types/macros/IUserScript"; import type { IObsidianCommand } from "../types/macros/IObsidianCommand"; import { log } from "../logger/logManager"; import { reportError } from "../utils/errorUtils"; +import { refuse } from "../errors/RefusalError"; import { CommandType } from "../types/macros/CommandType"; import { QuickAddApi } from "../quickAddApi"; import { restoreDateVariableFormats } from "../formatters/helpers/dateTokens"; @@ -19,7 +20,7 @@ import { QuickAddChoiceEngine } from "./QuickAddChoiceEngine"; import type { IMacro } from "../types/macros/IMacro"; import type { IChoiceCommand } from "../types/macros/IChoiceCommand"; import type QuickAdd from "../main"; -import { getQuickAddInstance } from "../quickAddInstance"; +import { isRunNoteToken } from "./CaptureTargetEngine"; import type { IChoiceExecutor } from "../IChoiceExecutor"; import { getUserScript } from "../utils/userScript"; import type { IWaitCommand } from "../types/macros/QuickCommands/IWaitCommand"; @@ -39,17 +40,16 @@ import { MoveCursorToLineStartCommand } from "../types/macros/EditorCommands/Mov import { MoveCursorToLineEndCommand } from "../types/macros/EditorCommands/MoveCursorToLineEndCommand"; import { waitFor } from "src/utility"; import type { IAIAssistantCommand } from "src/types/macros/QuickCommands/IAIAssistantCommand"; -import { CompleteFormatter } from "src/formatters/completeFormatter"; import type { ResolvedModel } from "src/ai/aiHelpers"; import type { IOpenFileCommand } from "../types/macros/QuickCommands/IOpenFileCommand"; import { openFile } from "../utils/fileOpening"; -import { TFile } from "obsidian"; import { MacroAbortError } from "../errors/MacroAbortError"; import type { IConditionalCommand } from "../types/macros/Conditional/IConditionalCommand"; -import type { ScriptCondition } from "../types/macros/Conditional/types"; +import type { ConditionalCondition, ScriptCondition } from "../types/macros/Conditional/types"; import { evaluateCondition } from "./helpers/conditionalEvaluator"; import { handleMacroAbort } from "../utils/macroAbortHandler"; import { buildOpenFileOptions } from "./helpers/openFileOptions"; +import { resolveStepNote } from "./helpers/stepNote"; import { createVariablesProxy } from "../utils/variablesProxy"; import { commandListOf, @@ -166,6 +166,10 @@ export class MacroChoiceEngine extends QuickAddChoiceEngine { enumerable: true, configurable: false, }); + Object.defineProperty(params, "note", { + get: () => choiceExecutor.runNote ?? null, + enumerable: true, + }); return params; } @@ -341,6 +345,7 @@ export class MacroChoiceEngine extends QuickAddChoiceEngine { } catch (error) { if ( handleMacroAbort(error, { + choiceName: this.choice.name, logPrefix: "Macro execution aborted", noticePrefix: "Macro execution aborted", defaultReason: "Macro execution aborted", @@ -511,12 +516,17 @@ export class MacroChoiceEngine extends QuickAddChoiceEngine { return pickMacroModel(this.app, this.choiceExecutor); } - private async executeConditional(command: IConditionalCommand) { - const shouldRunThenBranch = await evaluateCondition(command.condition, { + /** Whether `condition` holds now, read the way a Conditional command reads it. */ + public async conditionHolds(condition: ConditionalCondition): Promise { + return await evaluateCondition(condition, { variables: this.params.variables, - evaluateScriptCondition: async (condition: ScriptCondition) => - await this.evaluateScriptCondition(condition), + evaluateScriptCondition: async (script: ScriptCondition) => + await this.evaluateScriptCondition(script), }); + } + + private async executeConditional(command: IConditionalCommand) { + const shouldRunThenBranch = await this.conditionHolds(command.condition); const branch = shouldRunThenBranch ? command.thenCommands @@ -617,6 +627,8 @@ export class MacroChoiceEngine extends QuickAddChoiceEngine { return async () => script; } catch (error) { + // A missing script is a refusal the macro reports with its name. + if (error instanceof MacroAbortError) throw error; reportError( error, `Failed to load conditional script '${condition.scriptPath}'.` @@ -640,56 +652,21 @@ export class MacroChoiceEngine extends QuickAddChoiceEngine { } private async executeOpenFile(command: IOpenFileCommand) { + if (isRunNoteToken(command.filePath) && !this.choiceExecutor.runNote) { + throw refuse("nothing has written a note yet", "there is no {{NOTE}} to open"); + } try { - const formatter = new CompleteFormatter( - this.app, - getQuickAddInstance(), - this.choiceExecutor - ); - // This formatter is built per command, so a {{VALUE}} in the path only - // gets the macro's name and its own draft scope if they are handed over - // (issue #1546). Scoped per command id: a macro can hold several Open - // File commands, and they must not share one draft. - formatter.setPromptRunContext({ - choiceName: this.choice?.name, - draftScopeId: `${this.choice?.id ?? "macro"}#openFile:${command.id}`, - }); - formatter.choiceChain = this.chain; - - const resolvedPath = await formatter.formatFileName( + // A {{VALUE}} in the path gets the macro's name and a draft scope of + // its own per command: a macro can hold several Open File commands + // (issue #1546). + const file = await resolveStepNote( + { app: this.app, executor: this.choiceExecutor, choice: { id: this.choice?.id ?? "macro", name: this.choice?.name }, chain: this.chain }, command.filePath, - "filePath", + { scope: `openFile:${command.id}`, label: "OpenFile", consequence: "there is no {{NOTE}} to open" }, ); - const normalizedPath = resolvedPath.replace(/\\/g, "/"); - - // Validate path segments to prevent traversal attacks. A substring check - // would wrongly reject legitimate filenames that merely contain ".." (e.g. - // 'log..2024.md') or "//"; only a literal '..' path segment or an empty - // segment (from '//') is an actual traversal/malformed path. - const segments = normalizedPath.split("/"); - const hasTraversal = segments.some( - (segment, index) => - segment === ".." || - // An empty segment is a doubled slash; the leading slash of an - // absolute path is allowed (index 0), as is a single trailing slash. - (segment === "" && index !== 0 && index !== segments.length - 1) - ); - if (hasTraversal) { - log.logError(`OpenFile: Path traversal not allowed in '${normalizedPath}'`); - return; - } - - const file = this.app.vault.getAbstractFileByPath(normalizedPath); - - if (!file || !(file instanceof TFile)) { - log.logError(`OpenFile: '${normalizedPath}' does not exist or is not a file`); - return; - } - - const openOptions = buildOpenFileOptions(command); - + if (!file) return; await openFile(this.app, file, { - ...openOptions, + ...buildOpenFileOptions(command), originLeaf: this.originLeaf, }); } catch (error) { diff --git a/src/engine/SingleMacroEngine.anyChoice.test.ts b/src/engine/SingleMacroEngine.anyChoice.test.ts new file mode 100644 index 000000000..318a4a97d --- /dev/null +++ b/src/engine/SingleMacroEngine.anyChoice.test.ts @@ -0,0 +1,120 @@ +import { createChoiceExecutor } from "../../tests/helpers/createChoiceExecutor"; +import type { App, TFile } from "obsidian"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { IChoiceExecutor } from "../IChoiceExecutor"; +import type QuickAdd from "../main"; +import type IChoice from "../types/choices/IChoice"; +import type IMacroChoice from "../types/choices/IMacroChoice"; +import { log } from "../logger/logManager"; +import { SingleMacroEngine } from "./SingleMacroEngine"; + +const { macroRun } = vi.hoisted(() => ({ macroRun: vi.fn() })); + +vi.mock("./MacroChoiceEngine", () => ({ + MacroChoiceEngine: vi.fn(function MacroChoiceEngineMock(this: unknown, _app: unknown, _plugin: unknown, choice: IChoice) { + return { + run: () => macroRun(choice.name), + getOutput: () => `${choice.name} output`, + params: { variables: {} }, + }; + }), +})); +vi.mock("../gui/choiceList/ChoiceView.svelte", () => ({})); +vi.mock("../gui/GlobalVariables/GlobalVariablesView.svelte", () => ({})); +vi.mock("../quickAddSettingsTab", () => ({ DEFAULT_SETTINGS: {}, QuickAddSettingsTab: class {} })); + +const choice = (name: string, type: IChoice["type"], id = name): IChoice => + ({ id, name, type, command: false }) as IChoice; + +const macro = (name: string): IMacroChoice => + ({ ...choice(name, "Macro"), runOnStartup: false, macro: { id: name, name, commands: [] } }) as IMacroChoice; + +describe("SingleMacroEngine on a choice that is not a macro", () => { + let executor: IChoiceExecutor; + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(log, "logError").mockImplementation(() => {}); + executor = createChoiceExecutor(); + let recorded = 0; + executor.noteEndedOn = async (run) => { + const seen = recorded; + await run(); + return recorded === seen ? null : executor.runNote ?? null; + }; + vi.mocked(executor.execute).mockImplementation((ran: IChoice) => { + executor.runNote = { path: `notes/${ran.name}.md` } as TFile; + recorded++; + return Promise.resolve(); + }); + }); + + const engine = (choices: IChoice[]) => + new SingleMacroEngine({} as App, {} as QuickAdd, choices, executor); + + it("runs a Capture through the executor inside the caller's chain and gives back the note it wrote", async () => { + const capture = choice("Log", "Capture"); + const caller = choice("Daily", "Template"); + + await expect(engine([capture]).runAndGetOutput("log", undefined, [caller])).resolves.toBe("notes/Log.md"); + expect(executor.execute).toHaveBeenCalledWith(capture, [caller]); + }); + + it("finds one inside a folder, and gives back nothing when it wrote no note", async () => { + vi.mocked(executor.execute).mockResolvedValue(undefined); + const folder = { ...choice("Folder", "Multi"), choices: [choice("New note", "Template")] } as IChoice; + + await expect(engine([folder]).runAndGetOutput("New note")).resolves.toBe(""); + expect(executor.execute).toHaveBeenCalledWith(expect.objectContaining({ name: "New note" }), []); + }); + + it("gives back nothing, and keeps the run note, when the choice wrote none after an earlier step did", async () => { + const earlier = { path: "notes/Earlier.md" } as TFile; + executor.runNote = earlier; + vi.mocked(executor.execute).mockResolvedValue(undefined); + + await expect(engine([choice("Open inbox", "Capture")]).runAndGetOutput("Open inbox")).resolves.toBe(""); + expect(executor.runNote).toBe(earlier); + }); + + it("leaves the run note in place while the choice runs, so {{NOTE}} inside it sees the outer note", async () => { + const earlier = { path: "notes/Earlier.md" } as TFile; + executor.runNote = earlier; + let seenInside: TFile | null | undefined; + vi.mocked(executor.execute).mockImplementation(() => { + seenInside = executor.runNote; + return Promise.resolve(); + }); + + await engine([choice("Append", "Capture")]).runAndGetOutput("Append"); + expect(seenInside).toBe(earlier); + }); + + it("refuses export access on it", async () => { + await expect(engine([choice("Log", "Capture")]).runAndGetOutput("Log::entry")).rejects.toThrow( + "'Log' is not a macro, so it has no exports.", + ); + expect(executor.execute).not.toHaveBeenCalled(); + }); + + it("refuses a name that matches choices of different types when case is ignored", async () => { + const choices = [choice("Log", "Capture"), choice("LOG", "Template")]; + + await expect(engine(choices).runAndGetOutput("log")).rejects.toThrow("Ambiguous reference 'log'"); + expect(executor.execute).not.toHaveBeenCalled(); + }); + + it("still runs a macro of that name rather than another choice", async () => { + const choices = [choice("Log", "Capture"), macro("Log")]; + + await expect(engine(choices).runAndGetOutput("Log")).resolves.toBe("Log output"); + expect(macroRun).toHaveBeenCalledWith("Log"); + expect(executor.execute).not.toHaveBeenCalled(); + }); + + it("says when no choice has the name", async () => { + await expect(engine([choice("Log", "Capture")]).runAndGetOutput("Journal")).rejects.toThrow( + "There is no choice named 'Journal'.", + ); + }); +}); diff --git a/src/engine/SingleMacroEngine.ts b/src/engine/SingleMacroEngine.ts index 681b17893..d3d0191e4 100644 --- a/src/engine/SingleMacroEngine.ts +++ b/src/engine/SingleMacroEngine.ts @@ -66,6 +66,26 @@ function formatMacroOutput(value: unknown): string { } } +/** + * The choice named `name`: an exact match on trimmed names first (the first + * one wins, so two choices differing only by case stay distinct), then a + * case-insensitive one, which must be unique. + */ +function findByName(choices: IChoice[], name: string | undefined, reference: string): IChoice | undefined { + const trimmed = (s: string | undefined) => (s ?? "").trim(); + const exact = choices.find((choice) => trimmed(choice.name) === trimmed(name)); + if (exact) return exact; + + const lower = (s: string | undefined) => trimmed(s).toLowerCase(); + const matches = choices.filter((choice) => lower(choice.name) === lower(name)); + if (matches.length > 1) { + const message = `Ambiguous reference '${reference}': several choices match when ignoring case. Rename one of them.`; + log.logError(message); + throw new Error(message); + } + return matches[0]; +} + export class SingleMacroEngine { private readonly choiceExecutor: IChoiceExecutor; private readonly variables: Map; @@ -104,44 +124,31 @@ export class SingleMacroEngine { this.emittedConflictNotice = false; const { basename, memberAccess } = getUserScriptMemberAccess(macroName); - // ------------------------------------------------------------------ - // Step 1 – exact match (case-sensitive) on *trimmed* names. - // This preserves historical behaviour where two macros differing - // only by case are treated as distinct entities. - // ------------------------------------------------------------------ - const trimmed = (s: string | undefined) => (s ?? "").trim(); - let macroChoice = flattenChoices(this.choices).find( - (choice): choice is IMacroChoice => - choice.type === "Macro" && trimmed(choice.name) === trimmed(basename), - ); - - // ------------------------------------------------------------------ - // Step 2 – fallback to case-insensitive lookup for user convenience. - // If this yields *multiple* matches we abort to prevent ambiguity. - // ------------------------------------------------------------------ - if (!macroChoice) { - const lower = (s: string | undefined) => trimmed(s).toLowerCase(); - const ciMatches = flattenChoices(this.choices).filter( - (choice): choice is IMacroChoice => - choice.type === "Macro" && lower(choice.name) === lower(basename), - ); - - if (ciMatches.length > 1) { - log.logError( - `Ambiguous macro reference '${macroName}'. Multiple choices match when ignoring case.`, - ); - throw new Error( - `Ambiguous macro reference '${macroName}'. Please disambiguate by renaming macros.`, - ); - } + // A macro of that name wins, as it did before the token ran any choice. + const choices = flattenChoices(this.choices); + const choice = + findByName(choices.filter((c) => c.type === "Macro"), basename, macroName) ?? + findByName(choices.filter((c) => c.type !== "Macro"), basename, macroName); - macroChoice = ciMatches[0]; + if (!choice) { + const message = `There is no choice named '${macroName}'.`; + log.logError(message); + throw new Error(message); } - if (!macroChoice) { - log.logError(`macro '${macroName}' does not exist.`); - throw new Error(`macro '${macroName}' does not exist.`); + if (choice.type !== "Macro") { + if (memberAccess?.length) { + throw new Error(`'${choice.name}' is not a macro, so it has no exports.`); + } + // Through the executor, as a Choice step runs it: a folder opens its + // picker. The result is the note the choice itself ended on, not one + // an earlier step left. + const run = () => this.choiceExecutor.execute(choice, ancestry); + const note = this.choiceExecutor.noteEndedOn ? await this.choiceExecutor.noteEndedOn(run) : (await run(), null); + this.ensureNotAborted(); + return note?.path ?? ""; } + const macroChoice = choice as IMacroChoice; // Create a dedicated engine for this macro const engine = new MacroChoiceEngine( @@ -357,6 +364,7 @@ export class SingleMacroEngine { } catch (error) { if ( handleMacroAbort(error, { + choiceName: macroChoice.name, logPrefix: "Macro execution aborted", noticePrefix: "Macro execution aborted", defaultReason: "Macro execution aborted", diff --git a/src/engine/TemplateChoiceEngine.audit-template.test.ts b/src/engine/TemplateChoiceEngine.audit-template.test.ts index 60a0b2df3..603b79cea 100644 --- a/src/engine/TemplateChoiceEngine.audit-template.test.ts +++ b/src/engine/TemplateChoiceEngine.audit-template.test.ts @@ -1,6 +1,9 @@ import { createChoiceExecutor } from "../../tests/helpers/createChoiceExecutor"; import { beforeEach, describe, expect, it, vi } from "vitest"; +vi.mock("../utils/templateFolderUtils", async (importOriginal) => + (await import("../../tests/helpers/engines/everyTemplateExists")).everyTemplateExists(importOriginal)); + vi.mock("../quickAddSettingsTab", () => { const defaultSettings = { choices: [], @@ -162,6 +165,7 @@ function createEngine() { }, vault: { getRoot: vi.fn(() => ({ path: "" })), + read: vi.fn(async () => ""), adapter: { exists: vi.fn(async () => false), }, @@ -242,11 +246,11 @@ describe("TemplateChoiceEngine post-commit link failure (audit)", () => { await engine.run(); // Success is still recorded for the created note. - expect(choiceExecutor.recordExecutionResult).toHaveBeenCalledWith({ + expect(choiceExecutor.recordExecutionResult).toHaveBeenCalledWith(expect.objectContaining({ status: "success", file: createdFile, effect: "created", - }); + })); // The link failure surfaces as a warning that names the created file, not // a fatal "Error running template choice". expect( @@ -264,10 +268,35 @@ describe("TemplateChoiceEngine post-commit link failure (audit)", () => { }); }); +describe("TemplateChoiceEngine Undo snapshot after its link (audit)", () => { + it("records, as what the run left, the note after its link went into it as well", async () => { + const { engine, choiceExecutor, app } = createEngine(); + const createdFile = makeTFile("Test Template.md"); + vi.mocked(app.vault.read).mockImplementation(async (file) => file === createdFile ? "# Plan\n" : ""); + vi.mocked(app.workspace.getActiveFile).mockReturnValue(createdFile); + engine.choice.openFile = false; + engine.choice.appendLink = { + enabled: true, placement: "newLine", requireActiveFile: false, linkType: "link", destination: { type: "activeFile" }, + }; + (engine as unknown as { createFileWithTemplate: () => Promise }).createFileWithTemplate = + vi.fn().mockResolvedValue(createdFile); + insertFileLinkToActiveViewMock.mockImplementationOnce(async () => { + vi.mocked(app.vault.read).mockImplementation(async (file) => file === createdFile ? "# Plan\n[[Test Template]]\n" : ""); + }); + + await engine.run(); + + expect(choiceExecutor.recordExecutionResult).toHaveBeenCalledWith(expect.objectContaining({ + write: { path: "Test Template.md", before: null, after: "# Plan\n[[Test Template]]\n" }, + })); + }); +}); + describe("TemplateChoiceEngine create-another collision feedback (audit)", () => { - it("notices the renamed file when a create-another collision occurs and the file is not opened", async () => { - const { engine, app } = createEngine(); + it("reports the renamed file, and what it holds for Undo, when a create-another collision occurs", async () => { + const { engine, app, choiceExecutor } = createEngine(); const createdFile = makeTFile("Plan (1).md"); + vi.mocked(app.vault.read).mockImplementation(async (file) => file === createdFile ? "# Plan\n" : ""); engine.choice.openFile = false; engine.choice.fileExistsBehavior = { @@ -297,11 +326,12 @@ describe("TemplateChoiceEngine create-another collision feedback (audit)", () => "Plan (1).md", engine.choice.templatePath, ); - expect( - noticeClass.instances.some((instance) => - instance.message.includes("Created 'Plan (1)'"), - ), - ).toBe(true); + expect(choiceExecutor.recordExecutionResult).toHaveBeenCalledWith({ + status: "success", + file: createdFile, + effect: "created", + write: { path: "Plan (1).md", before: null, after: "# Plan\n" }, + }); }); // issue #1546: the prompt context line must never promise a folder the answer diff --git a/src/engine/TemplateChoiceEngine.collision.test.ts b/src/engine/TemplateChoiceEngine.collision.test.ts index 5e31093e4..dc85e39e4 100644 --- a/src/engine/TemplateChoiceEngine.collision.test.ts +++ b/src/engine/TemplateChoiceEngine.collision.test.ts @@ -1,6 +1,9 @@ import { createChoiceExecutor } from "../../tests/helpers/createChoiceExecutor"; import { beforeEach, describe, expect, it, vi } from "vitest"; +vi.mock("../utils/templateFolderUtils", async (importOriginal) => + (await import("../../tests/helpers/engines/everyTemplateExists")).everyTemplateExists(importOriginal)); + vi.mock("../quickAddSettingsTab", async () => { const { engineSettingsMock } = await import("../../tests/helpers/engines/settings"); return engineSettingsMock(); @@ -183,6 +186,7 @@ const createEngine = () => { }, vault: { getRoot: vi.fn(() => ({ path: "" })), + read: vi.fn(async () => ""), adapter: { exists: vi.fn(async () => false), }, @@ -361,7 +365,7 @@ describe("TemplateChoiceEngine collision behavior", () => { getPromptModes().find((mode) => mode.id === "duplicateSuffix")?.label, ]), expect.arrayContaining(["appendBottom", "increment", "duplicateSuffix"]), - "If the target file already exists", + "If the note already exists", ); expect(createSpy).not.toHaveBeenCalled(); expect(app.vault.adapter.exists).toHaveBeenCalledWith("Test Template.md"); diff --git a/src/engine/TemplateChoiceEngine.discovery.test.ts b/src/engine/TemplateChoiceEngine.discovery.test.ts index 10c4499af..038758a85 100644 --- a/src/engine/TemplateChoiceEngine.discovery.test.ts +++ b/src/engine/TemplateChoiceEngine.discovery.test.ts @@ -170,6 +170,7 @@ function buildEngine( getAbstractFileByPath: vi.fn((path: string) => files.get(path) ?? null), getFiles: vi.fn(() => [...files.values()]), cachedRead: vi.fn(async (target: TFile) => contents.get(target.path) ?? ""), + read: vi.fn(async (target: TFile) => contents.get(target.path) ?? ""), createFolder: vi.fn(), create: vi.fn(async () => created), modify: vi.fn(async (target: TFile, content: string) => { contents.set(target.path, content); }), @@ -318,6 +319,29 @@ describe("TemplateChoiceEngine note discovery", () => { expect(dateOrder).toBeLessThan(formatFileContentMock.mock.invocationCallOrder[0]); }); + it("records as the note's before-text what it held when the write happened, not when the run started", async () => { + const existing = file("People/Alice.md"); + promptForTemplateNoteDiscoveryMock.mockResolvedValue({ kind: "existing", file: existing }); + const { engine, choiceExecutor, files, contents } = buildEngine(choice({ + existingNoteAction: "appendBottom", + fileNameFormat: { enabled: true, format: "{{VALUE}}" }, + })); + files.set(existing.path, existing); + contents.set(existing.path, "Original body"); + // An edit lands while the template's prompts are open. + formatFileContentMock.mockImplementation(async () => { + contents.set(existing.path, "Edited meanwhile"); + return "Update"; + }); + + await engine.run(); + + expect(contents.get(existing.path)).toBe("Edited meanwhile\n\nUpdate"); + expect(choiceExecutor.recordExecutionResult).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ + write: { path: existing.path, before: "Edited meanwhile", after: "Edited meanwhile\n\nUpdate" }, + })); + }); + it.each([ ["appendBottom", "---\nstatus: active\n---\nOriginal body\n\nUpdate"], ["appendTop", "---\nstatus: active\n---\nUpdate\nOriginal body"], @@ -354,6 +378,7 @@ describe("TemplateChoiceEngine note discovery", () => { expect(choiceExecutor.variables.has("value")).toBe(false); expect(choiceExecutor.recordExecutionResult).toHaveBeenCalledExactlyOnceWith({ status: "success", file: existing, effect: "changed", + write: { path: existing.path, before: "---\nstatus: active\n---\nOriginal body", after: expected }, }); expect(insertFileLinkMock).toHaveBeenCalledTimes(1); expect(copyFileLinkMock).toHaveBeenCalledExactlyOnceWith(existing); diff --git a/src/engine/TemplateChoiceEngine.folderSorting.test.ts b/src/engine/TemplateChoiceEngine.folderSorting.test.ts index 4dee3b248..27bd4dd17 100644 --- a/src/engine/TemplateChoiceEngine.folderSorting.test.ts +++ b/src/engine/TemplateChoiceEngine.folderSorting.test.ts @@ -6,6 +6,9 @@ const { inputSuggestMock, setTargetFolderPath } = vi.hoisted(() => ({ setTargetFolderPath: vi.fn(), })); +vi.mock("../utils/templateFolderUtils", async (importOriginal) => + (await import("../../tests/helpers/engines/everyTemplateExists")).everyTemplateExists(importOriginal)); + vi.mock("../gui/InputSuggester/inputSuggester", () => ({ default: { Suggest: inputSuggestMock, diff --git a/src/engine/TemplateChoiceEngine.notice.test.ts b/src/engine/TemplateChoiceEngine.notice.test.ts index 42e86c619..b0166dbec 100644 --- a/src/engine/TemplateChoiceEngine.notice.test.ts +++ b/src/engine/TemplateChoiceEngine.notice.test.ts @@ -1,6 +1,9 @@ import { createChoiceExecutor } from "../../tests/helpers/createChoiceExecutor"; import { beforeEach, describe, expect, it, vi } from "vitest"; +vi.mock("../utils/templateFolderUtils", async (importOriginal) => + (await import("../../tests/helpers/engines/everyTemplateExists")).everyTemplateExists(importOriginal)); + vi.mock("../quickAddSettingsTab", async () => { const { engineSettingsMock } = await import("../../tests/helpers/engines/settings"); return engineSettingsMock(); @@ -116,6 +119,7 @@ import { UserCancelError } from "../errors/UserCancelError"; import { settingsStore } from "../settingsStore"; import { InputPromptDraftStore } from "../utils/InputPromptDraftStore"; import { insertFileLinkToActiveView } from "../utils/editorInsertion"; +import { getTemplateFile } from "../utils/templateFolderUtils"; const defaultSettingsState = structuredClone(settingsStore.getState()); @@ -163,6 +167,7 @@ const createEngine = ( }, vault: { getRoot: vi.fn(() => ({ path: "" })), + read: vi.fn(async () => ""), adapter: { exists: vi.fn(async () => false), }, @@ -518,11 +523,11 @@ describe("TemplateChoiceEngine cancellation notices", () => { await engine.run(); - expect(choiceExecutor.recordExecutionResult).toHaveBeenCalledWith({ + expect(choiceExecutor.recordExecutionResult).toHaveBeenCalledWith(expect.objectContaining({ status: "success", file: createdFile, effect: "created", - }); + })); }); /** @@ -549,6 +554,37 @@ describe("TemplateChoiceEngine cancellation notices", () => { }); }); + it.each([ + ["Templates/Test.md", "the template Templates/Test.md does not exist, so no note was created. Pick a template on the choice's page."], + ["", "no template is picked, so no note was created. Pick a template on the choice's page."], + ])("refuses a template path %j in one sentence naming the choice, before asking the title", async (templatePath, reason) => { + const { engine, choiceExecutor } = createEngine("unused", { throwDuringFileName: false }); + (engine as unknown as { choice: ITemplateChoice }).choice.templatePath = templatePath; + if (templatePath) vi.mocked(getTemplateFile).mockReturnValueOnce(null); + choiceExecutor.recordExecutionResult = vi.fn(); + + await engine.run(); + + const sentence = `Test Template Choice: ${reason}`; + expect(choiceExecutor.recordExecutionResult).toHaveBeenCalledWith({ status: "error", reason: sentence }); + expect(noticeClass.instances.map((notice) => notice.message)).toEqual([sentence]); + expect(vi.mocked(choiceExecutor.signalAbort!).mock.calls[0]?.[0]?.message).toBe(sentence); + expect(formatFileNameMock).not.toHaveBeenCalled(); + }); + + it("refuses a template path with a token once it is resolved, before asking the title", async () => { + const { engine, choiceExecutor } = createEngine("unused", { throwDuringFileName: false }); + (engine as unknown as { choice: ITemplateChoice }).choice.templatePath = "Templates/{{VALUE:type}}.md"; + vi.mocked(getTemplateFile).mockReturnValueOnce(null); + choiceExecutor.recordExecutionResult = vi.fn(); + + await engine.run(); + + const sentence = "Test Template Choice: the template Templates/{{VALUE:type}}.md does not exist, so no note was created. Pick a template on the choice's page."; + expect(choiceExecutor.recordExecutionResult).toHaveBeenCalledWith({ status: "error", reason: sentence }); + expect(formatFileNameMock).not.toHaveBeenCalled(); + }); + // A failure exit that is not a throw used to record nothing at all, which is the // same reason-less outcome reached without any exception. it("records a reason when the file could not be created", async () => { @@ -630,11 +666,11 @@ describe("TemplateChoiceEngine cancellation notices", () => { store.commitExecutionScope(draftScope); expect(copyFileLinkToClipboardMock).toHaveBeenCalledWith(createdFile); - expect(choiceExecutor.recordExecutionResult).toHaveBeenCalledWith({ + expect(choiceExecutor.recordExecutionResult).toHaveBeenCalledWith(expect.objectContaining({ status: "success", file: createdFile, effect: "created", - }); + })); expect(store.get(draftKey)).toBeUndefined(); }); @@ -676,11 +712,11 @@ describe("TemplateChoiceEngine cancellation notices", () => { destination: { type: "activeFile" }, }), ); - expect(choiceExecutor.recordExecutionResult).toHaveBeenCalledWith({ + expect(choiceExecutor.recordExecutionResult).toHaveBeenCalledWith(expect.objectContaining({ status: "success", file: createdFile, effect: "created", - }); + })); }); }); diff --git a/src/engine/TemplateChoiceEngine.ts b/src/engine/TemplateChoiceEngine.ts index 812af8d2e..dc43e1934 100644 --- a/src/engine/TemplateChoiceEngine.ts +++ b/src/engine/TemplateChoiceEngine.ts @@ -1,7 +1,6 @@ -import { appendLinkDestinationError, insertChoiceFileLink, copyChoiceFileLink, openChoiceFile } from "./choiceFileActions"; +import { appendLinkDestinationError, insertChoiceFileLink, copyChoiceFileLink, linkDestinationFile, openChoiceFile } from "./choiceFileActions"; import type { App, WorkspaceLeaf } from "obsidian"; -import { Notice, TFile } from "obsidian"; -import invariant from "src/utils/invariant"; +import { TFile } from "obsidian"; import { VALUE_SYNTAX } from "../constants"; import { isMarkdownTemplatePath } from "./applyTemplateToActiveNote"; import GenericSuggester from "../gui/GenericSuggester/genericSuggester"; @@ -21,7 +20,7 @@ import { resolveTemplateNoteSelection } from "src/utils/templateNoteDiscovery"; import { shouldRunTemplateNoteDiscovery } from "src/utils/templateNoteDiscoveryEligibility"; import { getPreparedTemplateNoteSelection } from "src/preflight/preparedChoiceInputs"; import type ITemplateChoice from "../types/choices/ITemplateChoice"; -import type { ChoiceEffect } from "../types/ChoiceOutcome"; +import type { ChoiceEffect, NoteWrite } from "../types/ChoiceOutcome"; import { routePrompt } from "../interactive/routePrompt"; import { promptEngineChoice } from "../interactive/engineChoice"; import { @@ -47,9 +46,10 @@ import type { ChoiceChain } from "./choiceChain"; import { MacroAbortError } from "../errors/MacroAbortError"; import { ChoiceAbortError } from "../errors/ChoiceAbortError"; import { handleMacroAbort } from "../utils/macroAbortHandler"; +import { RefusalError } from "../errors/RefusalError"; +import { checkTemplateSource, templateFileOrRefuse } from "./templateSource"; import { parentFolderPath } from "../utils/pathUtils"; import { mapEditorCursorPlacement } from "../utils/editorCursorPlacement"; -import { getTemplateFile } from "../utils/templateFolderUtils"; type NormalizedAppendLinkOptions = ReturnType; @@ -85,12 +85,7 @@ export class TemplateChoiceEngine extends TemplateEngine { let selectedUpdate: { file: TFile; mode: Exclude } | null = null; try { - invariant(this.choice.templatePath, () => { - return `Invalid template path for ${this.choice.name}. ${this.choice.templatePath.length === 0 - ? "Template path is empty." - : `Template path is not valid: ${this.choice.templatePath}` - }`; - }); + checkTemplateSource(this.app, this.choice); const linkOptions = normalizeAppendLinkOptions(this.choice.appendLink); this.setLinkToCurrentFileBehavior( @@ -140,7 +135,9 @@ export class TemplateChoiceEngine extends TemplateEngine { const templatePath = await this.resolveTemplateSourcePath( this.choice.templatePath, ); - if (selectedUpdate && getTemplateFile(this.app, templatePath)?.path === selectedUpdate.file.path) { + const templateFile = templateFileOrRefuse(this.app, templatePath, + selectedUpdate ? "the note was not changed" : "no note was created"); + if (selectedUpdate && templateFile.path === selectedUpdate.file.path) { throw new ChoiceAbortError("Cannot apply a template to its own template source."); } @@ -150,7 +147,6 @@ export class TemplateChoiceEngine extends TemplateEngine { let createdFile: TFile | null; let shouldAutoOpen = false; - let createdNew = false; // What this run did to its target note (#1615). Derived from the file-exists // resolution the engine actually performed rather than from a byte compare, // which is exact for the two answers an automation acts on: "createNew" @@ -159,6 +155,8 @@ export class TemplateChoiceEngine extends TemplateEngine { // inferred: it always writes, so `changed` can in principle over-report a // write whose bytes happened to match, which is the harmless direction. let effect: ChoiceEffect = "created"; + // The content before this run's write is read at the write (writtenBefore). + this.writtenBefore = null; if (selectedUpdate) { if (!isMarkdownTemplatePath(templatePath)) { throw new ChoiceAbortError("Only Markdown templates can be applied to a selected note."); @@ -234,18 +232,20 @@ export class TemplateChoiceEngine extends TemplateEngine { ); return; } - createdNew = true; } // File is created/resolved (the commit point). Record success before // append-link/open-file steps so a later post-commit failure cannot make // automation callers retry and duplicate the Template side effect. - this.outcome.success(createdFile, effect); + const write: NoteWrite | undefined = effect === "unchanged" ? undefined : { + path: createdFile.path, before: this.writtenBefore, after: await this.app.vault.read(createdFile), + }; + this.outcome.success(createdFile, effect, write); const cursorBeforeLink = this.cursorPlacement; if (linkOptions.enabled && createdFile) { // The note is already committed (success recorded above). A link - // failure here — most commonly strict "Link to created file" with no + // failure here - most commonly strict "Link to created note" with no // active Markdown view — must not surface as "Error running template // choice", which implies the run failed and tempts a duplicate re-run. // Report it as a non-fatal warning that names the created file. @@ -256,6 +256,11 @@ export class TemplateChoiceEngine extends TemplateEngine { this.cursorPlacement = mapEditorCursorPlacement(this.cursorPlacement, mutation); } } : undefined); + // The link may have gone into the note itself; Undo compares the note + // with what the run left, so the recorded write takes the text after it. + if (write && linkDestinationFile(this.app, linkOptions, this.choiceExecutor.focusedProperty)?.path === createdFile.path) { + write.after = await this.app.vault.read(createdFile); + } } catch (linkError) { // An abort propagating through the link step still aborts the run. if (linkError instanceof MacroAbortError) { @@ -288,24 +293,17 @@ export class TemplateChoiceEngine extends TemplateEngine { if (!this.templaterCursorHandled && !await jumpToNextTemplaterCursorIfPossible(this.app, createdFile)) { this.placeCursor(createdFile, cursorBeforeLink); } - } else if ( - createdNew && - !linkOptions.enabled && - !this.choice.copyLinkToClipboard - ) { - // The note was created but nothing else surfaces it (not opened, no - // link appended, not copied to clipboard). Confirm the creation so - // the run isn't silent — mirroring Capture's success notice. - new Notice(`Created '${createdFile.basename}'.`); } } catch (err) { if ( handleMacroAbort(err, { + choiceName: this.choice.name, logPrefix: "Template execution aborted", noticePrefix: "Template execution aborted", defaultReason: "Template execution aborted", }) ) { + if (err instanceof RefusalError) this.outcome.failure(err.message); this.choiceExecutor.signalAbort?.(err); return; } @@ -430,7 +428,7 @@ export class TemplateChoiceEngine extends TemplateEngine { } const promptModes = getPromptModes(); - const placeholder = "If the target file already exists"; + const placeholder = "If the note already exists"; return (await routePrompt(this.choiceExecutor, { // An interactive run drives this from the client, like every other prompt @@ -492,24 +490,8 @@ export class TemplateChoiceEngine extends TemplateEngine { async (path) => await this.app.vault.adapter.exists(path), ); - const createdFile = await this.createFileWithTemplate( - nextFilePath, - templatePath, - ); - - // A collision forced a different name. If the file won't be opened, - // the user otherwise gets no signal which name was actually used and - // may re-run, accumulating "Plan (1)", "Plan (2)", … clutter. - if ( - createdFile && - nextFilePath !== targetFilePath && - !this.choice.openFile - ) { - new Notice(`Created '${createdFile.basename}'.`); - } - return { - createdFile, + createdFile: await this.createFileWithTemplate(nextFilePath, templatePath), shouldAutoOpen: false, }; } @@ -601,6 +583,7 @@ export class TemplateChoiceEngine extends TemplateEngine { const file = await this.withAnonymousValueForInsertEngine(() => insertEngine.apply() ); + this.writtenBefore = insertEngine.writeBefore; this.cursorPlacement = insertEngine.getCursorPlacement(); return file; } diff --git a/src/engine/TemplateEngine.ts b/src/engine/TemplateEngine.ts index 25896e095..6d93c077e 100644 --- a/src/engine/TemplateEngine.ts +++ b/src/engine/TemplateEngine.ts @@ -19,7 +19,7 @@ import type { ChoiceChain } from "./choiceChain"; import type { App, TFile } from "obsidian"; import { TFolder } from "obsidian"; import type QuickAdd from "../main"; -import { getTemplateFile, resolveTemplatePath } from "../utils/templateFolderUtils"; +import { resolveTemplatePath } from "../utils/templateFolderUtils"; import { getTemplater, overwriteTemplaterOnce, templaterParseTemplate } from "../utils/templaterIntegration"; import { BASE_FILE_EXTENSION_REGEX, @@ -34,6 +34,7 @@ import { MacroAbortError } from "../errors/MacroAbortError"; import type { IChoiceExecutor } from "../IChoiceExecutor"; import { log } from "../logger/logManager"; import { assertCreatableFilePath } from "./assertCreatableFilePath"; +import { templateFileOrRefuse } from "./templateSource"; import { restoreUserText, restoreUserTextAt } from "../formatters/helpers/userText"; function isMacroAbortError(error: unknown): error is MacroAbortError { @@ -229,8 +230,8 @@ export abstract class TemplateEngine extends FolderSelectionEngine { /** * Why the last template write failed. * - * Each of the write helpers below reports the real cause ("Template file not found at - * path …") and then returns null, so its caller only knew THAT the write failed, not + * Each of the write helpers below reports the real cause ("Could not create file with + * template at …") and then returns null, so its caller only knew THAT the write failed, not * why - and the caller is what records the run's outcome. A remote client was told * "Choice execution failed; no file was created." while the actionable sentence went * to a desktop notice nobody was watching (#1603). @@ -299,7 +300,7 @@ export abstract class TemplateEngine extends FolderSelectionEngine { try { const templateContent: string = await this.getTemplateContent( - resolvedTemplatePath + resolvedTemplatePath, "no note was created", ); const { content: formattedTemplateContent, variables: templateVars } = @@ -390,6 +391,18 @@ export abstract class TemplateEngine extends FolderSelectionEngine { + /** + * What the note held right before this engine last wrote to it, read at + * the moment of the write so an edit made while a prompt was open is not + * undone with the run; null for a note the run created. + */ + protected writtenBefore: string | null = null; + + /** The note's text right before this engine's last write; null for a created note. */ + get writeBefore(): string | null { + return this.writtenBefore; + } + protected async overwriteFileWithTemplate( file: TFile, resolvedTemplatePath: string @@ -397,14 +410,17 @@ export abstract class TemplateEngine extends FolderSelectionEngine { this.lastTemplateFileFailure = null; try { const templateContent: string = await this.getTemplateContent( - resolvedTemplatePath + resolvedTemplatePath, "the note was not changed", ); const { content: formattedTemplateContent, variables: templateVars } = await this.prepareTemplateBody(templateContent, file.path, file.basename, "overwriteFileWithTemplate"); - await processNote(this.app, file, () => formattedTemplateContent); + await processNote(this.app, file, (content) => { + this.writtenBefore = content; + return formattedTemplateContent; + }); let rendered = false; try { @@ -439,7 +455,7 @@ export abstract class TemplateEngine extends FolderSelectionEngine { this.lastTemplateFileFailure = null; try { const templateContent: string = await this.getTemplateContent( - resolvedTemplatePath + resolvedTemplatePath, "the note was not changed", ); this.setTemplateDestination(file.path, file.basename); @@ -459,6 +475,7 @@ export abstract class TemplateEngine extends FolderSelectionEngine { } formattedTemplateContent = restoreUserText(formattedTemplateContent); const fileContent: string = await this.app.vault.cachedRead(file); + this.writtenBefore = fileContent; const newFileContent: string = section === "top" ? `${formattedTemplateContent}\n${fileContent}` @@ -482,14 +499,12 @@ export abstract class TemplateEngine extends FolderSelectionEngine { * This method intentionally does not format, so {{date}}/{{random}} in a * template path won't re-evaluate between extension derivation and reading. */ - protected async getTemplateContent(resolvedTemplatePath: string): Promise { - const templateFile = getTemplateFile(this.app, resolvedTemplatePath); - - if (!templateFile) - throw new Error( - `Template file not found at path "${resolvedTemplatePath}".` - ); - - return await this.app.vault.cachedRead(templateFile); + protected async getTemplateContent( + resolvedTemplatePath: string, + consequence = "nothing was written", + ): Promise { + return await this.app.vault.cachedRead( + templateFileOrRefuse(this.app, resolvedTemplatePath, consequence), + ); } } diff --git a/src/engine/TemplateInsertEngine.ts b/src/engine/TemplateInsertEngine.ts index b69a2a836..f1bced402 100644 --- a/src/engine/TemplateInsertEngine.ts +++ b/src/engine/TemplateInsertEngine.ts @@ -333,6 +333,7 @@ export class TemplateInsertEngine extends TemplateEngine { const cursor = this.cursorPlacement; if (body.trim().length > 0 || cursor) { await processNote(this.app, this.targetFile, (noteContent) => { + this.writtenBefore = noteContent; const inserted = insertBodyIntoNoteContent(noteContent, body, position); if (cursor && inserted.insertedStartOffset !== null) { const blockStart = inserted.insertedStartOffset; diff --git a/src/engine/choiceFileActions.ts b/src/engine/choiceFileActions.ts index 225d95977..1a848c70a 100644 --- a/src/engine/choiceFileActions.ts +++ b/src/engine/choiceFileActions.ts @@ -33,6 +33,16 @@ export async function insertChoiceFileLink( } } +/** The note {@link insertChoiceFileLink} puts the link in, branch for branch. */ +export function linkDestinationFile( + app: App, options: AppendLinkOptions, focusedProperty: IChoiceExecutor["focusedProperty"], +): TFile | null { + if (!options.enabled) return null; + if (options.destination?.type === "specifiedFile") return getAppendLinkDestinationFile(app, options.destination); + if (focusedProperty && !placementSupportsFrontmatter(options.placement)) return focusedProperty.file; + return app.workspace.getActiveFile(); +} + export async function copyChoiceFileLink(file: TFile): Promise { try { await copyFileLinkToClipboard(file); diff --git a/src/engine/choiceOutcomeRecorder.ts b/src/engine/choiceOutcomeRecorder.ts index 2e7546ec1..2a48ddff0 100644 --- a/src/engine/choiceOutcomeRecorder.ts +++ b/src/engine/choiceOutcomeRecorder.ts @@ -1,6 +1,6 @@ import type { TFile } from "obsidian"; import type { IChoiceExecutor } from "../IChoiceExecutor"; -import type { ChoiceEffect } from "../types/ChoiceOutcome"; +import type { ChoiceEffect, NoteWrite } from "../types/ChoiceOutcome"; /** * Records what a choice run actually did, for the callers that must report it back to @@ -52,10 +52,17 @@ export class ChoiceOutcomeRecorder { * so a new one cannot inherit a positive "something landed" by omission. The four * existing sites split evenly — two of them (Template's "Do nothing" mode and its * open-an-existing-note discovery path) commit nothing at all. + * + * `write` is what the run wrote, for Undo. An `unchanged` run wrote nothing, so it + * never carries one. */ - success(file: TFile | undefined, effect: ChoiceEffect): void { + success(file: TFile | undefined, effect: ChoiceEffect, write?: NoteWrite): void { this.closed = true; - this.executor.recordExecutionResult?.({ status: "success", file, effect }); + this.executor.recordExecutionResult?.( + write && effect !== "unchanged" + ? { status: "success", file, effect, write } + : { status: "success", file, effect }, + ); } /** diff --git a/src/engine/helpers/stepNote.ts b/src/engine/helpers/stepNote.ts new file mode 100644 index 000000000..1fd8fb7c2 --- /dev/null +++ b/src/engine/helpers/stepNote.ts @@ -0,0 +1,58 @@ +import { TFile, type App } from "obsidian"; +import { refuse } from "../../errors/RefusalError"; +import { CompleteFormatter } from "../../formatters/completeFormatter"; +import type { IChoiceExecutor } from "../../IChoiceExecutor"; +import { log } from "../../logger/logManager"; +import { getQuickAddInstance } from "../../quickAddInstance"; +import type IChoice from "../../types/choices/IChoice"; +import type { ChoiceChain } from "../choiceChain"; +import { isRunNoteToken } from "../CaptureTargetEngine"; + +export interface StepNoteContext { + app: App; + executor: IChoiceExecutor; + /** The macro or action the step belongs to: it names the prompts and their drafts. */ + choice: Pick; + chain: ChoiceChain; +} + +/** + * The note a step works on: the run note for `{{NOTE}}`, which refuses with + * `consequence` before any note is written, else the note at the formatted + * `path`. Null, with the reason logged under `label`, when the path names no + * note. `scope` keeps the drafts of a `{{VALUE}}` in the path apart per step. + */ +export async function resolveStepNote( + { app, executor, choice, chain }: StepNoteContext, + path: string, + { scope, label, consequence }: { scope: string; label: string; consequence: string }, +): Promise { + if (isRunNoteToken(path)) { + if (!executor.runNote) throw refuse("nothing has written a note yet", consequence); + return executor.runNote; + } + const formatter = new CompleteFormatter(app, getQuickAddInstance(), executor); + formatter.setPromptRunContext({ choiceName: choice.name, draftScopeId: `${choice.id}#${scope}` }); + formatter.choiceChain = chain; + const normalizedPath = (await formatter.formatFileName(path, "filePath")).replace(/\\/g, "/"); + + // Only a literal '..' segment or an empty one (from '//') is a traversal or + // a malformed path; a file name may contain "..". The leading slash of an + // absolute path and a single trailing slash are allowed. + const segments = normalizedPath.split("/"); + const hasTraversal = segments.some( + (segment, index) => + segment === ".." || (segment === "" && index !== 0 && index !== segments.length - 1), + ); + if (hasTraversal) { + log.logError(`${label}: Path traversal not allowed in '${normalizedPath}'`); + return null; + } + + const file = app.vault.getAbstractFileByPath(normalizedPath); + if (!(file instanceof TFile)) { + log.logError(`${label}: '${normalizedPath}' does not exist or is not a file`); + return null; + } + return file; +} diff --git a/src/engine/macroAI.test.ts b/src/engine/macroAI.test.ts new file mode 100644 index 000000000..50e9d4399 --- /dev/null +++ b/src/engine/macroAI.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { App } from "obsidian"; +import type IMacroChoice from "../types/choices/IMacroChoice"; +import type { IAIAssistantCommand } from "../types/macros/QuickCommands/IAIAssistantCommand"; +import { createChoiceExecutor } from "../../tests/helpers/createChoiceExecutor"; +import { RefusalError } from "../errors/RefusalError"; + +const { state } = vi.hoisted(() => ({ + state: { disableOnlineFeatures: false, ai: { providers: [] as unknown[] } }, +})); + +vi.mock("../settingsStore", () => ({ settingsStore: { getState: () => state } })); +vi.mock("../ai/AIAssistant", () => ({ runAIAssistant: vi.fn() })); +vi.mock("../formatters/completeFormatter", () => ({ CompleteFormatter: class {} })); +vi.mock("../quickAddInstance", () => ({ getQuickAddInstance: vi.fn() })); +vi.mock("../ai/aiHelpers", () => ({ resolveModel: vi.fn(() => undefined) })); + +const { executeMacroAI, pickMacroModel } = await import("./macroAI"); + +const app = {} as App; +const choice = { name: "Summarize" } as IMacroChoice; +const command = { id: "ai", model: "gpt-9" } as IAIAssistantCommand; + +async function refusal(run: Promise): Promise { + const error = await run.catch((e: unknown) => e); + expect(error).toBeInstanceOf(RefusalError); + return (error as Error).message; +} + +describe("AI steps that are not set up", () => { + beforeEach(() => { + state.disableOnlineFeatures = false; + state.ai.providers = []; + }); + + it("refuses with online features off", async () => { + state.disableOnlineFeatures = true; + expect(await refusal(executeMacroAI(app, choice, createChoiceExecutor(), [], command, vi.fn()))) + .toBe("Online features are off, so the AI request was not sent. Turn off \"Disable AI & online features\" in QuickAdd's settings."); + }); + + it("refuses a model no provider offers", async () => { + expect(await refusal(executeMacroAI(app, choice, createChoiceExecutor(), [], command, vi.fn()))) + .toBe("No AI provider offers the model gpt-9, so the AI request was not sent. Pick a model on the step's row."); + }); + + it("refuses to ask for a model when none is set up", async () => { + expect(await refusal(pickMacroModel(app, createChoiceExecutor()))) + .toBe("No AI models are set up, so the AI request was not sent. Add a provider with models in QuickAdd's AI settings."); + }); +}); diff --git a/src/engine/macroAI.ts b/src/engine/macroAI.ts index 0982c2fc3..6e9e426b2 100644 --- a/src/engine/macroAI.ts +++ b/src/engine/macroAI.ts @@ -14,15 +14,15 @@ import { isCancellationError } from "../utils/errorUtils"; import { ChoiceAbortError } from "../errors/ChoiceAbortError"; import { UserCancelError } from "../errors/UserCancelError"; import GenericSuggester from "../gui/GenericSuggester/genericSuggester"; +import { onlineFeaturesOffRefusal, unknownModelRefusal } from "../ai/aiRefusals"; +import { refuse } from "../errors/RefusalError"; export async function executeMacroAI( app: App, choice: IMacroChoice, executor: IChoiceExecutor, chain: ChoiceChain, command: IAIAssistantCommand, chooseModel: () => Promise, ) { if (settingsStore.getState().disableOnlineFeatures) { - throw new Error( - "Blocking request: Online features are disabled in settings." - ); + throw onlineFeaturesOffRefusal(); } const aiSettings = settingsStore.getState().ai; @@ -39,9 +39,7 @@ export async function executeMacroAI( activeModelRef(command.model, command.modelRef) ?? command.model, ); if (!resolved) { - throw new Error( - `Model ${command.model} not found with any provider.`, - ); + throw unknownModelRefusal(command.model); } } @@ -107,9 +105,7 @@ export async function pickMacroModel(app: App, executor: IChoiceExecutor): Promi ); if (entries.length === 0) { - throw new Error( - "No AI models are configured. Add a provider with models in the AI Assistant settings.", - ); + throw refuse("no AI models are set up", "the AI request was not sent", "Add a provider with models in QuickAdd's AI settings."); } // Route to a remote interactive session (Raycast) when one is driving. diff --git a/src/engine/runTemplateFromFolder.test.ts b/src/engine/runTemplateFromFolder.test.ts index b10756262..9e30afd9e 100644 --- a/src/engine/runTemplateFromFolder.test.ts +++ b/src/engine/runTemplateFromFolder.test.ts @@ -240,12 +240,16 @@ describe("createFolderTemplateChoice + real preflight collector", () => { expect(unresolved.some((r) => r.id === "value")).toBe(true); }); - it("negative control: disabling fileNameFormat hides the note-name requirement", async () => { + it("negative control: with fileNameFormat disabled, an editor selection fills the note name", async () => { const executor = createExecutor(); const choice = createFolderTemplateChoice("Templates/Daily.md"); choice.fileNameFormat = { enabled: false, format: VALUE_SYNTAX }; + const app = { + ...collectorApp, + workspace: { getActiveViewOfType: () => ({ editor: { getSelection: () => "Selected" } }) }, + } as unknown as App; const reqs = await collectChoiceRequirements( - collectorApp, + app, collectorPlugin, executor, choice, diff --git a/src/engine/runTemplateFromFolder.ts b/src/engine/runTemplateFromFolder.ts index ebdf4a597..f94ce54a8 100644 --- a/src/engine/runTemplateFromFolder.ts +++ b/src/engine/runTemplateFromFolder.ts @@ -28,12 +28,9 @@ function templateDisplayName(path: string): string { * Not persisted and never added to settings.choices — it exists only for the * duration of one ChoiceExecutor.execute() call. * - * `fileNameFormat.enabled` MUST be true. Runtime is identical to `enabled:false` - * (TemplateChoiceEngine resolves both to VALUE_SYNTAX), but collectChoiceRequirements - * only scans the file-name format when it is enabled — so with `enabled:false` the - * implicit {{value}} note-name prompt is invisible to the non-interactive CLI guard - * (it would pass with zero unresolved inputs and then hang on an interactive prompt) - * and to the one-page input form (which would omit the name field). + * `fileNameFormat.enabled` is true so the note name is always a collected input; + * with `enabled:false` collectChoiceRequirements leaves it to the editor's + * selection when there is one. */ export function createFolderTemplateChoice(templatePath: string): ITemplateChoice { const choice = new TemplateChoice(templateDisplayName(templatePath)); diff --git a/src/engine/templateSource.test.ts b/src/engine/templateSource.test.ts new file mode 100644 index 000000000..04dcce0c9 --- /dev/null +++ b/src/engine/templateSource.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../utils/templateFolderUtils", () => ({ + getTemplateFile: (_app: unknown, path: string) => (path === "Templates/There.md" ? { path } : null), +})); + +import { checkTemplateSource } from "./templateSource"; + +const app = {} as never; + +describe("checkTemplateSource", () => { + it("refuses a missing template before anything is asked", () => { + expect(() => checkTemplateSource(app, { templatePath: "Templates/Missing.md" })).toThrow("does not exist"); + expect(() => checkTemplateSource(app, { templatePath: "" })).toThrow("No template is picked"); + expect(() => checkTemplateSource(app, { templatePath: "Templates/There.md" })).not.toThrow(); + }); + + it("does not refuse up front a run that may open an existing note instead", () => { + expect(() => + checkTemplateSource(app, { templatePath: "Templates/Missing.md", discoverExistingNotesBeforeCreate: true }), + ).not.toThrow(); + }); +}); diff --git a/src/engine/templateSource.ts b/src/engine/templateSource.ts new file mode 100644 index 000000000..b7ed0b25f --- /dev/null +++ b/src/engine/templateSource.ts @@ -0,0 +1,33 @@ +import type { App, TFile } from "obsidian"; +import { refuse } from "../errors/RefusalError"; +import type ITemplateChoice from "../types/choices/ITemplateChoice"; +import { getTemplateFile } from "../utils/templateFolderUtils"; +import { hasTemplatePathSyntax } from "../utils/templatePathSyntax"; + +const PICK_TEMPLATE = "Pick a template on the choice's page."; + +/** The template file at a resolved template path, or a refusal saying it is not there. */ +export function templateFileOrRefuse(app: App, resolvedTemplatePath: string, consequence: string): TFile { + const file = getTemplateFile(app, resolvedTemplatePath); + if (!file) throw refuse(`the template ${resolvedTemplatePath} does not exist`, consequence, PICK_TEMPLATE); + return file; +} + +/** + * Refuses a Template run whose template is not picked or not there, before it + * asks anything. A path with format syntax names its file only once its answers + * are in, so the run checks that one when it resolves the path, still before the + * note's title. + */ +export function checkTemplateSource( + app: App, + choice: Pick, +): void { + // A run that may open an existing note needs no template for that; the + // creation path checks when it comes to creating. + if (choice.discoverExistingNotesBeforeCreate) return; + if (!choice.templatePath) throw refuse("no template is picked", "no note was created", PICK_TEMPLATE); + if (!hasTemplatePathSyntax(choice.templatePath)) { + templateFileOrRefuse(app, choice.templatePath, "no note was created"); + } +} diff --git a/src/engine/userScriptExecution.ts b/src/engine/userScriptExecution.ts index 4b682cca0..b08daa547 100644 --- a/src/engine/userScriptExecution.ts +++ b/src/engine/userScriptExecution.ts @@ -1,10 +1,10 @@ -import type { App } from "obsidian"; +import type { App, TFile } from "obsidian"; import type * as obsidian from "obsidian"; import type { QuickAddApi } from "../quickAddApi"; import type QuickAdd from "../main"; import type { IChoiceExecutor } from "../IChoiceExecutor"; import type { IUserScript } from "../types/macros/IUserScript"; -import { getUserScriptPreloadKey, type LoadedUserScript, loadUserScript } from "../utils/userScript"; +import { emptyScriptRefusal, getUserScriptPreloadKey, type LoadedUserScript, loadUserScript } from "../utils/userScript"; import { initializeUserScriptSettings } from "../utils/userScriptSettings"; import { resolveScriptSettings } from "./userScriptSettings"; import { log } from "../logger/logManager"; @@ -22,6 +22,8 @@ export type ScriptParameters = { variables: Record; obsidian: typeof obsidian; abort: (message?: string) => never; + /** The note this run last created or wrote to, `{{NOTE}}`; null before the first write. */ + readonly note: TFile | null; }; type ScriptContext = { @@ -56,10 +58,7 @@ export async function executeUserScript( if (cacheKey !== undefined && loaded !== undefined) preloadedUserScripts.delete(cacheKey); if (loaded === undefined) loaded = await loadUserScript(command, app); const userScript = loaded?.script; - if (!userScript) { - log.logError(`failed to load user script ${command.path}.`); - return; - } + if (!userScript) throw emptyScriptRefusal(command.path); if (!command.settings) command.settings = {}; // Read from the module, not the `::`-drilled export (a bare function for @@ -75,9 +74,7 @@ export async function executeUserScript( async function delegate(value: unknown): Promise { if (isUserScriptFunction(value)) return invoke(value); if (isRecord(value)) { - if (Object.keys(value).length === 0) { - throw new Error(`user script in macro for '${choiceName}' is an empty object`); - } + if (Object.keys(value).length === 0) throw emptyScriptRefusal(command.path); if (isUserScriptFunction(value.entry)) return invoke(value.entry); const keys = Object.keys(value); try { diff --git a/src/errors/RefusalError.test.ts b/src/errors/RefusalError.test.ts new file mode 100644 index 000000000..61ef64634 --- /dev/null +++ b/src/errors/RefusalError.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Notice } from "obsidian"; +import { log } from "../logger/logManager"; +import { reportError, reportRefusal } from "../utils/errorUtils"; +import { MacroAbortError } from "./MacroAbortError"; +import { claimRefusal, refuse } from "./RefusalError"; + +const notices = Notice as unknown as { instances: Array<{ message: string }> }; + +describe("refusals", () => { + beforeEach(() => { + notices.instances.length = 0; + }); + + it("reads what is missing, what did not happen, and the one thing to do", () => { + expect(refuse("the template T.md does not exist", "no note was created", "Pick a template on the choice's page.").message) + .toBe("The template T.md does not exist, so no note was created. Pick a template on the choice's page."); + expect(refuse("no note is open", "there is nothing to add to").message) + .toBe("No note is open, so there is nothing to add to."); + }); + + it("keeps a proper noun's capital after the choice's name", () => { + const refusal = refuse("Periodic Notes has monthly notes off", "{{MONTHLY}} has no note to point at"); + expect(claimRefusal(refusal, "Log")).toBe("Log: Periodic Notes has monthly notes off, so {{MONTHLY}} has no note to point at."); + }); + + it("stops a run like an abort", () => { + expect(refuse("a", "b")).toBeInstanceOf(MacroAbortError); + }); + + it("names the first choice that claims it, so a sequence names its step", () => { + const refusal = refuse("no note is open", "there is nothing to add to"); + expect(claimRefusal(refusal, "Quick capture")).toBe("Quick capture: no note is open, so there is nothing to add to."); + expect(claimRefusal(refusal, "Morning routine")).toBe("Quick capture: no note is open, so there is nothing to add to."); + expect(refusal.message).toBe("Quick capture: no note is open, so there is nothing to add to."); + }); + + it("shows one plain notice, logs it as a message, and keeps a later error report quiet", () => { + const logError = vi.spyOn(log, "logError").mockImplementation(() => {}); + const logMessage = vi.spyOn(log, "logMessage").mockImplementation(() => {}); + const refusal = refuse("no note is open", "there is nothing to add to"); + + reportRefusal(refusal, "Quick capture"); + reportRefusal(refusal, "Morning routine"); + reportError(refusal, "Could not run \"Morning routine\""); + + expect(notices.instances.map((notice) => notice.message)).toEqual(["Quick capture: no note is open, so there is nothing to add to."]); + expect(logMessage).toHaveBeenCalledWith("Quick capture: no note is open, so there is nothing to add to."); + expect(logError).not.toHaveBeenCalled(); + logError.mockRestore(); + logMessage.mockRestore(); + }); +}); diff --git a/src/errors/RefusalError.ts b/src/errors/RefusalError.ts new file mode 100644 index 000000000..6f4e1d138 --- /dev/null +++ b/src/errors/RefusalError.ts @@ -0,0 +1,43 @@ +import { MacroAbortError } from "./MacroAbortError"; + +/** + * A run that stops on purpose because something it needs is not set up, such as + * the Daily notes core plugin for `{{DAILY}}` or a template file that is not + * there. Not a bug: the user sees one plain sentence naming the choice, what is + * missing, what did not happen and the one thing to do, without the "Error + * running ..." context or the logger's error prefix. The CLI and URI outcomes + * carry the same sentence as their reason. + * + * An abort, so it stops an enclosing sequence the way any abort does. The + * innermost choice that stops names itself in the sentence (see {@link claimRefusal}). + */ +export class RefusalError extends MacroAbortError { + /** The choice the sentence names, once a run has claimed the refusal. */ + choiceName: string | null = null; + + /** `reason` is written as it reads after the choice's name, e.g. "the Daily notes core plugin is off, ...". */ + constructor(readonly reason: string) { + super(reason.charAt(0).toUpperCase() + reason.slice(1)); + } +} + +/** + * Builds a refusal: `, so . ` + * `missing` starts as it reads mid-sentence; the choice's name goes before it + * when a run reports it. + */ +export function refuse(missing: string, consequence: string, action?: string): RefusalError { + return new RefusalError(`${missing}, so ${consequence}.${action ? ` ${action}` : ""}`); +} + +/** + * Names the choice that refused in the sentence and returns it. The first claim + * wins, so a refusal inside a sequence names the step's choice, not the sequence. + */ +export function claimRefusal(error: RefusalError, choiceName: string): string { + if (error.choiceName === null) { + error.choiceName = choiceName; + error.message = `${choiceName}: ${error.reason}`; + } + return error.message; +} diff --git a/src/formatters/captureChoiceFormatter.ts b/src/formatters/captureChoiceFormatter.ts index c613fb6e9..680f10846 100644 --- a/src/formatters/captureChoiceFormatter.ts +++ b/src/formatters/captureChoiceFormatter.ts @@ -54,8 +54,6 @@ export class CaptureChoiceFormatter extends CompleteFormatter { private templaterProcessed = false; /** A picked heading is a verbatim file line: skip token and escape expansion when matching it. */ private insertAfterTargetOverride: string | null = null; - /** Resolved insert-after heading for the success notice; null until the token-driven block path resolves. */ - private lastResolvedInsertAfterHeading: string | null = null; /** Expand format-template escapes once, before substitution, so captured backslashes remain literal. */ private linebreaksProcessed = false; @@ -95,11 +93,6 @@ export class CaptureChoiceFormatter extends CompleteFormatter { this.insertAfterTargetOverride = target; } - /** Resolved heading for this run, including leading # characters; null when no heading was resolved. */ - public getResolvedInsertAfterHeading(): string | null { - return this.lastResolvedInsertAfterHeading; - } - public consumeCreatedClipboardAttachmentPaths(): string[] { const paths = this.createdClipboardAttachmentPaths; this.createdClipboardAttachmentPaths = []; @@ -429,16 +422,6 @@ export class CaptureChoiceFormatter extends CompleteFormatter { await this.expandFormatTemplateEscapes(this.choice.insertAfter.after), )); - // Record the resolved heading for the success notice (ordered captures show - // '## 2026-06-16' instead of the raw token). Token-driven path only; the - // promptHeading override sets its own notice text in the engine. - if (override === null) { - const firstLine = targetString.split("\n", 1)[0]; - this.lastResolvedInsertAfterHeading = /^#+\s+\S/.test(firstLine) - ? firstLine.trim() - : null; - } - const targetLines = positioning.toTargetLines(targetString); if (positioning.isBlankTarget(targetLines)) { throw new ChoiceAbortError( diff --git a/src/formatters/completeFormatter.inputOverride.test.ts b/src/formatters/completeFormatter.inputOverride.test.ts new file mode 100644 index 000000000..ee5ac46f0 --- /dev/null +++ b/src/formatters/completeFormatter.inputOverride.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + prompt: vi.fn(async (..._args: unknown[]) => "answer"), + datePrompt: vi.fn(async (..._args: unknown[]) => ""), +})); + +vi.mock("obsidian", async () => (await import("../../tests/helpers/formatters/mocks")).obsidianMock()); +vi.mock("../gui/InputPrompt", () => ({ + default: class { + factory() { + return { Prompt: mocks.prompt }; + } + }, +})); +vi.mock("../gui/GenericSuggester/genericSuggester", () => ({ default: {} })); +vi.mock("../gui/GenericInputPrompt/GenericInputPrompt", () => ({ default: { Prompt: mocks.prompt } })); +vi.mock("../gui/InputSuggester/inputSuggester", () => ({ default: {} })); +vi.mock("../gui/MultiSuggester/multiSuggester", () => ({ default: {} })); +vi.mock("../gui/VDateInputPrompt/VDateInputPrompt", () => ({ default: { Prompt: mocks.datePrompt } })); +vi.mock("../gui/MathModal", () => ({ MathModal: {} })); +vi.mock("../parsers/NLDParser", () => ({ NLDParser: { getNattyParser: () => ({}) } })); +vi.mock("../logger/logManager", () => ({ + log: { logMessage: vi.fn(), logWarning: vi.fn(), logError: vi.fn() }, +})); + +import { CompleteFormatter } from "./completeFormatter"; +import { settingsStore } from "../settingsStore"; +import { createChoiceExecutor } from "../../tests/helpers/createChoiceExecutor"; +import type IChoice from "../types/choices/IChoice"; +import type { Action, InputOverride } from "../v3/model"; + +const app = { + workspace: { getActiveFile: () => null, getActiveViewOfType: () => null }, + fileManager: { generateMarkdownLink: () => "" }, +} as never; +const plugin = { settings: { globalVariables: {}, inputPrompt: "single-line" } } as never; + +function withOverrides(inputs: Record) { + const action: Action = { kind: "action", id: "log", name: "Log", steps: [], show: { command: false }, inputs }; + settingsStore.setState({ actions: [action] }); +} + +function formatterFor(choiceId: string, executor = createChoiceExecutor()) { + const formatter = new CompleteFormatter(app, plugin, executor); + formatter.choiceChain = [{ id: choiceId, name: "Log", type: "Capture" } as IChoice]; + return formatter; +} + +const promptCall = (calls: unknown[][]) => { + const args = calls.at(-1); + if (!args) throw new Error("no prompt was opened"); + return { title: args[1], options: args.at(-1) as { optional?: boolean } }; +}; + +beforeEach(() => { + mocks.prompt.mockClear(); + mocks.datePrompt.mockClear(); + settingsStore.setState({ actions: [] }); +}); + +describe("an action's input override at prompt time", () => { + it("titles a named value's prompt with the override's label and lets it be left empty", async () => { + withOverrides({ Title: { label: "What happened?", optional: true } }); + + await formatterFor("log").formatFileContent("- {{VALUE:Title}}"); + + expect(promptCall(mocks.prompt.mock.calls)).toMatchObject({ title: "What happened?", options: { optional: true } }); + }); + + it("applies to the {{VALUE}} prompt and a date prompt too", async () => { + withOverrides({ value: { label: "Entry" }, Due: { label: "When is it due?", optional: true } }); + const formatter = formatterFor("log"); + + expect(await formatter.formatFileContent("{{VALUE}} due {{VDATE:Due,YYYY-MM-DD}}")).toBe("answer due "); + + expect(promptCall(mocks.prompt.mock.calls).title).toBe("Entry"); + expect(promptCall(mocks.datePrompt.mock.calls).title).toBe("When is it due?"); + }); + + it("applies to the write of a sequence, which runs as the action's nested choice", async () => { + withOverrides({ Title: { label: "What happened?" } }); + + await formatterFor("log:choice").formatFileContent("- {{VALUE:Title}}"); + + expect(promptCall(mocks.prompt.mock.calls).title).toBe("What happened?"); + }); + + it("leaves another choice's prompts as their placeholders say", async () => { + withOverrides({ Title: { label: "What happened?" } }); + + await formatterFor("other").formatFileContent("- {{VALUE:Title}}"); + + expect(promptCall(mocks.prompt.mock.calls).title).toBe("Title"); + }); + + it("leaves a run that was given the value alone", async () => { + withOverrides({ Title: { label: "What happened?", default: "Nothing" } }); + const executor = { ...createChoiceExecutor(), interactive: false }; + executor.variables.set("Title", "Shipped it"); + + expect(await formatterFor("log", executor).formatFileContent("- {{VALUE:Title}}")).toBe("- Shipped it"); + expect(mocks.prompt).not.toHaveBeenCalled(); + }); +}); diff --git a/src/formatters/completeFormatter.promptContext.test.ts b/src/formatters/completeFormatter.promptContext.test.ts index 281a9a7ab..83e7e347d 100644 --- a/src/formatters/completeFormatter.promptContext.test.ts +++ b/src/formatters/completeFormatter.promptContext.test.ts @@ -112,6 +112,14 @@ describe("anonymous {{VALUE}} prompt copy", () => { }); }); + it("takes the note title the one-page form answered without asking again", async () => { + const executor = { variables: new Map([["value", "Friday"]]) } as never; + const f = new CompleteFormatter(app, plugin, executor); + + expect(await f.formatFileName("{{VALUE}}", "noteTitle")).toBe("Friday"); + expect(mocks.prompt).not.toHaveBeenCalled(); + }); + it("keeps the choice name when the answer is only part of the file name", async () => { const f = makeFormatter(); f.setPromptRunContext({ choiceName: "Daily note" }); diff --git a/src/formatters/completeFormatter.template-cursor.test.ts b/src/formatters/completeFormatter.template-cursor.test.ts index 43eb6be1e..169d9e38e 100644 --- a/src/formatters/completeFormatter.template-cursor.test.ts +++ b/src/formatters/completeFormatter.template-cursor.test.ts @@ -85,7 +85,7 @@ describe("Template cursor formatting scope", () => { prompt.mockImplementationOnce(async () => { if (throws) { await expect(formatter.formatFileContent("{{TEMPLATE:Missing.md}}")) - .rejects.toThrow("Template file not found"); + .rejects.toThrow("The template Missing.md does not exist"); return "recovered"; } return await formatter.formatFileContent("nested{{CURSOR}}text"); @@ -98,7 +98,7 @@ describe("Template cursor formatting scope", () => { it("does not preserve markers in a later render after a Template render fails", async () => { const { formatter, formatTemplate } = makeHarness(); await expect(formatTemplate("{{CURSOR}}{{TEMPLATE:Missing.md}}")) - .rejects.toThrow("Template file not found"); + .rejects.toThrow("The template Missing.md does not exist"); expect(await formatter.withPromptScope("noteBody", "later{{CURSOR}}text", () => formatter.formatFileContent("later{{CURSOR}}text"), )).toBe("latertext"); diff --git a/src/formatters/completeFormatter.test.ts b/src/formatters/completeFormatter.test.ts index 99550450a..278db30dc 100644 --- a/src/formatters/completeFormatter.test.ts +++ b/src/formatters/completeFormatter.test.ts @@ -357,6 +357,13 @@ describe("CompleteFormatter - macro / template / inline-script integration", () expect(mocks.macroRunAndGetOutput).toHaveBeenCalled(); }); + it.each(["{{ACTION:doThing}}", "{{action:doThing}}"])("replaces %s like {{MACRO:doThing}}", async (input) => { + mocks.macroRunAndGetOutput.mockResolvedValue("ACTION_OUT"); + const f = defaultFormatter(); + await expect(f.formatFolderPath(`a ${input} b`)).resolves.toBe("a ACTION_OUT b"); + expect(mocks.macroRunAndGetOutput).toHaveBeenCalledWith("doThing", undefined, expect.anything()); + }); + it("inserts a macro's output as text instead of expanding macro tokens in it", async () => { mocks.macroRunAndGetOutput.mockResolvedValue("{{MACRO:again}}"); const f = defaultFormatter(); @@ -772,7 +779,7 @@ describe("CompleteFormatter - getCurrentFileLink / getCurrentFileName", () => { it("throws (required behavior) when {{LINKCURRENT}} but no active file", async () => { const f = defaultFormatter({}, { activeFile: null }); await expect(f.formatFileContent("{{LINKCURRENT}}")).rejects.toThrow( - "Unable to get current file path", + "No note is open, so {{LINKCURRENT}} has nothing to link to.", ); }); @@ -787,7 +794,40 @@ describe("CompleteFormatter - getCurrentFileLink / getCurrentFileName", () => { const f = defaultFormatter({}, { activeFile: null }); await expect( f.formatFileContent("{{FILENAMECURRENT}}"), - ).rejects.toThrow("Unable to get current file name"); + ).rejects.toThrow("No note is open, so {{FILENAMECURRENT}} has no name to give."); + }); +}); + +describe("CompleteFormatter - {{NOTE}}, the run note", () => { + function withRunNote(runNote: unknown) { + const app = makeApp({ activeFile: null, selection: null, generatedLink: "" }); + app.fileManager.generateMarkdownLink = ((file: { basename: string }) => + `[[${file.basename}]]`) as never; + const executor = { ...createChoiceExecutor(), runNote: runNote as never }; + return new CompleteFormatter(app as any, makePlugin() as any, executor); + } + const note = { path: "notes/Run note.md", basename: "Run note", parent: { path: "notes" } }; + + it("resolves every form from the run note", async () => { + const f = withRunNote(note); + await expect( + f.formatFileContent("{{NOTE}} | {{note|LINK}} | {{Note|name}} | {{NOTE|folder}}"), + ).resolves.toBe("notes/Run note.md | [[Run note]] | Run note | notes"); + await expect(f.formatFileName("{{NOTE}}", "filePath")).resolves.toBe("notes/Run note.md"); + await expect(f.formatFolderPath("{{NOTE|folder}}/sub")).resolves.toBe("notes/sub"); + }); + + it("gives the vault root as an empty folder", async () => { + const f = withRunNote({ path: "Top.md", basename: "Top", parent: { path: "/" } }); + await expect(f.formatFileContent("[{{NOTE|folder}}]")).resolves.toBe("[]"); + }); + + it("resolves every form to nothing before the run writes a note", async () => { + for (const f of [withRunNote(null), defaultFormatter()]) { + await expect( + f.formatFileContent("a{{NOTE}}b{{NOTE|link}}c{{NOTE|name}}d{{NOTE|folder}}e"), + ).resolves.toBe("abcde"); + } }); }); @@ -897,7 +937,7 @@ describe("CompleteFormatter - {{FOLDERCURRENT}} (issue #1480)", () => { f.setLinkToCurrentFileBehavior("optional"); await expect( f.formatFileName("{{FOLDERCURRENT}}/Tasks.md"), - ).rejects.toThrow("Unable to get the active file's folder"); + ).rejects.toThrow("No note is open, so {{FOLDERCURRENT}} has no folder to give."); }); it("formatFolderPath strips the leading slash a root-level active file produces", async () => { @@ -915,7 +955,7 @@ describe("CompleteFormatter - {{FOLDERCURRENT}} (issue #1480)", () => { it("formatFolderPath throws without an active file", async () => { const f = defaultFormatter({}, { activeFile: null }); await expect(f.formatFolderPath("{{FOLDERCURRENT}}")).rejects.toThrow( - "Unable to get the active file's folder", + "No note is open, so {{FOLDERCURRENT}} has no folder to give.", ); }); @@ -2136,7 +2176,7 @@ describe("CompleteFormatter {{linksection}} runtime resolution", () => { const app = makeSectionApp({ activeFile: null, view: undefined }); const f = new CompleteFormatter(app as any, makePlugin() as any); await expect(f.formatFileContent("{{linksection}}")).rejects.toThrow( - "Unable to get current file path", + "No note is open, so {{LINKSECTION}} has nothing to link to.", ); }); diff --git a/src/formatters/completeFormatter.ts b/src/formatters/completeFormatter.ts index a8887fb35..dc37af798 100644 --- a/src/formatters/completeFormatter.ts +++ b/src/formatters/completeFormatter.ts @@ -8,6 +8,8 @@ import type { App, TFile } from "obsidian"; import { MarkdownView } from "obsidian"; import type { IChoiceExecutor } from "../IChoiceExecutor"; import type { ChoiceChain } from "../engine/choiceChain"; +import { actionInputOverride } from "../v3/inputOverride"; +import type { InputOverride } from "../v3/model"; import type { RunClocks } from "../types/dateOrigin"; import { DATE_VARIABLE_REGEX, TITLE_REGEX } from "../constants"; import { findDateVariableFormat } from "./helpers/dateTokens"; @@ -24,6 +26,7 @@ import { normalizeNumericValue } from "../utils/valueSyntax"; import { collectFieldValuesRaw, generateFieldCacheKey } from "../utils/FieldValueCollector"; import { getActiveMarkdownEditorView } from "../utils/activeMarkdownEditor"; import { Formatter, type PromptContext } from "./formatter"; +import type { RunNoteForms } from "./helpers/currentFileTokens"; import { buildPromptContextLine, describeValuePrompt, @@ -401,6 +404,18 @@ export class CompleteFormatter extends Formatter { return parentPath === "/" ? "" : parentPath; } + protected getRunNote(): RunNoteForms | null { + const note = this.choiceExecutor?.runNote; + if (!note) return null; + const folder = note.parent?.path ?? ""; + return { + path: note.path, + link: this.app.fileManager.generateMarkdownLink(note, ""), + name: note.basename, + folder: folder === "/" ? "" : folder, + }; + } + /** Resolve the cursor heading link only when present, honoring required/optional behavior. */ protected getCurrentFileLinkToSection(): string | null { const currentFile = this.app.workspace.getActiveFile(); @@ -695,6 +710,10 @@ export class CompleteFormatter extends Formatter { }; } + protected inputOverride(name: string): InputOverride | undefined { + return actionInputOverride(this.choiceChain.at(-1)?.id, name); + } + protected async promptForVariable(header?: string, context?: PromptContext): Promise { return promptForVariable(this.promptRuntime(), header, context); diff --git a/src/formatters/displayFormatters-standins.test.ts b/src/formatters/displayFormatters-standins.test.ts index 71c5139e4..32dd262d2 100644 --- a/src/formatters/displayFormatters-standins.test.ts +++ b/src/formatters/displayFormatters-standins.test.ts @@ -40,8 +40,11 @@ describe("preview stand-ins", () => { ["{{VALUE:due: x}}", "due: x_value", "user input"], ["{{MACRO:clipboard}}", "clipboard_content", "clipboard_content"], ["{{MACRO:a:b}}", "a:b_output", "macro_output"], + ["{{ACTION:clipboard}}", "clipboard_content", "clipboard_content"], + ["{{action:a:b}}", "a:b_output", "macro_output"], ["{{FIELD:status}}", "status_field_value", "status_field_value"], ["{{FIELD:a:b}}", "a:b_field_value", "field_value"], + ["{{NOTE}} {{NOTE|link}} {{NOTE|name}} {{NOTE|folder}}", "note note note note_folder", "note note note note_folder"], ])("%s previews as %s in the body and %s in the file name", async (input, body, fileName) => { await expect(preview(input)).resolves.toEqual({ body, fileName }); }); diff --git a/src/formatters/formatter-filenamecurrent.test.ts b/src/formatters/formatter-filenamecurrent.test.ts index 403608ecf..f3780a134 100644 --- a/src/formatters/formatter-filenamecurrent.test.ts +++ b/src/formatters/formatter-filenamecurrent.test.ts @@ -30,7 +30,7 @@ describe("Formatter filename of current file behavior", () => { const formatter = new StubFormatter(); formatter.setFilename(null); await expect(formatter.process("{{FILENAMECURRENT}}")) - .rejects.toThrow("Unable to get current file name"); + .rejects.toThrow("No note is open, so {{FILENAMECURRENT}} has no name to give."); }); it("silently strips placeholder when optional and no active file", async () => { diff --git a/src/formatters/formatter-foldercurrent.test.ts b/src/formatters/formatter-foldercurrent.test.ts index 727b1fed8..6e7d83377 100644 --- a/src/formatters/formatter-foldercurrent.test.ts +++ b/src/formatters/formatter-foldercurrent.test.ts @@ -135,8 +135,7 @@ describe("Formatter {{FOLDERCURRENT}} token", () => { }); describe("missing active file", () => { - const error = - "Unable to get the active file's folder. Make sure you have a file open in the editor."; + const error = "No note is open, so {{FOLDERCURRENT}} has no folder to give."; it("throws in path mode with required behavior", () => { const formatter = makeFormatter(null); diff --git a/src/formatters/formatter-linkcurrent.test.ts b/src/formatters/formatter-linkcurrent.test.ts index ea0a7c2fc..f21eda504 100644 --- a/src/formatters/formatter-linkcurrent.test.ts +++ b/src/formatters/formatter-linkcurrent.test.ts @@ -30,7 +30,7 @@ describe("Formatter link to current file behavior", () => { const formatter = new StubFormatter(); formatter.setLink(null); await expect(formatter.process("{{LINKCURRENT}}")) - .rejects.toThrow("Unable to get current file path"); + .rejects.toThrow("No note is open, so {{LINKCURRENT}} has nothing to link to."); }); it("silently strips placeholder when optional and no active file", async () => { diff --git a/src/formatters/formatter-linksection.test.ts b/src/formatters/formatter-linksection.test.ts index 39b37f2b7..971050455 100644 --- a/src/formatters/formatter-linksection.test.ts +++ b/src/formatters/formatter-linksection.test.ts @@ -32,7 +32,7 @@ describe("Formatter {{linksection}} behavior", () => { const formatter = new StubFormatter(); formatter.setLink(null); expect(() => formatter.process("{{LINKSECTION}}")).toThrow( - "Unable to get current file path", + "No note is open, so {{LINKSECTION}} has nothing to link to.", ); }); diff --git a/src/formatters/formatter-token-named-file.test.ts b/src/formatters/formatter-token-named-file.test.ts index 3f390ac72..3ceb3f7c2 100644 --- a/src/formatters/formatter-token-named-file.test.ts +++ b/src/formatters/formatter-token-named-file.test.ts @@ -179,7 +179,7 @@ describe("#1358 note-derived token rescan", () => { f.setLink(null); f.setFilename(null); expect(() => f.combined("{{FILENAMECURRENT}} {{LINKCURRENT}}", allTokens)).toThrow( - "Unable to get current file path", + "No note is open, so {{LINKCURRENT}} has nothing to link to.", ); }); @@ -188,7 +188,7 @@ describe("#1358 note-derived token rescan", () => { f.setBehavior("required"); f.setFilename(null); expect(() => f.combined("{{FILENAMECURRENT}}", allTokens)).toThrow( - "Unable to get current file name", + "No note is open, so {{FILENAMECURRENT}} has no name to give.", ); }); @@ -200,7 +200,7 @@ describe("#1358 note-derived token rescan", () => { // Filename token appears first, but the link message must still win. expect(() => f.combined("{{FILENAMECURRENT}} then {{LINKCURRENT}}", allTokens), - ).toThrow("Unable to get current file path"); + ).toThrow("No note is open, so {{LINKCURRENT}} has nothing to link to."); }); it("optional + no active file strips active link/filename tokens", () => { diff --git a/src/formatters/formatter.ts b/src/formatters/formatter.ts index 8964b30fd..7b7ca068b 100644 --- a/src/formatters/formatter.ts +++ b/src/formatters/formatter.ts @@ -2,7 +2,7 @@ import { ValueFormatter } from "./valueFormatter"; import { replaceDateInString, replaceTimeInString, replaceDateVariableInString, defaultDateVariableFormat, renderStoredDateVariable, getDateVariableFormat } from "./helpers/dateTokens"; export { findDateVariableFormat } from "./helpers/dateTokens"; import { findInlineScriptSpans } from "./helpers/inlineScriptSpans"; -import { replaceCurrentFileTokens, type CurrentFileTokenOptions } from "./helpers/currentFileTokens"; +import { refuseLink, replaceCurrentFileTokens, type CurrentFileTokenOptions, type RunNoteForms } from "./helpers/currentFileTokens"; import { TFile } from "obsidian"; import { LINK_TO_CURRENT_FILE_REGEX, LINK_TO_CURRENT_SECTION_REGEX, FILE_REGEX, MACRO_REGEX, MATH_VALUE_REGEX, TEMPLATE_REGEX, FIELD_VAR_REGEX_WITH_FILTERS, FIELD_VARIABLE_PREFIX, SELECTED_REGEX, CLIPBOARD_REGEX, RANDOM_REGEX, PROPERTY_REGEX } from "../constants"; import { @@ -21,6 +21,7 @@ import { FieldSuggestionParser } from "../utils/FieldSuggestionParser"; import { parseMacroToken } from "../utils/macroSyntax"; import { stringifyPropertyTokenValue } from "../engine/captureProperty"; import type { CompleteFormatter } from "./completeFormatter"; +import { withInputOverride } from "../v3/inputOverride"; export type LinkToCurrentFileBehavior = "required" | "optional"; export { type PromptContext } from "./valueFormatter"; @@ -128,9 +129,7 @@ export abstract class Formatter extends ValueFormatter { const currentFilePathLink = this.getCurrentFileLink(); if (!currentFilePathLink) { - if (this.linkToCurrentFileBehavior === "required") { - throw new Error("Unable to get current file path. Make sure you have a file open in the editor."); - } + if (this.linkToCurrentFileBehavior === "required") throw refuseLink("LINKCURRENT"); log.logMessage("Skipping {{LINKCURRENT}} replacement because no active file is available."); } @@ -145,9 +144,7 @@ export abstract class Formatter extends ValueFormatter { const sectionLink = this.getCurrentFileLinkToSection(); if (!sectionLink) { - if (this.linkToCurrentFileBehavior === "required") { - throw new Error("Unable to get current file path. Make sure you have a file open in the editor."); - } + if (this.linkToCurrentFileBehavior === "required") throw refuseLink("LINKSECTION"); log.logMessage("Skipping {{LINKSECTION}} replacement because no active file is available."); } @@ -174,6 +171,7 @@ export abstract class Formatter extends ValueFormatter { FOLDER: () => this.targetFolderPath ?? "", FOLDERCURRENT: () => this.getCurrentFolderPath(), TITLE: () => this.getVariableValue("title"), + NOTE: () => this.getRunNote(), }, this.linkToCurrentFileBehavior); } @@ -227,6 +225,11 @@ export abstract class Formatter extends ValueFormatter { return null; } + /** `{{NOTE}}` and its forms: the note this run last created or wrote to. Null when there is none. */ + protected getRunNote(): RunNoteForms | null { + return null; + } + protected async replaceFieldVarInString(input: string) { const regex = new RegExp(FIELD_VAR_REGEX_WITH_FILTERS.source, "gi"); let output = ""; @@ -326,7 +329,7 @@ export abstract class Formatter extends ValueFormatter { const key = parsed.variableKey; if (!this.hasConcreteVariable(key)) { - this.variables.set(key, await this.suggestForFile(parsed)); + this.variables.set(key, await this.suggestForFile(withInputOverride(parsed, this.inputOverride(key)))); } const renderedValue = renderStoredFileValue( @@ -477,6 +480,7 @@ export abstract class Formatter extends ValueFormatter { protected async replaceDateVariableInString(input: string): Promise { return replaceDateVariableInString(input, { variables: this.variables, dateParser: this.dateParser, prompt: (name, options) => this.promptForVariable(name, options), + override: (name) => this.inputOverride(name), applyCase: (value, style, token) => this.applyCaseOption(value, style, token), }); } diff --git a/src/formatters/helpers/currentFileTokens.ts b/src/formatters/helpers/currentFileTokens.ts index 3b0b080d7..1fed470c1 100644 --- a/src/formatters/helpers/currentFileTokens.ts +++ b/src/formatters/helpers/currentFileTokens.ts @@ -1,5 +1,15 @@ +import { refuse, type RefusalError } from "../../errors/RefusalError"; import { log } from "../../logger/logManager"; +/** A link to the open note, asked for with no note open. */ +export function refuseLink(token: "LINKCURRENT" | "LINKSECTION"): RefusalError { + return refuse("no note is open", `{{${token}}} has nothing to link to`); +} + +function refuseCurrent(token: "FILENAMECURRENT" | "FOLDERCURRENT"): RefusalError { + return refuse("no note is open", `{{${token}}} has no ${token === "FILENAMECURRENT" ? "name" : "folder"} to give`); +} + export interface CurrentFileTokenOptions { links?: boolean; fileName?: boolean; @@ -9,7 +19,19 @@ export interface CurrentFileTokenOptions { } type CurrentToken = "LINKCURRENT" | "LINKSECTION" | "FILENAMECURRENT" | "FOLDER" | "FOLDERCURRENT" | "TITLE"; -type Resolvers = Record string | null>; + +/** The forms of `{{NOTE}}`, the run note: `{{NOTE}}`, `{{NOTE|link}}`, `{{NOTE|name}}`, `{{NOTE|folder}}`. */ +export interface RunNoteForms { + path: string; + link: string; + name: string; + folder: string; +} + +type Resolvers = Record string | null> & { + /** Null when the run has written no note yet; every form is then empty. */ + NOTE: () => RunNoteForms | null; +}; /** Resolve once per token and never scan replacement text, which may itself contain tokens. */ export function replaceCurrentFileTokens( @@ -19,6 +41,7 @@ export function replaceCurrentFileTokens( behavior: "required" | "optional", ): string { const values = new Map(); + let runNote: RunNoteForms | null | undefined; const missing = new Set(); const enabled: Record = { LINKCURRENT: opts.links, @@ -29,8 +52,20 @@ export function replaceCurrentFileTokens( TITLE: opts.title, }; const output = input.replace( - /{{(?:(LINKCURRENT|LINKSECTION|FILENAMECURRENT|TITLE)|(FOLDERCURRENT|FOLDER)(\|name)?)}}/gi, - (match: string, simple: string | undefined, folder: string | undefined, leaf: string | undefined) => { + /{{(?:(LINKCURRENT|LINKSECTION|FILENAMECURRENT|TITLE)|(FOLDERCURRENT|FOLDER)(\|name)?|(NOTE)(?:\|(link|name|folder))?)}}/gi, + ( + match: string, + simple: string | undefined, + folder: string | undefined, + leaf: string | undefined, + note: string | undefined, + noteForm: string | undefined, + ) => { + if (note) { + if (runNote === undefined) runNote = resolve.NOTE(); + const form = (noteForm?.toLowerCase() ?? "path") as keyof RunNoteForms; + return runNote?.[form] ?? ""; + } const name = (simple ?? folder ?? "").toUpperCase(); if (!(name in resolve)) return match; // The regex and resolver table enumerate the same closed token domain. @@ -45,19 +80,13 @@ export function replaceCurrentFileTokens( return leaf && value !== null ? value.slice(value.lastIndexOf("/") + 1) : value ?? ""; }, ); - const folderError = "Unable to get the active file's folder. Make sure you have a file open in the editor."; - if (missing.has("FOLDERCURRENT") && opts.activeFolder === "path") { - throw new Error(folderError); - } + // A folder path needs the folder whatever the behavior. + if (missing.has("FOLDERCURRENT") && opts.activeFolder === "path") throw refuseCurrent("FOLDERCURRENT"); if (missing.size > 0) { if (behavior === "required") { - throw new Error( - missing.has("LINKCURRENT") || missing.has("LINKSECTION") - ? "Unable to get current file path. Make sure you have a file open in the editor." - : missing.has("FILENAMECURRENT") - ? "Unable to get current file name. Make sure you have a file open in the editor." - : folderError, - ); + if (missing.has("LINKCURRENT")) throw refuseLink("LINKCURRENT"); + if (missing.has("LINKSECTION")) throw refuseLink("LINKSECTION"); + throw refuseCurrent(missing.has("FILENAMECURRENT") ? "FILENAMECURRENT" : "FOLDERCURRENT"); } log.logMessage("Skipping current-file token replacement because no active file is available."); } diff --git a/src/formatters/helpers/dateTokens.ts b/src/formatters/helpers/dateTokens.ts index 4aa0f6c98..8e3b06350 100644 --- a/src/formatters/helpers/dateTokens.ts +++ b/src/formatters/helpers/dateTokens.ts @@ -8,6 +8,8 @@ import { normalizeDateInput } from "../../utils/dateAliases"; import { applyDateSnap, type DateSnap, parseDateSnapSegment } from "../../utils/dateModifiers"; import { parseVDateOptions } from "../../utils/vdateSyntax"; import { formatUnknownValue } from "../../utils/conditionalHelpers"; +import { withInputOverride } from "../../v3/inputOverride"; +import type { InputOverride } from "../../v3/model"; type ApplyCase = (value: string, style: string | undefined, token: string) => string; interface DateTokenContext { @@ -19,6 +21,8 @@ interface DateVariableContext { dateParser: IDateParser | undefined; prompt: (name: string, context: PromptContext) => Promise; applyCase: ApplyCase; + /** What the builder changed about the date input of this name. */ + override?: (name: string) => InputOverride | undefined; } function replaceLiteral(input: string, pattern: RegExp, value: string): string { return input.replace(pattern, () => value); @@ -234,7 +238,7 @@ export async function replaceDateVariableInString(input: string, context: DateVa } const { defaultValue, optional, withTime, snap, caseStyle, label } = - parseVDateOptions(match[3]); + withInputOverride(parseVDateOptions(match[3]), context.override?.(variableName)); // A |time/|datetime token with no explicit format gets a datetime // default so the rendered value carries the picked time. const dateFormat = diff --git a/src/formatters/previewFormatter.ts b/src/formatters/previewFormatter.ts index 11a16d6b3..8ae7df53a 100644 --- a/src/formatters/previewFormatter.ts +++ b/src/formatters/previewFormatter.ts @@ -1,5 +1,6 @@ import { findDateVariableFormat, Formatter, type PromptContext } from "./formatter"; import { PreviewDiagnostics } from "./previewDiagnostics"; +import type { RunNoteForms } from "./helpers/currentFileTokens"; import { DateFormatPreviewGenerator, fieldValuePreview, getCurrentFileLinkPreview, getCurrentFileLinkToSectionPreview, getCurrentFileNamePreview, getCurrentFolderPathPreview, getMacroPreview, getVariableExample, getVariablePromptExample } from "./helpers/previewHelpers"; import { defaultDateVariableFormat, rememberDateVariableFormat, renderStoredDateVariable } from "./helpers/dateTokens"; import { snappedExampleDate } from "./helpers/snappedExampleDate"; @@ -84,6 +85,10 @@ export abstract class PreviewFormatter extends Formatter { return getCurrentFileNamePreview(this.app.workspace.getActiveFile()); } + protected getRunNote(): RunNoteForms { + return { path: "note", link: "note", name: "note", folder: "note_folder" }; + } + protected getCurrentFolderPath(): string | null { if (!this.app) return "current_folder"; return getCurrentFolderPathPreview(this.app.workspace.getActiveFile()); diff --git a/src/formatters/promptScope.test.ts b/src/formatters/promptScope.test.ts index a6fa0bcca..f76d7939f 100644 --- a/src/formatters/promptScope.test.ts +++ b/src/formatters/promptScope.test.ts @@ -209,4 +209,12 @@ describe("buildPromptContextLine tooltip form", () => { "Note to self → Work/Clients/Acme/Meetings/2026/Weekly standup notes.md", ); }); + + it("keeps the capture text's title when other tokens share the line", () => { + expect(describeValuePrompt("captureText", false)).toEqual({ + title: "Text to capture", + placeholder: "Part of the text added to the note", + }); + expect(describeValuePrompt("noteTitle", false)).toEqual({ placeholder: "Part of the new note's title" }); + }); }); diff --git a/src/formatters/promptScope.ts b/src/formatters/promptScope.ts index c19345c68..c8728207a 100644 --- a/src/formatters/promptScope.ts +++ b/src/formatters/promptScope.ts @@ -44,6 +44,16 @@ export interface PromptRunContext { draftScopeId?: string; destination?: string; destinationKind?: "file" | "folder"; + /** The heading a capture writes under in `destination`. */ + heading?: string; +} + +/** + * Where a capture adds its text: `target`, and the heading it writes under. + * Said the same way by the one-page form and the sequential prompt. + */ +export function describeCaptureTarget(target: string, heading: string | undefined): string { + return heading ? `${target} under ${heading}` : target; } interface ScopeCopy { @@ -227,8 +237,12 @@ export function describeValuePrompt( ): ValuePromptCopy { if (scope === "generic") return {}; const copy = SCOPE_COPY[scope]; - return soleValue - ? { title: copy.ask, placeholder: copy.hint } + if (soleValue) return { title: copy.ask, placeholder: copy.hint }; + // A capture format's other tokens are filled in, not typed: the answer is + // still the text to capture, so the title holds; the placeholder says it is + // part of the line. + return FORMATTING_LITERAL_SCOPES.has(scope) + ? { title: copy.ask, placeholder: copy.partOf } : { placeholder: copy.partOf }; } @@ -286,7 +300,7 @@ export function buildPromptContextLine( const shown = options?.elide === false ? destination : elideMiddlePath(destination); parts.push( - `→ ${context.destinationKind === "folder" ? `${shown}/` : shown}`, + `→ ${context.destinationKind === "folder" ? `${shown}/` : describeCaptureTarget(shown, context.heading)}`, ); } diff --git a/src/formatters/valueFormatter.ts b/src/formatters/valueFormatter.ts index 3a209e90e..786021915 100644 --- a/src/formatters/valueFormatter.ts +++ b/src/formatters/valueFormatter.ts @@ -37,6 +37,8 @@ import { } from "../utils/valueSyntax"; import { SILENT_WARN, type WarnSink } from "../utils/warnSink"; import { formatUnknownValue } from "../utils/conditionalHelpers"; +import { withInputOverride } from "../v3/inputOverride"; +import type { InputOverride } from "../v3/model"; export interface PromptContext { type?: string; @@ -238,6 +240,11 @@ export abstract class ValueFormatter { protected abstract promptForValue(header?: string): Promise | string; + /** What the builder changed about the input of this name for the choice being run. */ + protected inputOverride(_name: string): InputOverride | undefined { + return undefined; + } + /** Settles the {{VALUE}} answer for `input`'s tokens, asking at most once per run. */ protected async resolveValue(input: string): Promise { this.valuePromptContext = this.getValuePromptContext(input); @@ -283,9 +290,10 @@ export abstract class ValueFormatter { )); } const rawOptions = inner.slice(optionsIndex); - const parsed = parseAnonymousValueOptions(rawOptions, { - warn: this.warnSink, - }); + const parsed = withInputOverride( + parseAnonymousValueOptions(rawOptions, { warn: this.warnSink }), + this.inputOverride("value"), + ); // An empty submission takes the default unless |optional explicitly permits empty. const effectiveValue = this.value === "" && parsed.defaultValue && !parsed.optional @@ -388,7 +396,8 @@ export abstract class ValueFormatter { } } - return context; + const override = this.inputOverride("value"); + return override ? withInputOverride(context ?? {}, override) : context; } /** @@ -502,8 +511,9 @@ export abstract class ValueFormatter { * the prompt/suggest/default/store logic has a single source of truth. */ private async ensureValueVariableResolved( - parsed: ParsedValueToken, + token: ParsedValueToken, ): Promise { + const parsed = withInputOverride(token, this.inputOverride(token.variableKey)); const { variableName, variableKey, diff --git a/src/global.d.ts b/src/global.d.ts index 38d33e458..476c4ce93 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -10,6 +10,7 @@ declare module "obsidian" { }; enablePlugin: (id: string) => Promise; disablePlugin: (id: string) => Promise; + disablePluginAndSave: (id: string) => Promise; }; internalPlugins: { plugins: { diff --git a/src/gui/ChoiceBuilder/CaptureChoiceForm.svelte b/src/gui/ChoiceBuilder/CaptureChoiceForm.svelte index 8a37d3f58..867d05613 100644 --- a/src/gui/ChoiceBuilder/CaptureChoiceForm.svelte +++ b/src/gui/ChoiceBuilder/CaptureChoiceForm.svelte @@ -19,9 +19,15 @@ import FileOpeningSetting from "./components/FileOpeningSetting.svelte"; import OnePageOverrideSetting from "./components/OnePageOverrideSetting.svelte"; import DateOriginSetting from "./components/DateOriginSetting.svelte"; import CommandPaletteSetting from "./components/CommandPaletteSetting.svelte"; +import RibbonSetting from "./components/RibbonSetting.svelte"; +import StepsSection from "./components/StepsSection.svelte"; +import InputsSection from "./components/InputsSection.svelte"; +import type { Step } from "../../v3/model"; import CaptureTargetSetting from "./components/CaptureTargetSetting.svelte"; import WritePositionSetting from "./components/WritePositionSetting.svelte"; import ChoiceIconSetting from "./components/ChoiceIconSetting.svelte"; +import ChoiceSummary from "./components/ChoiceSummary.svelte"; +import MoreSettings from "./components/MoreSettings.svelte"; /** * Reactive replacement for CaptureChoiceBuilder.display(). Every conditional row @@ -33,10 +39,12 @@ let { choice = $bindable(), app, plugin, + onAddStep = undefined, }: { choice: ICaptureChoice; app: App; plugin: QuickAdd; + onAddStep?: (step: Step) => void; } = $props(); const templateFilePaths = $derived( @@ -99,85 +107,25 @@ function onTemplaterAfterCaptureChange(value: boolean) { } - - + - {#if !choice.captureToActiveFile} - - {#snippet control()} - - {/snippet} - - - {#if choice.createFileIfItDoesntExist.enabled} - - {#snippet control()} - - {/snippet} - {#snippet children(id)} - - (choice.createFileIfItDoesntExist.template = value.trim())} - /> - {/snippet} - - {/if} - {/if} - + + - - - - - - - {#snippet control()} - (choice.copyLinkToClipboard = value)} - /> - {/snippet} - - - - - {#if !choice.propertyCapture} - - {#snippet control()} - - {/snippet} - - - {#snippet control()} - (choice.eachLine = value)} - /> - {/snippet} - - {/if} + {#snippet control()} + {#if !choice.propertyCapture} + + + + + {/if} + {/snippet} {#snippet children(id)} {#key formatSuggestContext} - + + + + + {#if !choice.captureToActiveFile} - - {#if choice.openFile} - - {/if} + + + {#snippet control()} + + {/snippet} + + + {#if choice.createFileIfItDoesntExist.enabled} + + {#snippet control()} + + {/snippet} + {#snippet children(id)} + + (choice.createFileIfItDoesntExist.template = value.trim())} + /> + {/snippet} + + {/if} + {/if} - - {#snippet control()} - - {/snippet} - - - - {#if !choice.propertyCapture && choice.templater?.afterCapture === "wholeFile"} - - {#snippet control()} - - {/snippet} - + + + + {#snippet control()} + (choice.copyLinkToClipboard = value)} + /> + {/snippet} + + + + {#if !choice.propertyCapture} + + + {#snippet control()} + (choice.eachLine = value)} + /> + {/snippet} + + {/if} - + + {#if !choice.captureToActiveFile} + + {#if choice.openFile} + + {/if} + {/if} - + + {#snippet control()} + + {/snippet} + - + + {#if !choice.propertyCapture && choice.templater?.afterCapture === "wholeFile"} + + {#snippet control()} + + {/snippet} + + {/if} - - + + + + + + + + + + + + + diff --git a/src/gui/ChoiceBuilder/CaptureChoiceForm.test.ts b/src/gui/ChoiceBuilder/CaptureChoiceForm.test.ts index 6a29d5b6a..aa0c0452e 100644 --- a/src/gui/ChoiceBuilder/CaptureChoiceForm.test.ts +++ b/src/gui/ChoiceBuilder/CaptureChoiceForm.test.ts @@ -1,7 +1,7 @@ -import { settingItem, settingNames, choiceIconInput } from "../../../tests/helpers/settings/fields"; -import { describe, expect, it } from "vitest"; +import { settingItem, settingNames, choiceIconInput, openMoreSettings } from "../../../tests/helpers/settings/fields"; +import { describe, expect, it, vi } from "vitest"; -import { App } from "obsidian"; +import { App, Menu } from "obsidian"; import { fireEvent, render } from "@testing-library/svelte"; import { flushSync, tick } from "svelte"; import type QuickAdd from "../../main"; @@ -81,6 +81,7 @@ function mountForm(choice: ICaptureChoice = captureChoice()) { const result = render(CaptureChoiceForm, { props: { choice: props.choice, app: props.app, plugin: props.plugin }, }); + openMoreSettings(result.container); return { ...result, props }; } @@ -111,12 +112,12 @@ describe("CaptureChoiceForm", () => { props.choice.insertAfter.enabled = true; props.choice.task = true; flushSync(); - expect(selectUnderSetting(container, "Write position").value).toBe("after"); - await fireEvent.change(selectUnderSetting(container, "Write position"), { target: { value: "property" } }); + expect(selectUnderSetting(container, "Position").value).toBe("after"); + await fireEvent.change(selectUnderSetting(container, "Position"), { target: { value: "property" } }); flushSync(); - expect(selectUnderSetting(container, "Write position").value).toBe("property"); + expect(selectUnderSetting(container, "Position").value).toBe("property"); expect(settingNames(container)).not.toContain("Insert after"); - expect(settingNames(container)).not.toContain("Task"); + expect(container.querySelector('[aria-label="Task"]')).toBeNull(); expect(settingNames(container)).toContain("Create property if missing"); await fireEvent.input(getByLabelText("Property"), { target: { value: "{{VALUE:property}}" } }); await fireEvent.change(selectUnderSetting(container, "Action"), { target: { value: "addToList" } }); @@ -134,10 +135,10 @@ describe("CaptureChoiceForm", () => { await fireEvent.change(selectUnderSetting(container, "Property"), { target: { value: "named" } }); flushSync(); expect(getByLabelText("Property")).toHaveValue("status"); - await fireEvent.change(selectUnderSetting(container, "Write position"), { target: { value: "bottom" } }); + await fireEvent.change(selectUnderSetting(container, "Position"), { target: { value: "bottom" } }); flushSync(); expect(props.choice.propertyCapture).toBeUndefined(); - expect(settingNames(container)).toContain("Task"); + expect(container.querySelector('[aria-label="Task"]')).not.toBeNull(); }); // #1748: for a list destination each line of the Capture format is one item. @@ -148,10 +149,10 @@ describe("CaptureChoiceForm", () => { props.choice.format.enabled = true; flushSync(); const actionDesc = () => settingItem(container, "Action").querySelector(".setting-item-description")?.textContent ?? ""; - const textarea = () => settingItem(container, "Capture format").closest(".qa-field")?.querySelector("textarea") as HTMLTextAreaElement; + const textarea = () => settingItem(container, "What").closest(".qa-field")?.querySelector("textarea") as HTMLTextAreaElement; expect(textarea().placeholder).toBe("{{VALUE}}"); - await fireEvent.change(selectUnderSetting(container, "Write position"), { target: { value: "property" } }); + await fireEvent.change(selectUnderSetting(container, "Position"), { target: { value: "property" } }); flushSync(); expect(actionDesc()).toContain("For a list, each line is one item"); expect(actionDesc()).toContain("{{PROPERTY}}"); @@ -164,7 +165,7 @@ describe("CaptureChoiceForm", () => { expect(actionDesc()).toContain("{{PROPERTY}}"); expect(textarea().placeholder).toBe("One item per line"); - await fireEvent.change(selectUnderSetting(container, "Write position"), { target: { value: "bottom" } }); + await fireEvent.change(selectUnderSetting(container, "Position"), { target: { value: "bottom" } }); flushSync(); expect(textarea().placeholder).toBe("{{VALUE}}"); }); @@ -175,7 +176,7 @@ describe("CaptureChoiceForm", () => { expect(headerBefore).not.toBeNull(); expect(settingNames(container)).not.toContain("Insert after"); - const select = selectUnderSetting(container, "Write position"); + const select = selectUnderSetting(container, "Position"); await fireEvent.change(select, { target: { value: "after" } }); flushSync(); expect(settingNames(container)).toContain("Insert after"); @@ -194,12 +195,12 @@ describe("CaptureChoiceForm", () => { it("hides the create/open/file-opening sections when capturing to the active file", () => { const { container, props } = mountForm(); - expect(settingNames(container)).toContain("Create file if it doesn't exist"); + expect(settingNames(container)).toContain("Create note if it doesn't exist"); props.choice.captureToActiveFile = true; flushSync(); const names = settingNames(container); - expect(names).not.toContain("Create file if it doesn't exist"); + expect(names).not.toContain("Create note if it doesn't exist"); expect(names).not.toContain("Open"); }); @@ -216,18 +217,19 @@ describe("CaptureChoiceForm", () => { const { container } = render(CaptureChoiceForm, { props: { choice: props.choice, app: props.app, plugin: props.plugin }, }); - expect(settingNames(container)).toContain("Create file if it doesn't exist"); + openMoreSettings(container); + expect(settingNames(container)).toContain("Create note if it doesn't exist"); props.choice.captureToActiveFile = true; flushSync(); expect(settingNames(container)).not.toContain( - "Create file if it doesn't exist", + "Create note if it doesn't exist", ); }); it("persists write-position edits onto the form proxy (snapshot reflects them)", async () => { const { container, props } = mountForm(); - const select = selectUnderSetting(container, "Write position"); + const select = selectUnderSetting(container, "Position"); await fireEvent.change(select, { target: { value: "before" } }); flushSync(); // Mutual-exclusivity zeroing held: only insertBefore is enabled. @@ -254,10 +256,14 @@ describe("CaptureChoiceForm", () => { ).toHaveAttribute("data-icon", "inbox"); }); - it("keeps the optional icon override at the bottom of the form", () => { + it("keeps the inputs and the steps above More settings, and the optional icon override last", async () => { const { container } = mountForm(); + await vi.waitFor(() => expect(settingNames(container)).toContain("Inputs")); - expect(settingNames(container).at(-1)).toBe("Icon"); + const names = settingNames(container); + expect(names.indexOf("Inputs")).toBeLessThan(names.indexOf("Steps")); + expect(names.slice(names.indexOf("Steps"), names.indexOf("Steps") + 2)).toEqual(["Steps", "More settings"]); + expect(names.at(-1)).toBe("Icon"); }); it("persists the copy-link-to-clipboard toggle", async () => { @@ -275,14 +281,14 @@ describe("CaptureChoiceForm", () => { }); // #1544: the capture target used to be described by three rows — a control-less - // "Capture to", the "Capture to active file" toggle, a control-less "File path / + // "Where", the "Capture to active note" toggle, a control-less "File path / // format" — and the input that actually holds it advertised itself as a *file // name* format. One decision, one label, one description, one input. // #2014: the whole-file Templater pass is deprecated. Only a choice that // already has it on still sees the row, so it can turn it off. it("shows the deprecated whole-file Templater option only while it is on", async () => { const { container, props } = mountForm(); - const rowName = "Run Templater on entire destination file after capture (deprecated)"; + const rowName = "Run Templater on entire destination note after capture (deprecated)"; expect(settingNames(container)).not.toContain(rowName); props.choice.templater = { afterCapture: "wholeFile" }; @@ -301,7 +307,7 @@ describe("CaptureChoiceForm", () => { const { container, props } = mountForm(); props.choice.createFileIfItDoesntExist = { enabled: false, createWithTemplate: true, template: "T.md" }; flushSync(); - const button = () => [...settingItem(container, "Capture to").querySelectorAll("button")] + const button = () => [...settingItem(container, "Where").querySelectorAll("button")] .find((el) => el.textContent === "Daily note"); await fireEvent.click(button()!); @@ -317,9 +323,9 @@ describe("CaptureChoiceForm", () => { const names = settingNames(container); expect(names).not.toContain("File path / format"); - expect(names.filter((name) => name === "Capture to")).toHaveLength(1); + expect(names.filter((name) => name === "Where")).toHaveLength(1); - const input = getByLabelText("Capture to") as HTMLInputElement; + const input = getByLabelText("Where") as HTMLInputElement; expect(input.placeholder).toBe("Daily/{{DATE}}.md"); // The label is a real