diff --git a/docs/public/packages/capture-inbox-gps.quickadd.json b/docs/public/packages/capture-inbox-gps.quickadd.json
new file mode 100644
index 000000000..1f447d529
--- /dev/null
+++ b/docs/public/packages/capture-inbox-gps.quickadd.json
@@ -0,0 +1,44 @@
+{
+ "schemaVersion": 1,
+ "quickAddVersion": "2.23.0",
+ "createdAt": "2026-08-31T00:00:00.000Z",
+ "rootChoiceIds": [
+ "qa-pkg-capture-inbox-gps"
+ ],
+ "choices": [
+ {
+ "choice": {
+ "id": "qa-pkg-capture-inbox-gps",
+ "name": "Capture to Inbox with GPS",
+ "type": "Macro",
+ "command": true,
+ "runOnStartup": false,
+ "macro": {
+ "id": "qa-pkg-capture-inbox-gps-macro",
+ "name": "Capture to Inbox with GPS",
+ "commands": [
+ {
+ "id": "qa-pkg-capture-inbox-gps-script",
+ "name": "captureInboxGps",
+ "type": "UserScript",
+ "path": "scripts/captureInboxGps.js",
+ "settings": {}
+ }
+ ]
+ }
+ },
+ "pathHint": [
+ "Capture to Inbox with GPS"
+ ],
+ "parentChoiceId": null
+ }
+ ],
+ "assets": [
+ {
+ "kind": "user-script",
+ "originalPath": "scripts/captureInboxGps.js",
+ "contentEncoding": "base64",
+ "content": "Y29uc3QgSU5CT1hfUEFUSCA9ICJJbmJveCBwYXRoIjsKY29uc3QgQ1JFQVRFX0lGX01JU1NJTkcgPSAiQ3JlYXRlIEluYm94IGlmIG1pc3NpbmciOwoKbW9kdWxlLmV4cG9ydHMgPSB7CgllbnRyeTogc3RhcnQsCglzZXR0aW5nczogewoJCW5hbWU6ICJDYXB0dXJlIHRvIEluYm94IHdpdGggR1BTIiwKCQlhdXRob3I6ICJRdWlja0FkZCIsCgkJb3B0aW9uczogewoJCQlbSU5CT1hfUEFUSF06IHsKCQkJCXR5cGU6ICJ0ZXh0IiwKCQkJCWRlZmF1bHRWYWx1ZTogIkluYm94Lm1kIiwKCQkJCXBsYWNlaG9sZGVyOiAiSW5ib3gubWQiLAoJCQkJZGVzY3JpcHRpb246ICJWYXVsdCBwYXRoIG9mIHRoZSBub3RlIHRvIGFwcGVuZCB0byIsCgkJCX0sCgkJCVtDUkVBVEVfSUZfTUlTU0lOR106IHsKCQkJCXR5cGU6ICJ0b2dnbGUiLAoJCQkJZGVmYXVsdFZhbHVlOiB0cnVlLAoJCQkJZGVzY3JpcHRpb246ICJDcmVhdGUgdGhlIEluYm94IG5vdGUgd2hlbiBpdCBkb2VzIG5vdCBleGlzdCIsCgkJCX0sCgkJfSwKCX0sCn07Cgphc3luYyBmdW5jdGlvbiBzdGFydChwYXJhbXMsIHNldHRpbmdzKSB7Cgljb25zdCB7IE5vdGljZSwgVEZpbGUsIG5vcm1hbGl6ZVBhdGggfSA9IHBhcmFtcy5vYnNpZGlhbjsKCWNvbnN0IGluYm94UGF0aCA9IG5vcm1hbGl6ZVBhdGgoCgkJU3RyaW5nKHNldHRpbmdzPy5bSU5CT1hfUEFUSF0gfHwgIkluYm94Lm1kIikudHJpbSgpIHx8ICJJbmJveC5tZCIsCgkpOwoJY29uc3QgY3JlYXRlSWZNaXNzaW5nID0gc2V0dGluZ3M/LltDUkVBVEVfSUZfTUlTU0lOR10gIT09IGZhbHNlOwoKCWNvbnN0IGNvb3Jkc1Byb21pc2UgPSByZXNvbHZlQ29vcmRpbmF0ZXMocGFyYW1zLnZhcmlhYmxlcyk7Cgljb25zdCB0ZXh0ID0gYXdhaXQgcmVzb2x2ZU5vdGVUZXh0KHBhcmFtcyk7CglpZiAoIXRleHQpIHJldHVybjsKCgljb25zdCBjb29yZHMgPSBhd2FpdCBjb29yZHNQcm9taXNlOwoJY29uc3Qgc3RhbXAgPSBmb3JtYXRTdGFtcCgpOwoJY29uc3Qgc3VmZml4ID0gY29vcmRzID8gYCAoJHtjb29yZHN9KWAgOiAiIjsKCWNvbnN0IGxpbmUgPSBgLSAke3N0YW1wfSAke3RleHR9JHtzdWZmaXh9XG5gOwoKCWNvbnN0IGV4aXN0aW5nID0gcGFyYW1zLmFwcC52YXVsdC5nZXRBYnN0cmFjdEZpbGVCeVBhdGgoaW5ib3hQYXRoKTsKCWlmIChleGlzdGluZyBpbnN0YW5jZW9mIFRGaWxlKSB7CgkJYXdhaXQgcGFyYW1zLmFwcC52YXVsdC5hcHBlbmQoZXhpc3RpbmcsIGxpbmUpOwoJfSBlbHNlIGlmIChleGlzdGluZykgewoJCW5ldyBOb3RpY2UoYFF1aWNrQWRkOiAke2luYm94UGF0aH0gaXMgYSBmb2xkZXIsIG5vdCBhIG5vdGVgKTsKCQlyZXR1cm47Cgl9IGVsc2UgaWYgKGNyZWF0ZUlmTWlzc2luZykgewoJCWF3YWl0IGVuc3VyZVBhcmVudEZvbGRlcnMocGFyYW1zLmFwcCwgaW5ib3hQYXRoKTsKCQlhd2FpdCBwYXJhbXMuYXBwLnZhdWx0LmNyZWF0ZShpbmJveFBhdGgsIGxpbmUpOwoJfSBlbHNlIHsKCQluZXcgTm90aWNlKGBRdWlja0FkZDogJHtpbmJveFBhdGh9IG5vdCBmb3VuZGApOwoJCXJldHVybjsKCX0KCglpZiAoIWNvb3JkcykgewoJCW5ldyBOb3RpY2UoIlNhdmVkIHdpdGhvdXQgR1BTIChubyBmaXgsIHBlcm1pc3Npb24gZGVuaWVkLCBvciBkZXNrdG9wKSIpOwoJfQp9Cgphc3luYyBmdW5jdGlvbiByZXNvbHZlTm90ZVRleHQocGFyYW1zKSB7Cgljb25zdCBwcmVzZXQgPSBwYXJhbXMudmFyaWFibGVzPy52YWx1ZTsKCWlmICh0eXBlb2YgcHJlc2V0ID09PSAic3RyaW5nIikgewoJCXJldHVybiBwcmVzZXQudHJpbSgpOwoJfQoJY29uc3QgdHlwZWQgPSBhd2FpdCBwYXJhbXMucXVpY2tBZGRBcGkuaW5wdXRQcm9tcHQoIkluYm94Iik7CglyZXR1cm4gdHlwZW9mIHR5cGVkID09PSAic3RyaW5nIiA/IHR5cGVkLnRyaW0oKSA6ICIiOwp9CgpmdW5jdGlvbiByZXNvbHZlQ29vcmRpbmF0ZXModmFyaWFibGVzKSB7Cgljb25zdCBwcmVzZXQgPSB2YXJpYWJsZXM/LmNvb3JkaW5hdGVzOwoJaWYgKHR5cGVvZiBwcmVzZXQgPT09ICJzdHJpbmciICYmIHByZXNldC50cmltKCkpIHsKCQlyZXR1cm4gUHJvbWlzZS5yZXNvbHZlKHByZXNldC50cmltKCkpOwoJfQoKCXJldHVybiBuZXcgUHJvbWlzZSgocmVzb2x2ZSkgPT4gewoJCWlmICh0eXBlb2YgbmF2aWdhdG9yID09PSAidW5kZWZpbmVkIiB8fCAhbmF2aWdhdG9yLmdlb2xvY2F0aW9uKSB7CgkJCXJlc29sdmUoIiIpOwoJCQlyZXR1cm47CgkJfQoKCQluYXZpZ2F0b3IuZ2VvbG9jYXRpb24uZ2V0Q3VycmVudFBvc2l0aW9uKAoJCQkocG9zKSA9PiB7CgkJCQlyZXNvbHZlKAoJCQkJCWAke3Bvcy5jb29yZHMubGF0aXR1ZGUudG9GaXhlZCg2KX0sICR7cG9zLmNvb3Jkcy5sb25naXR1ZGUudG9GaXhlZCg2KX1gLAoJCQkJKTsKCQkJfSwKCQkJKCkgPT4gcmVzb2x2ZSgiIiksCgkJCXsKCQkJCWVuYWJsZUhpZ2hBY2N1cmFjeTogdHJ1ZSwKCQkJCXRpbWVvdXQ6IDMwMDAwLAoJCQkJbWF4aW11bUFnZTogNjAwMDAsCgkJCX0sCgkJKTsKCX0pOwp9CgpmdW5jdGlvbiBmb3JtYXRTdGFtcCgpIHsKCWlmICh0eXBlb2Ygd2luZG93ICE9PSAidW5kZWZpbmVkIiAmJiB3aW5kb3cubW9tZW50KSB7CgkJcmV0dXJuIHdpbmRvdy5tb21lbnQoKS5mb3JtYXQoIllZWVktTU0tREQgSEg6bW0iKTsKCX0KCXJldHVybiBuZXcgRGF0ZSgpLnRvSVNPU3RyaW5nKCkuc2xpY2UoMCwgMTYpLnJlcGxhY2UoIlQiLCAiICIpOwp9Cgphc3luYyBmdW5jdGlvbiBlbnN1cmVQYXJlbnRGb2xkZXJzKGFwcCwgZmlsZVBhdGgpIHsKCWNvbnN0IHBhcnRzID0gZmlsZVBhdGguc3BsaXQoIi8iKS5maWx0ZXIoQm9vbGVhbik7CglwYXJ0cy5wb3AoKTsKCWxldCBmb2xkZXIgPSAiIjsKCWZvciAoY29uc3QgcGFydCBvZiBwYXJ0cykgewoJCWZvbGRlciA9IGZvbGRlciA/IGAke2ZvbGRlcn0vJHtwYXJ0fWAgOiBwYXJ0OwoJCWlmICghYXBwLnZhdWx0LmdldEFic3RyYWN0RmlsZUJ5UGF0aChmb2xkZXIpKSB7CgkJCWF3YWl0IGFwcC52YXVsdC5jcmVhdGVGb2xkZXIoZm9sZGVyKTsKCQl9Cgl9Cn0K"
+ }
+ ]
+}
diff --git a/docs/public/scripts/captureInboxGps.js b/docs/public/scripts/captureInboxGps.js
new file mode 100644
index 000000000..ba4322e79
--- /dev/null
+++ b/docs/public/scripts/captureInboxGps.js
@@ -0,0 +1,114 @@
+const INBOX_PATH = "Inbox path";
+const CREATE_IF_MISSING = "Create Inbox if missing";
+
+module.exports = {
+ entry: start,
+ settings: {
+ name: "Capture to Inbox with GPS",
+ author: "QuickAdd",
+ options: {
+ [INBOX_PATH]: {
+ type: "text",
+ defaultValue: "Inbox.md",
+ placeholder: "Inbox.md",
+ description: "Vault path of the note to append to",
+ },
+ [CREATE_IF_MISSING]: {
+ type: "toggle",
+ defaultValue: true,
+ description: "Create the Inbox note when it does not exist",
+ },
+ },
+ },
+};
+
+async function start(params, settings) {
+ const { Notice, TFile, normalizePath } = params.obsidian;
+ const inboxPath = normalizePath(
+ String(settings?.[INBOX_PATH] || "Inbox.md").trim() || "Inbox.md",
+ );
+ const createIfMissing = settings?.[CREATE_IF_MISSING] !== false;
+
+ const coordsPromise = resolveCoordinates(params.variables);
+ const text = await resolveNoteText(params);
+ if (!text) return;
+
+ const coords = await coordsPromise;
+ const stamp = formatStamp();
+ const suffix = coords ? ` (${coords})` : "";
+ const line = `- ${stamp} ${text}${suffix}\n`;
+
+ const existing = params.app.vault.getAbstractFileByPath(inboxPath);
+ if (existing instanceof TFile) {
+ await params.app.vault.append(existing, line);
+ } else if (existing) {
+ new Notice(`QuickAdd: ${inboxPath} is a folder, not a note`);
+ return;
+ } else if (createIfMissing) {
+ await ensureParentFolders(params.app, inboxPath);
+ await params.app.vault.create(inboxPath, line);
+ } else {
+ new Notice(`QuickAdd: ${inboxPath} not found`);
+ return;
+ }
+
+ if (!coords) {
+ new Notice("Saved without GPS (no fix, permission denied, or desktop)");
+ }
+}
+
+async function resolveNoteText(params) {
+ const preset = params.variables?.value;
+ if (typeof preset === "string") {
+ return preset.trim();
+ }
+ const typed = await params.quickAddApi.inputPrompt("Inbox");
+ return typeof typed === "string" ? typed.trim() : "";
+}
+
+function resolveCoordinates(variables) {
+ const preset = variables?.coordinates;
+ if (typeof preset === "string" && preset.trim()) {
+ return Promise.resolve(preset.trim());
+ }
+
+ return new Promise((resolve) => {
+ if (typeof navigator === "undefined" || !navigator.geolocation) {
+ resolve("");
+ return;
+ }
+
+ navigator.geolocation.getCurrentPosition(
+ (pos) => {
+ resolve(
+ `${pos.coords.latitude.toFixed(6)}, ${pos.coords.longitude.toFixed(6)}`,
+ );
+ },
+ () => resolve(""),
+ {
+ enableHighAccuracy: true,
+ timeout: 30000,
+ maximumAge: 60000,
+ },
+ );
+ });
+}
+
+function formatStamp() {
+ if (typeof window !== "undefined" && window.moment) {
+ return window.moment().format("YYYY-MM-DD HH:mm");
+ }
+ return new Date().toISOString().slice(0, 16).replace("T", " ");
+}
+
+async function ensureParentFolders(app, filePath) {
+ const parts = filePath.split("/").filter(Boolean);
+ parts.pop();
+ let folder = "";
+ for (const part of parts) {
+ folder = folder ? `${folder}/${part}` : part;
+ if (!app.vault.getAbstractFileByPath(folder)) {
+ await app.vault.createFolder(folder);
+ }
+ }
+}
diff --git a/docs/src/content/docs/docs/Examples/Macro_CaptureInboxGps.md b/docs/src/content/docs/docs/Examples/Macro_CaptureInboxGps.md
new file mode 100644
index 000000000..265a7c847
--- /dev/null
+++ b/docs/src/content/docs/docs/Examples/Macro_CaptureInboxGps.md
@@ -0,0 +1,57 @@
+---
+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
+---
+
+This macro asks for a quick note, looks up your device location at the same
+time, and appends a timestamped line to `Inbox.md`. It is meant for capture on
+the go: voice-to-text in the prompt, GPS in the background, one line waiting
+when you get home.
+
+GPS works on **Obsidian mobile 1.11 or newer**. The first run asks Android or
+iOS for location permission. Desktop cannot read GPS, so the line is still
+saved, just without coordinates. A cold satellite lock while offline can take
+a while; if it times out, the note is saved without GPS.
+
+This is not a built-in `{{coordinates}}` token. A capture format that calls
+`{{MACRO:...}}` would wait for GPS *before* opening the prompt, which fights
+voice-to-text. The script starts the location lookup first, then prompts.
+
+## Install from a package {#install-from-a-package}
+
+1. Download capture-inbox-gps.quickadd.json.
+2. Open **Settings → QuickAdd** and click **Import package…**.
+3. Paste the file contents. Review the script (it is marked executable), tick
+ the acknowledgement, and import.
+4. Assign a hotkey to **Capture to Inbox with GPS**, or run it from the command
+ palette.
+
+The package writes `scripts/captureInboxGps.js` and a Macro choice that runs
+it. Open the cog on the script step to change the Inbox path (default
+`Inbox.md`) or to turn off creating the note when it is missing.
+
+See [Share QuickAdd Packages](/docs/Choices/Packages/) for the import review
+screen.
+
+## Install by hand {#install-by-hand}
+
+1. Save captureInboxGps.js
+ anywhere in your vault except `.obsidian` or a hidden folder.
+2. **Settings → QuickAdd → Add Choice → Macro**. Name it
+ `Capture to Inbox with GPS`.
+3. In the Macro Builder, add that script as a **User Script**.
+4. Enable **Command** on the choice so it appears in the palette.
+
+## What you get
+
+Each run appends one line:
+
+```markdown
+- 2026-08-31 15:42 Saw the trail marker at the ridge (55.676098, 12.568337)
+```
+
+If GPS is unavailable, the same line is written without the coordinates.
+
+A run that already has `value` and `coordinates` set (URI, CLI, or an earlier
+macro step) skips the prompt and the location lookup.
diff --git a/docs/src/content/docs/docs/Examples/index.md b/docs/src/content/docs/docs/Examples/index.md
index fadb040ec..468e3b039 100644
--- a/docs/src/content/docs/docs/Examples/index.md
+++ b/docs/src/content/docs/docs/Examples/index.md
@@ -10,6 +10,7 @@ from a blank choice.
| 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 |
| [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 |
@@ -27,8 +28,10 @@ from a blank choice.
### Capture information faster
Start with [Capture to Your Daily Note](/docs/Examples/Capture_ToDailyNote/) for daily-note
-captures. Move to [Canvas Capture](/docs/Examples/Capture_CanvasCapture/) when your target is
-a Canvas card instead of a Markdown note.
+captures. Use [Capture to Inbox with GPS](/docs/Examples/Macro_CaptureInboxGps/) when the
+line should also store device coordinates. Move to
+[Canvas Capture](/docs/Examples/Capture_CanvasCapture/) when your target is a Canvas card
+instead of a Markdown note.
### Create structured notes
diff --git a/tests/captureInboxGps.package.test.ts b/tests/captureInboxGps.package.test.ts
new file mode 100644
index 000000000..9890d0f79
--- /dev/null
+++ b/tests/captureInboxGps.package.test.ts
@@ -0,0 +1,112 @@
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it, vi } from "vitest";
+import type { App } from "obsidian";
+import type IMacroChoice from "../src/types/choices/IMacroChoice";
+import { decodeFromBase64 } from "../src/utils/base64";
+import { buildPackagePreview } from "../src/services/packagePreview";
+import {
+ applyPackageImport,
+ parseQuickAddPackage,
+} from "../src/services/packageImportService";
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const packagePath = path.join(
+ root,
+ "docs/public/packages/capture-inbox-gps.quickadd.json",
+);
+const scriptPath = path.join(root, "docs/public/scripts/captureInboxGps.js");
+
+function lf(value: string): string {
+ return value.replace(/\r\n/g, "\n");
+}
+
+function createMockApp(initialFiles: Record = {}): {
+ app: App;
+ files: Map;
+} {
+ const files = new Map(Object.entries(initialFiles));
+ const app = {
+ vault: {
+ adapter: {
+ exists: vi.fn(async (filePath: string) => files.has(filePath)),
+ read: vi.fn(async (filePath: string) => {
+ const content = files.get(filePath);
+ if (content === undefined) throw new Error(`Missing file: ${filePath}`);
+ return content;
+ }),
+ write: vi.fn(async (filePath: string, content: string) => {
+ files.set(filePath, content);
+ }),
+ },
+ configDir: ".obsidian",
+ createFolder: vi.fn(async (folderPath: string) => {
+ files.set(folderPath, "");
+ }),
+ },
+ } as unknown as App;
+
+ return { app, files };
+}
+
+describe("Capture to Inbox with GPS package", () => {
+ const raw = readFileSync(packagePath, "utf8");
+ const script = lf(readFileSync(scriptPath, "utf8"));
+ const pkg = parseQuickAddPackage(raw);
+
+ it("bundles the published script and a command-palette Macro", () => {
+ expect(pkg.quickAddVersion).toBe("2.23.0");
+ expect(pkg.rootChoiceIds).toEqual(["qa-pkg-capture-inbox-gps"]);
+ expect(pkg.assets).toHaveLength(1);
+ expect(pkg.assets[0]?.originalPath).toBe("scripts/captureInboxGps.js");
+ expect(lf(decodeFromBase64(pkg.assets[0]?.content ?? ""))).toBe(script);
+
+ const choice = pkg.choices[0]?.choice as IMacroChoice;
+ expect(choice.name).toBe("Capture to Inbox with GPS");
+ expect(choice.command).toBe(true);
+ expect(choice.runOnStartup).toBe(false);
+ expect(choice.macro.commands[0]?.type).toBe("UserScript");
+ expect(
+ (choice.macro.commands[0] as { path?: string }).path,
+ ).toBe("scripts/captureInboxGps.js");
+ });
+
+ it("previews as an executable script that registers a command", () => {
+ const preview = buildPackagePreview([], pkg, new Set());
+
+ expect(preview.summary.scriptCount).toBe(1);
+ expect(preview.summary.registersCommandCount).toBe(1);
+ expect(preview.criticalScriptPaths).toEqual(["scripts/captureInboxGps.js"]);
+ expect(preview.capabilityRows.some((row) => row.flag === "user-script")).toBe(
+ true,
+ );
+ expect(
+ preview.capabilityRows.some((row) => row.flag === "registers-command"),
+ ).toBe(true);
+ });
+
+ it("imports the script file and the Macro choice", async () => {
+ const { app, files } = createMockApp();
+ const result = await applyPackageImport({
+ app,
+ existingChoices: [],
+ pkg,
+ choiceDecisions: [
+ { choiceId: "qa-pkg-capture-inbox-gps", mode: "import" },
+ ],
+ assetDecisions: [
+ {
+ originalPath: "scripts/captureInboxGps.js",
+ destinationPath: "scripts/captureInboxGps.js",
+ mode: "write",
+ },
+ ],
+ });
+
+ expect(result.addedChoiceIds).toEqual(["qa-pkg-capture-inbox-gps"]);
+ expect(result.writtenAssets).toEqual(["scripts/captureInboxGps.js"]);
+ expect(lf(files.get("scripts/captureInboxGps.js") ?? "")).toBe(script);
+ expect(result.updatedChoices[0]?.name).toBe("Capture to Inbox with GPS");
+ });
+});
diff --git a/tests/e2e/capture-inbox-gps-package.test.ts b/tests/e2e/capture-inbox-gps-package.test.ts
new file mode 100644
index 000000000..408a7bb3c
--- /dev/null
+++ b/tests/e2e/capture-inbox-gps-package.test.ts
@@ -0,0 +1,261 @@
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
+import {
+ captureFailureArtifacts,
+ clearVaultRunLockMarker,
+ createSandboxApi,
+} from "obsidian-e2e";
+import type {
+ ObsidianClient,
+ PluginHandle,
+ SandboxApi,
+ VaultRunLock,
+} from "obsidian-e2e";
+import {
+ acquireQuickAddVaultRunLock,
+ createQuickAddObsidianClient,
+ seedVaultFile,
+} from "./e2eVault";
+
+const PLUGIN_ID = "quickadd";
+const CHOICE_ID = "qa-pkg-capture-inbox-gps";
+const CHOICE_NAME = "Capture to Inbox with GPS";
+const PACKAGE_VAULT_PATH = "packages/capture-inbox-gps.quickadd.json";
+const WAIT_OPTS = { timeoutMs: 15_000, intervalMs: 200 };
+const repoRoot = path.resolve(
+ path.dirname(fileURLToPath(import.meta.url)),
+ "../..",
+);
+const packageJson = readFileSync(
+ path.join(repoRoot, "docs/public/packages/capture-inbox-gps.quickadd.json"),
+ "utf8",
+);
+
+let obsidian: ObsidianClient;
+let sandbox: SandboxApi;
+let qa: PluginHandle;
+let lock: VaultRunLock | undefined;
+let inboxPath: string;
+
+type QuickAddData = {
+ choices: Array<{
+ id?: string;
+ macro?: {
+ commands?: Array<{ settings?: Record }>;
+ };
+ }>;
+ migrations: Record;
+};
+
+type PackagePreviewResponse = {
+ ok: boolean;
+ error?: string;
+ preview?: {
+ summary?: {
+ scriptCount?: number;
+ registersCommandCount?: number;
+ };
+ criticalScriptPaths?: string[];
+ };
+};
+
+async function runTeardownStep(
+ label: string,
+ step: () => Promise | unknown,
+ errors: unknown[],
+) {
+ try {
+ await step();
+ } catch (error) {
+ errors.push(error);
+ console.warn(`capture-inbox-gps teardown failed during ${label}`, error);
+ }
+}
+
+async function mockGeolocation(
+ result: { latitude: number; longitude: number } | "error" | "missing",
+) {
+ await obsidian.dev.eval(`(() => {
+ const result = ${JSON.stringify(result)};
+ if (result === "missing") {
+ Object.defineProperty(navigator, "geolocation", {
+ configurable: true,
+ value: undefined,
+ });
+ return true;
+ }
+ Object.defineProperty(navigator, "geolocation", {
+ configurable: true,
+ value: {
+ getCurrentPosition(success, error) {
+ if (result === "error") {
+ error({ code: 2, message: "unavailable" });
+ return;
+ }
+ success({
+ coords: {
+ latitude: result.latitude,
+ longitude: result.longitude,
+ accuracy: 8,
+ altitude: null,
+ altitudeAccuracy: null,
+ heading: null,
+ speed: null,
+ },
+ timestamp: Date.now(),
+ });
+ },
+ },
+ });
+ return true;
+ })()`);
+}
+
+async function runCapture(vars: Record) {
+ return obsidian.execJson<{ ok: boolean; error?: string }>("quickadd:run", {
+ choice: CHOICE_NAME,
+ ...Object.fromEntries(
+ Object.entries(vars).map(([key, value]) => [`value-${key}`, value]),
+ ),
+ });
+}
+
+describe("Capture to Inbox with GPS package", () => {
+ beforeAll(async () => {
+ obsidian = createQuickAddObsidianClient();
+ lock = await acquireQuickAddVaultRunLock(obsidian);
+ await lock.publishMarker(obsidian);
+ qa = obsidian.plugin(PLUGIN_ID);
+ sandbox = await createSandboxApi({
+ obsidian,
+ sandboxRoot: "__obsidian_e2e__",
+ testName: "capture-inbox-gps-package",
+ });
+ inboxPath = sandbox.path("gps-inbox.md");
+
+ await seedVaultFile(obsidian, sandbox, PACKAGE_VAULT_PATH, packageJson);
+
+ const imported = await obsidian.dev.evalJsonAsync<{
+ ok: boolean;
+ scriptPath?: string;
+ error?: string;
+ }>(`(async () => {
+ try {
+ const raw = await app.vault.adapter.read(${JSON.stringify(PACKAGE_VAULT_PATH)});
+ const pkg = JSON.parse(raw);
+ const decode = (b64) => new TextDecoder().decode(
+ Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)),
+ );
+ for (const asset of pkg.assets) {
+ const dest = asset.originalPath;
+ const folder = dest.split("/").slice(0, -1).join("/");
+ if (folder && !app.vault.getAbstractFileByPath(folder)) {
+ await app.vault.createFolder(folder);
+ }
+ const existing = app.vault.getAbstractFileByPath(dest);
+ const content = decode(asset.content);
+ if (existing) await app.vault.modify(existing, content);
+ else await app.vault.create(dest, content);
+ }
+ return { ok: true, scriptPath: pkg.assets[0].originalPath };
+ } catch (error) {
+ return { ok: false, error: String(error && error.message ? error.message : error) };
+ }
+ })()`);
+
+ expect(imported).toMatchObject({
+ ok: true,
+ scriptPath: "scripts/captureInboxGps.js",
+ });
+
+ const parsed = JSON.parse(packageJson) as {
+ choices: Array<{ choice: QuickAddData["choices"][number] }>;
+ };
+
+ await qa.data().patch((data) => {
+ data.choices = data.choices.filter((choice) => choice.id !== CHOICE_ID);
+ const choice = structuredClone(parsed.choices[0]?.choice);
+ const command = choice?.macro?.commands?.[0];
+ if (command) {
+ command.settings = {
+ "Inbox path": inboxPath,
+ "Create Inbox if missing": true,
+ };
+ }
+ if (choice) data.choices.push(choice);
+ });
+
+ await qa.reload({ waitUntilReady: true });
+ }, 30_000);
+
+ beforeEach((ctx) => {
+ ctx.onTestFailed(async () => {
+ await captureFailureArtifacts(
+ { id: ctx.task.id, name: ctx.task.name },
+ obsidian,
+ { plugin: qa, captureOnFailure: true },
+ );
+ });
+ });
+
+ afterAll(async () => {
+ const errors: unknown[] = [];
+ await runTeardownStep("restoreData", () => qa?.restoreData?.(), errors);
+ await runTeardownStep("reload", () => qa?.reload?.(), errors);
+ await runTeardownStep("sandbox cleanup", () => sandbox?.cleanup?.(), errors);
+ await runTeardownStep(
+ "clear vault run lock marker",
+ () => (obsidian ? clearVaultRunLockMarker(obsidian) : undefined),
+ errors,
+ );
+ await runTeardownStep("release vault lock", () => lock?.release(), errors);
+ if (errors.length > 0) {
+ throw errors[0];
+ }
+ }, 15_000);
+
+ it("previews the packaged script as executable", async () => {
+ const preview = await obsidian.execJson(
+ "quickadd:package-preview",
+ { path: PACKAGE_VAULT_PATH, decode: "true" },
+ );
+
+ expect(preview.ok).toBe(true);
+ expect(preview.preview?.summary?.scriptCount).toBe(1);
+ expect(preview.preview?.summary?.registersCommandCount).toBe(1);
+ expect(preview.preview?.criticalScriptPaths).toEqual([
+ "scripts/captureInboxGps.js",
+ ]);
+ });
+
+ it("appends a GPS-stamped line from the mocked device location", async () => {
+ await mockGeolocation({ latitude: 55.676098, longitude: 12.568337 });
+ const outcome = await runCapture({ value: "Trail marker" });
+ expect(outcome.ok).toBe(true);
+
+ const content = await sandbox.waitForContent(
+ inboxPath,
+ (text) => text.includes("Trail marker") && text.includes("55.676098"),
+ WAIT_OPTS,
+ );
+ expect(content).toMatch(
+ /^- \d{4}-\d{2}-\d{2} \d{2}:\d{2} Trail marker \(55\.676098, 12\.568337\)\n$/,
+ );
+ });
+
+ it("still captures when GPS is unavailable", async () => {
+ await mockGeolocation("error");
+ const outcome = await runCapture({ value: "No fix today" });
+ expect(outcome.ok).toBe(true);
+
+ const content = await sandbox.waitForContent(
+ inboxPath,
+ (text) => text.includes("No fix today"),
+ WAIT_OPTS,
+ );
+ expect(content).toMatch(/^- \d{4}-\d{2}-\d{2} \d{2}:\d{2} No fix today\n$/);
+ expect(content).not.toContain("(");
+ });
+});
diff --git a/tests/examples/captureInboxGps.test.ts b/tests/examples/captureInboxGps.test.ts
new file mode 100644
index 000000000..f508d4502
--- /dev/null
+++ b/tests/examples/captureInboxGps.test.ts
@@ -0,0 +1,221 @@
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const scriptPath = path.resolve(
+ path.dirname(fileURLToPath(import.meta.url)),
+ "../../docs/public/scripts/captureInboxGps.js",
+);
+
+class TFile {
+ path: string;
+ constructor(path: string) {
+ this.path = path;
+ }
+}
+
+class Notice {
+ static messages: string[] = [];
+ constructor(message: string) {
+ Notice.messages.push(message);
+ }
+}
+
+function loadScript() {
+ const source = readFileSync(scriptPath, "utf8");
+ const module = {
+ exports: {} as {
+ entry: (
+ params: unknown,
+ settings?: Record,
+ ) => Promise;
+ settings: unknown;
+ },
+ };
+ const fn = new Function("require", "module", "exports", source);
+ fn(() => undefined, module, module.exports);
+ return module.exports;
+}
+
+function makeParams(options: {
+ files?: Map;
+ value?: string;
+ coordinates?: string;
+ geolocation?: {
+ success?: { latitude: number; longitude: number };
+ error?: boolean;
+ missing?: boolean;
+ };
+}) {
+ const files = options.files ?? new Map();
+ const created: { path: string; content: string }[] = [];
+ const appended: { path: string; content: string }[] = [];
+ const folders: string[] = [];
+
+ const params = {
+ app: {
+ vault: {
+ getAbstractFileByPath(filePath: string) {
+ return files.get(filePath) ?? null;
+ },
+ append: vi.fn(async (file: TFile, content: string) => {
+ appended.push({ path: file.path, content });
+ }),
+ create: vi.fn(async (filePath: string, content: string) => {
+ created.push({ path: filePath, content });
+ const file = new TFile(filePath);
+ files.set(filePath, file);
+ return file;
+ }),
+ createFolder: vi.fn(async (folderPath: string) => {
+ folders.push(folderPath);
+ files.set(folderPath, { folder: true, path: folderPath });
+ }),
+ },
+ },
+ quickAddApi: {
+ inputPrompt: vi.fn(async () => "dictated note"),
+ },
+ variables: {
+ ...(options.value !== undefined ? { value: options.value } : {}),
+ ...(options.coordinates !== undefined
+ ? { coordinates: options.coordinates }
+ : {}),
+ },
+ obsidian: { Notice, TFile, normalizePath: (value: string) => value },
+ };
+
+ if (options.geolocation?.missing) {
+ vi.stubGlobal("navigator", {});
+ } else {
+ vi.stubGlobal("navigator", {
+ geolocation: {
+ getCurrentPosition: (
+ success: (pos: GeolocationPosition) => void,
+ error: () => void,
+ ) => {
+ if (options.geolocation?.error) {
+ error();
+ return;
+ }
+ const coords = options.geolocation?.success ?? {
+ latitude: 55.676098,
+ longitude: 12.568337,
+ };
+ success({
+ coords: {
+ latitude: coords.latitude,
+ longitude: coords.longitude,
+ accuracy: 10,
+ altitude: null,
+ altitudeAccuracy: null,
+ heading: null,
+ speed: null,
+ },
+ timestamp: Date.now(),
+ } as GeolocationPosition);
+ },
+ },
+ });
+ }
+
+ vi.stubGlobal("moment", () => ({ format: () => "2026-08-31 15:42" }));
+ Object.defineProperty(globalThis, "window", {
+ configurable: true,
+ writable: true,
+ value: { ...(typeof window === "undefined" ? {} : window), moment: globalThis.moment },
+ });
+
+ return { params, created, appended, folders };
+}
+
+describe("captureInboxGps script", () => {
+ beforeEach(() => {
+ Notice.messages = [];
+ vi.unstubAllGlobals();
+ });
+
+ it("starts GPS, then appends a stamped line with coordinates", async () => {
+ const existing = new TFile("Inbox.md");
+ const { params, appended } = makeParams({
+ files: new Map([["Inbox.md", existing]]),
+ });
+ const script = loadScript();
+
+ await script.entry(params, {
+ "Inbox path": "Inbox.md",
+ "Create Inbox if missing": true,
+ });
+
+ expect(params.quickAddApi.inputPrompt).toHaveBeenCalled();
+ expect(appended).toEqual([
+ {
+ path: "Inbox.md",
+ content:
+ "- 2026-08-31 15:42 dictated note (55.676098, 12.568337)\n",
+ },
+ ]);
+ expect(Notice.messages).toEqual([]);
+ });
+
+ it("uses preset value and coordinates without prompting or GPS", async () => {
+ const existing = new TFile("Inbox.md");
+ const { params, appended } = makeParams({
+ files: new Map([["Inbox.md", existing]]),
+ value: "Trail marker",
+ coordinates: "1.000000, 2.000000",
+ geolocation: { missing: true },
+ });
+ const script = loadScript();
+
+ await script.entry(params, {
+ "Inbox path": "Inbox.md",
+ "Create Inbox if missing": true,
+ });
+
+ expect(params.quickAddApi.inputPrompt).not.toHaveBeenCalled();
+ expect(appended[0]?.content).toBe(
+ "- 2026-08-31 15:42 Trail marker (1.000000, 2.000000)\n",
+ );
+ });
+
+ it("creates the inbox note when it is missing", async () => {
+ const { params, created } = makeParams({
+ value: "first capture",
+ coordinates: "55.676098, 12.568337",
+ });
+ const script = loadScript();
+
+ await script.entry(params, {
+ "Inbox path": "Captures/Inbox.md",
+ "Create Inbox if missing": true,
+ });
+
+ expect(created).toEqual([
+ {
+ path: "Captures/Inbox.md",
+ content:
+ "- 2026-08-31 15:42 first capture (55.676098, 12.568337)\n",
+ },
+ ]);
+ });
+
+ it("saves without GPS when the location lookup fails", async () => {
+ const existing = new TFile("Inbox.md");
+ const { params, appended } = makeParams({
+ files: new Map([["Inbox.md", existing]]),
+ value: "no fix",
+ geolocation: { error: true },
+ });
+ const script = loadScript();
+
+ await script.entry(params, {
+ "Inbox path": "Inbox.md",
+ "Create Inbox if missing": true,
+ });
+
+ expect(appended[0]?.content).toBe("- 2026-08-31 15:42 no fix\n");
+ expect(Notice.messages[0]).toContain("Saved without GPS");
+ });
+});