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>
This commit is contained in:
51
extension/background.js
Normal file
51
extension/background.js
Normal file
@@ -0,0 +1,51 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
31
extension/extract.js
Normal file
31
extension/extract.js
Normal file
@@ -0,0 +1,31 @@
|
||||
// Runs in the page as a content script, after lib/Readability.js has already
|
||||
// been injected (see background.js's executeScript files array — order matters).
|
||||
// Its return value becomes the result of chrome.scripting.executeScript.
|
||||
(() => {
|
||||
try {
|
||||
const docClone = document.cloneNode(true);
|
||||
const article = new Readability(docClone).parse();
|
||||
if (article && article.textContent && article.textContent.trim().length > 200) {
|
||||
return {
|
||||
url: location.href,
|
||||
title: article.title || document.title,
|
||||
content_html: article.content,
|
||||
content_text: article.textContent,
|
||||
author: article.byline || undefined,
|
||||
published_at: article.publishedTime || undefined,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
// fall through to raw capture below
|
||||
}
|
||||
|
||||
// Fallback: Readability either failed or returned too little to be useful
|
||||
// (common on heavily JS-rendered pages) — capture what's actually rendered
|
||||
// rather than failing outright. Worse capture beats no capture.
|
||||
return {
|
||||
url: location.href,
|
||||
title: document.title,
|
||||
content_html: document.body ? document.body.innerHTML : '',
|
||||
content_text: document.body ? document.body.innerText : '',
|
||||
};
|
||||
})();
|
||||
2786
extension/lib/Readability.js
Normal file
2786
extension/lib/Readability.js
Normal file
File diff suppressed because it is too large
Load Diff
15
extension/manifest.json
Normal file
15
extension/manifest.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "stash",
|
||||
"version": "0.1.0",
|
||||
"description": "Send the current page to your self-hosted stash read-later.",
|
||||
"permissions": ["activeTab", "scripting", "storage", "contextMenus", "notifications"],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"action": {
|
||||
"default_title": "Send to stash"
|
||||
},
|
||||
"options_page": "options.html"
|
||||
}
|
||||
27
extension/options.html
Normal file
27
extension/options.html
Normal file
@@ -0,0 +1,27 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>stash settings</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; max-width: 420px; margin: 30px; }
|
||||
label { display: block; margin-top: 14px; font-size: 13px; color: #555; }
|
||||
input { width: 100%; padding: 6px; margin-top: 4px; box-sizing: border-box; }
|
||||
button { margin-top: 16px; padding: 8px 16px; }
|
||||
#status { margin-top: 10px; font-size: 13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>stash settings</h2>
|
||||
<p>Generate a token on the server with <code>npm run token</code>, then paste it below.</p>
|
||||
<label>Server URL
|
||||
<input id="serverUrl" placeholder="https://stash.example.com">
|
||||
</label>
|
||||
<label>Token
|
||||
<input id="token" placeholder="paste the token here">
|
||||
</label>
|
||||
<button id="save">Save</button>
|
||||
<div id="status"></div>
|
||||
<script src="options.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
17
extension/options.js
Normal file
17
extension/options.js
Normal file
@@ -0,0 +1,17 @@
|
||||
const serverUrlEl = document.getElementById('serverUrl');
|
||||
const tokenEl = document.getElementById('token');
|
||||
const statusEl = document.getElementById('status');
|
||||
|
||||
chrome.storage.local.get(['serverUrl', 'token']).then(({ serverUrl, token }) => {
|
||||
if (serverUrl) serverUrlEl.value = serverUrl;
|
||||
if (token) tokenEl.value = token;
|
||||
});
|
||||
|
||||
document.getElementById('save').addEventListener('click', async () => {
|
||||
await chrome.storage.local.set({
|
||||
serverUrl: serverUrlEl.value.trim(),
|
||||
token: tokenEl.value.trim(),
|
||||
});
|
||||
statusEl.textContent = 'Saved.';
|
||||
setTimeout(() => { statusEl.textContent = ''; }, 2000);
|
||||
});
|
||||
Reference in New Issue
Block a user