Files
stash/extension/background.js
Fredrik Johansson f2ebfe35d4 Initial build: stash server + browser extension
Server (Express + better-sqlite3 + FTS5, mirroring keep's stack):
/api/capture (upsert-on-url, per PROPOSAL.md's dedup decision),
/api/articles (list/search/mark-read/delete), plus a minimal
server-rendered reading list and reader view at / and /read/:id
(token passed as ?t= there since browser navigation can't set an
Authorization header). Bearer-token auth generated via `npm run
token` — no login flow, matching keep's one-time identity
registration pattern.

Extension (Manifest V3, sideloaded, no store distribution): toolbar
click or context menu "Send to stash" runs @mozilla/readability
(vendored into extension/lib/) against the current page, falling back
to raw rendered HTML when extraction comes back too thin. Options
page for one-time server URL + token entry.

Verified end-to-end with the real extension loaded in a live Chromium
instance (not just unit-tested extraction logic): capture against a
real page, dedup-on-recapture, mark-read, FTS5 search, 401 on missing
auth, and both server-rendered views all confirmed working.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:23:01 +02:00

52 lines
1.6 KiB
JavaScript

chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.create({
id: 'send-to-stash',
title: 'Send to stash',
contexts: ['page'],
});
});
chrome.contextMenus.onClicked.addListener((info, tab) => {
if (info.menuItemId === 'send-to-stash' && tab?.id) capture(tab.id);
});
chrome.action.onClicked.addListener((tab) => {
if (tab?.id) capture(tab.id);
});
async function capture(tabId) {
try {
const [{ result }] = await chrome.scripting.executeScript({
target: { tabId },
files: ['lib/Readability.js', 'extract.js'],
});
if (!result) throw new Error('extraction produced nothing');
const { serverUrl, token } = await chrome.storage.local.get(['serverUrl', 'token']);
if (!serverUrl || !token) {
notify('stash: not configured', 'Set the server URL and token in the extension options.');
chrome.runtime.openOptionsPage();
return;
}
const res = await fetch(`${serverUrl.replace(/\/$/, '')}/api/capture`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(result),
});
if (!res.ok) throw new Error(`server responded ${res.status}`);
notify('Sent to stash', result.title);
} catch (e) {
notify('stash capture failed', String(e.message ?? e));
}
}
function notify(title, message) {
chrome.notifications.create({
type: 'basic',
iconUrl: 'data:image/svg+xml;base64,' + btoa('<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><rect width="48" height="48" rx="8" fill="#0a5"/></svg>'),
title,
message,
});
}