diff --git a/AGENTS.md b/AGENTS.md index 99af2ba..48bac35 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,3 +82,52 @@ snotes/ app needs internet access on first editor load (or vendor these locally later). - `notes.py` opens a new `aiosqlite` connection per operation (same pattern as `tasky`); fine for a small personal corpus, not for hot paths. + +## Browser extension (browser-extension/) + +A Firefox MV3 "quick capture" extension — mirrors the pve-max extension pattern. +No backend changes: it reuses `GET /api/users` and `POST/PUT /api/notes`. + +``` +browser-extension/ + manifest.json # MV3, gecko id snotes-capture@schmeeve.com + background.js # Relays fetch() to snotes API (credentials:'include') + content.js # postMessage ping/pong + settings handshake; get_context msg + popup.html/js/css # Capture form: textarea, user select, Save, Page button + options.html/js # Server URL config (auto-filled via ping/pong) + release-firefox.sh # Package + --sign; copies .xpi to static/ + icons/ # 48px + 96px PNGs (from assets/snotes-favicon.png) +``` + +### How it works + +1. **Content script** runs on all pages. It answers `snotes-ping` → `snotes-pong` + and `snotes-settings` → writes `serverUrl` to `browser.storage.sync`, plus a + `get_context` message returning the page's selected text, title, and URL. +2. **Popup** prefills the note from the active tab's selection, lists users via + `list_users`, and Save creates (`POST`) then updates (`PUT`) via `save_note`. + A **Page** button inserts `[title](url)`; the note **autosaves 8s** after the + last keystroke. +3. **Helper page** — the SPA's "Capture" view (`showTools()` in `static/app.js`) + pings the extension, offers one-click install from + `/static/snotes-capture-firefox.xpi`, and sends `snotes-settings` with + `location.origin` to auto-configure. + +### Install / release + +```bash +# Manual dev install: about:debugging → Load Temporary Add-on → manifest.json +bash browser-extension/release-firefox.sh # package .zip only +bash browser-extension/release-firefox.sh --sign # sign via AMO (auto-bumps version) +``` + +The signed `.xpi` is copied to `static/snotes-capture-firefox.xpi` (git-tracked) +so the helper page serves it. Signing needs `AMO_JWT_ISSUER` / `AMO_JWT_SECRET` +and `npm install -g web-ext`. + +### Gotchas + +- The extension's `fetch` uses `credentials:'include'`, so on remote hosts it + only works if the user has already logged into snotes in that browser (the + `snotes_auth` cookie is reused). On LAN no auth is needed. +- `dist/` is gitignored; committed artifact lives in `static/` instead. diff --git a/browser-extension/background.js b/browser-extension/background.js new file mode 100644 index 0000000..b3dba90 --- /dev/null +++ b/browser-extension/background.js @@ -0,0 +1,57 @@ +let serverUrl = ''; + +browser.storage.sync.get('serverUrl').then(r => { + if (r.serverUrl) serverUrl = r.serverUrl.replace(/\/+$/, ''); +}); + +browser.storage.onChanged.addListener(changes => { + if (changes.serverUrl) { + serverUrl = changes.serverUrl.newValue.replace(/\/+$/, ''); + } +}); + +browser.runtime.onMessage.addListener((msg, sender) => { + switch (msg.kind) { + case 'list_users': + return listUsers(); + case 'save_note': + return saveNote(msg); + default: + return Promise.reject(new Error('Unknown message kind')); + } +}); + +async function request(path, opts) { + if (!serverUrl) throw new Error('Server URL not configured'); + opts = opts || {}; + opts.credentials = 'include'; + const res = await fetch(serverUrl + path, opts); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.detail || 'HTTP ' + res.status); + } + return res.json(); +} + +async function listUsers() { + const users = await request('/api/users', { headers: { 'Accept': 'application/json' } }); + return { users: users }; +} + +async function saveNote(msg) { + const body = { body: msg.body }; + if (msg.username) body.username = msg.username; + + if (msg.id) { + return request('/api/notes/' + msg.id, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + } + return request('/api/notes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} diff --git a/browser-extension/content.js b/browser-extension/content.js new file mode 100644 index 0000000..f4ff8af --- /dev/null +++ b/browser-extension/content.js @@ -0,0 +1,37 @@ +(function () { + 'use strict'; + + // ── Page→Extension messaging (used by the snotes helper page) ───────────── + window.addEventListener('message', function (event) { + if (event.source !== window) return; + var msg = event.data; + if (!msg || typeof msg.kind !== 'string') return; + + switch (msg.kind) { + case 'snotes-ping': + window.postMessage({ kind: 'snotes-pong', version: '1.0.0' }, '*'); + break; + case 'snotes-settings': { + var data = {}; + if (msg.serverUrl !== undefined) data.serverUrl = msg.serverUrl; + if (Object.keys(data).length > 0) browser.storage.sync.set(data); + window.postMessage({ kind: 'snotes-settings-ack', ok: true }, '*'); + break; + } + } + }); + + // ── Extension→Page messaging (capture context for the popup) ────────────── + browser.runtime.onMessage.addListener(function (msg) { + if (msg && msg.kind === 'get_context') { + var selection = ''; + try { selection = window.getSelection().toString().trim(); } catch (e) {} + return Promise.resolve({ + selection: selection, + title: document.title || '', + url: window.location.href || '', + }); + } + return Promise.resolve(undefined); + }); +})(); diff --git a/browser-extension/icons/icon-48.png b/browser-extension/icons/icon-48.png new file mode 100644 index 0000000..87030d4 Binary files /dev/null and b/browser-extension/icons/icon-48.png differ diff --git a/browser-extension/icons/icon-96.png b/browser-extension/icons/icon-96.png new file mode 100644 index 0000000..71e4bd3 Binary files /dev/null and b/browser-extension/icons/icon-96.png differ diff --git a/browser-extension/manifest.json b/browser-extension/manifest.json new file mode 100644 index 0000000..8d6de61 --- /dev/null +++ b/browser-extension/manifest.json @@ -0,0 +1,55 @@ +{ + "manifest_version": 3, + "name": "quickSnote", + "version": "1.0.0", + "description": "Quick-capture a note to snotes from any page, with the selected text and page link ready to use", + "permissions": [ + "storage" + ], + "content_security_policy": { + "extension_pages": "default-src 'self'; script-src 'self'; style-src 'self'; connect-src http://* https://*" + }, + "host_permissions": [ + "http://*/*", + "https://*/*" + ], + "background": { + "scripts": [ + "background.js" + ] + }, + "options_ui": { + "page": "options.html", + "open_in_tab": true + }, + "content_scripts": [ + { + "matches": [ + "*://*/*" + ], + "js": [ + "content.js" + ], + "all_frames": true, + "run_at": "document_idle" + } + ], + "browser_specific_settings": { + "gecko": { + "id": "snotes-capture@schmeeve.com", + "strict_min_version": "109.0" + } + }, + "action": { + "default_title": "quickSnote", + "default_popup": "popup.html", + "default_icon": { + "48": "icons/icon-48.png", + "96": "icons/icon-96.png" + } + }, + "icons": { + "48": "icons/icon-48.png", + "96": "icons/icon-96.png" + } +} diff --git a/browser-extension/options.html b/browser-extension/options.html new file mode 100644 index 0000000..232c3a0 --- /dev/null +++ b/browser-extension/options.html @@ -0,0 +1,47 @@ + + +
+ + +#tag anywhere in your note to tag it. Tags are normalized to lowercase and shown in the sidebar, search, and note list.' }),
+
+ el("h2", { text: "Lists" }),
+ el("p", { html: 'Type - at the start of a line for a bullet list, or 1. for a numbered list. You can also use the • List and 1. List toolbar buttons.' }),
+
+ el("h2", { text: "Editing" }),
+ el("p", { html: 'Use the toolbar to format text with bold, italic, headings, quotes, code, and horizontal rules. The editor also accepts standard Markdown shortcuts as you type.' }),
+
+ el("h2", { text: "Notes & sharing" }),
+ el("p", { html: 'Notes are plain Markdown files. A note lives in a space (a user), chosen with the dropdown at the top of the editor. Changing the space moves the note to that user.' }),
+ el("p", { html: 'Use Attach to upload a file and insert a link to it. History shows every change, and Delete soft-deletes a note.' }),
+ ]),
+ ]);
+ root.appendChild(wrap);
+}
+
+function helpRow(keys, action) {
+ return el("tr", {}, [el("td", { html: "" + keys + "" }), el("td", { text: action })]);
+}
+
+function showTools() {
+ state.view = "tools";
+ root.innerHTML = "";
+ state.editor?.destroy(); state.editor = null;
+
+ const back = el("button", { class: "btn ghost", text: "← Back", onclick: showHome });
+
+ const extStatus = el("div", { class: "muted", text: "" });
+ const installBtn = el("button", { class: "btn primary", text: "Install Browser Extension" });
+ const qsStatus = el("span", { class: "muted", text: "" });
+ const qsApply = el("button", { class: "btn", text: "Apply to Extension" });
+
+ const card = el("div", { class: "help-card" }, [
+ el("h1", { text: "Quick capture" }),
+ el("p", { html: 'The quickSnote browser extension lets you jot a note from any page. It prefills the note with the text you\'ve selected, and a Page button inserts the current page\'s title and link.' }),
+
+ el("h2", { text: "Firefox" }),
+ el("div", { class: "tools-row" }, [installBtn, extStatus]),
+
+ el("h2", { text: "Quick settings" }),
+ el("p", { text: "Point the extension at this snotes server (" + window.location.origin + ")." }),
+ el("div", { class: "tools-row" }, [qsApply, qsStatus]),
+
+ el("h2", { text: "Manual install" }),
+ el("p", { text: "Open about:debugging#/runtime/this-firefox → Load Temporary Add-on… → select browser-extension/manifest.json." }),
+
+ el("h2", { text: "Using it" }),
+ el("p", { html: 'Open the toolbar icon, pick a user, type, and Save — or just stop typing for 8s and it autosaves.' }),
+ ]);
+
+ const wrap = el("div", { class: "editor-wrap" }, [
+ el("div", { class: "editor-topbar" }, [back]),
+ card,
+ ]);
+ root.appendChild(wrap);
+
+ // ── Extension detection (ping/pong) ──────────────────────────────────────
+ const xpiUrl = "/static/snotes-capture-firefox.xpi";
+ let detected = false;
+ let pingCount = 0;
+ let pingInterval = null;
+ let slowInterval = null;
+
+ function stopPing() {
+ if (pingInterval) clearInterval(pingInterval), pingInterval = null;
+ if (slowInterval) clearInterval(slowInterval), slowInterval = null;
+ }
+
+ function onDetected() {
+ detected = true;
+ stopPing();
+ extStatus.textContent = "✓ Extension detected";
+ installBtn.textContent = "Extension installed";
+ }
+
+ function doPing() {
+ window.postMessage({ kind: "snotes-ping" }, "*");
+ pingCount++;
+ if (pingCount >= 8) {
+ if (pingInterval) clearInterval(pingInterval), pingInterval = null;
+ if (!detected && !slowInterval) {
+ extStatus.textContent = "Extension not detected";
+ slowInterval = setInterval(() => window.postMessage({ kind: "snotes-ping" }, "*"), 2000);
+ }
+ }
+ }
+
+ installBtn.addEventListener("click", () => {
+ if (detected) return;
+ if (window.InstallTrigger) InstallTrigger.install({ "quickSnote": xpiUrl });
+ else window.location.href = xpiUrl;
+ extStatus.textContent = "Installing… check toolbar for prompt";
+ stopPing();
+ pingCount = 0;
+ pingInterval = setInterval(doPing, 500);
+ });
+
+ qsApply.addEventListener("click", () => {
+ window.postMessage({ kind: "snotes-settings", serverUrl: window.location.origin }, "*");
+ qsStatus.textContent = "Applying…";
+ setTimeout(() => { if (qsStatus.textContent === "Applying…") qsStatus.textContent = "No response — is the extension installed?"; }, 5000);
+ });
+
+ window.addEventListener("message", (event) => {
+ if (event.source !== window) return;
+ const msg = event.data;
+ if (msg && msg.kind === "snotes-pong") onDetected();
+ if (msg && msg.kind === "snotes-settings-ack") {
+ qsStatus.textContent = "✓ Applied";
+ setTimeout(() => { if (qsStatus.textContent === "✓ Applied") qsStatus.textContent = ""; }, 3000);
+ }
+ });
+
+ pingInterval = setInterval(doPing, 500);
+ doPing();
+}
+
async function showHome() {
state.view = "home";
root.innerHTML = "";
@@ -341,6 +480,11 @@ function renderEditor(body, owner) {
]);
const editorArea = el("div", { class: "editor" });
const status = el("div", { class: "status", text: "" });
+ const hint = el("div", { class: "editor-hint" }, [
+ el("p", { html: 'Tags: type #tag anywhere in your note to add a tag.' }),
+ el("p", { html: 'Lists: type - for a bullet list or 1. for a numbered list, or use the toolbar buttons.' }),
+ el("p", { html: 'Shortcuts: Ctrl/Cmd+S save · Ctrl/Cmd+Shift+S save & new note.' }),
+ ]);
const fileInput = el("input", { type: "file", style: "display:none" });
fileInput.addEventListener("change", () => uploadAttachment(fileInput));
@@ -350,6 +494,7 @@ function renderEditor(body, owner) {
el("div", { class: "editor-topbar" }, [back, spaceSel, el("div", { class: "spacer" }), attach, hist, del, save]),
toolbar,
editorArea,
+ hint,
status,
]);
wrap.appendChild(fileInput);
@@ -387,7 +532,7 @@ function runToolbar(action) {
cmds[action]?.();
}
-async function saveNote() {
+async function persistNote() {
const html = state.editor.getHTML();
const body = turndownService.turndown(html);
const status = document.querySelector(".status");
@@ -404,6 +549,17 @@ async function saveNote() {
} catch (e) { status.textContent = "Error: " + e.message; }
}
+async function saveNote() {
+ const isNew = !state.noteId;
+ await persistNote();
+ if (isNew) showHome();
+}
+
+async function saveAndNewNote() {
+ await persistNote();
+ openNote(null);
+}
+
async function deleteNote() {
if (!confirm("Delete this note?")) return;
await api("DELETE", "/api/notes/" + state.noteId);
@@ -451,4 +607,22 @@ async function showHistory() {
root.appendChild(overlay);
}
+document.addEventListener("keydown", (e) => {
+ const mod = e.ctrlKey || e.metaKey;
+ if (state.view === "home") {
+ if (e.key === "n" && !mod) {
+ const active = document.activeElement;
+ if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA" || active.isContentEditable)) return;
+ e.preventDefault();
+ openNote(null);
+ }
+ } else if (state.view === "editor") {
+ if (mod && e.key.toLowerCase() === "s") {
+ e.preventDefault();
+ if (e.shiftKey) saveAndNewNote();
+ else saveNote();
+ }
+ }
+});
+
boot();