Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions docs/public/packages/capture-inbox-gps.quickadd.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
114 changes: 114 additions & 0 deletions docs/public/scripts/captureInboxGps.js
Original file line number Diff line number Diff line change
@@ -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);
Comment on lines +42 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Insert a line boundary before appending

When an existing inbox note does not end with a newline—for example, a newly created note containing only # Inboxvault.append writes this string directly after the final character, producing # Inbox- 2026-... instead of a separate Markdown list item. Read the existing file and conditionally prefix the appended entry with \n; also regenerate the package's embedded script copy when applying the fix.

AGENTS.md reference: AGENTS.md:L31-L32

Useful? React with 👍 / 👎.

} 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);
}
}
}
57 changes: 57 additions & 0 deletions docs/src/content/docs/docs/Examples/Macro_CaptureInboxGps.md
Original file line number Diff line number Diff line change
@@ -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 <a href="/packages/capture-inbox-gps.quickadd.json" download>capture-inbox-gps.quickadd.json</a>.
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 <a href="/scripts/captureInboxGps.js" download>captureInboxGps.js</a>
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.
7 changes: 5 additions & 2 deletions docs/src/content/docs/docs/Examples/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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

Expand Down
112 changes: 112 additions & 0 deletions tests/captureInboxGps.package.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {}): {
app: App;
files: Map<string, string>;
} {
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");
});
});
Loading