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"); + }); +});