Files
captoys/diigo-cache/src/cache-page.ts
T
dckc 2a1942fc2a fixup! diigo-cache: invalidate the last N days of cached pages
Docs per the docs-files-api skill: a 1–2 line @file; what a declaration is or does in JSDoc on it, not in // comments or the file header.

Generated-by: Claude Opus 5.5
2026-10-07 09:34:06 -05:00

131 lines
4.8 KiB
TypeScript

/** @file the webkey page at /cache/: a skeleton the key's holder drives over /api. */
/**
* the webkey page, identical for every key (docs/webkeys.md): the key
* rides the URL fragment, which browsers never send to the server or in a
* Referer. Its script reads it, trades it for a capability
* (`api.cache(key)`, Cap'n Web over /api, in-band), and drives the fill
* job. Each round trip is its own HTTP batch session.
*
* Without a fragment it is a login form a password manager fills
* (petname as username, cache id as password). The inputs have no
* `name`, so a native submit (script failed) sends nothing; the key stays
* in memory, out of the address bar and history.
*/
export const CACHE_PAGE_HTML = `<!doctype html>
<html><head><meta charset="utf-8"><meta name="referrer" content="no-referrer">
<title>diigo cache</title></head>
<body>
<h1>diigo cache <code id="fingerprint"></code></h1>
<p id="problem" hidden></p>
<form id="login" hidden>
<p><label>petname <input id="login-petname" autocomplete="username" size="24"></label></p>
<p><label>cache id <input id="login-key" type="password" autocomplete="current-password" size="24" required></label></p>
<p><button type="submit">open</button></p>
</form>
<section id="private" hidden>
<h2>private cache</h2>
<p id="private-status">no fill job yet</p>
<button type="button" data-target="private" data-action="start">fill</button>
<button type="button" data-target="private" data-action="stop">stop</button>
<p><label>invalidate the last <input type="number" id="private-days" min="1" max="3650" value="7" size="5"> days</label>
<button type="button" data-target="private" data-action="invalidate">invalidate</button>
<span id="private-invalidated"></span></p>
</section>
<section id="public" hidden>
<h2>public cache</h2>
<p id="public-status">no fill job yet</p>
<button type="button" data-target="public" data-action="start">fill</button>
<button type="button" data-target="public" data-action="stop">stop</button>
<p><label>invalidate the last <input type="number" id="public-days" min="1" max="3650" value="7" size="5"> days</label>
<button type="button" data-target="public" data-action="invalidate">invalidate</button>
<span id="public-invalidated"></span></p>
</section>
<p><a href="/">← home</a></p>
<script type="module">
import { newHttpBatchRpcSession } from "/_capnweb.js";
let key = location.hash.slice(1);
const $ = (id) => document.getElementById(id);
const problem = (text) => {
$("problem").textContent = text;
$("problem").hidden = false;
};
/** a fresh batch session, traded for this key's capability */
const cap = () => newHttpBatchRpcSession("/api").cache(key);
/** the fill-job stub for a target */
const job = (target) => (target === "public" ? cap().publicCache() : cap());
const describe = (s) => {
if (!s) return "no fill job yet";
const of = Math.max(0, s.nextPage - 1) + " of " + (s.pages ?? "?") + " pages";
return {
running: "filling: " + of,
done: "filled: " + of,
stopped: "fill stopped: " + of,
failed: "fill failed at page " + s.nextPage + ": " + (s.error ?? "unknown error"),
}[s.state];
};
let polling = false;
async function refresh(hasPublic) {
const targets = hasPublic ? ["private", "public"] : ["private"];
const statuses = await Promise.all(targets.map((t) => job(t).fillStatus()));
targets.forEach((t, i) => ($(t + "-status").textContent = describe(statuses[i])));
const running = statuses.some((s) => s?.state === "running");
if (running && !polling) {
polling = true;
setTimeout(() => {
polling = false;
refresh(hasPublic).catch(report);
}, 3000);
}
}
function report(err) {
console.error({ event: "cache-page", error: String(err) });
problem(String(err));
}
/** no fragment: ask for the key (the password manager fills it in) */
function login() {
const form = $("login");
form.hidden = false;
form.addEventListener("submit", (event) => {
event.preventDefault();
key = $("login-key").value.trim();
if (!key) return;
form.hidden = true;
main().catch(report);
});
}
async function main() {
if (!key) return login();
const c = cap();
const [fingerprint, hasPublic] = await Promise.all([c.fingerprint(), c.hasPublic()]);
$("fingerprint").textContent = fingerprint;
$("private").hidden = false;
$("public").hidden = !hasPublic;
for (const button of document.querySelectorAll("button[data-target]")) {
button.addEventListener("click", () => {
const { target, action } = button.dataset;
const j = job(target);
if (action === "invalidate") {
const days = Number($(target + "-days").value);
j.invalidateRecent(days)
.then((r) => ($(target + "-invalidated").textContent = "dropped " + r.pages + " cached pages; fill to refetch"))
.catch(report);
return;
}
(action === "start" ? j.startFill() : j.stopFill())
.then(() => refresh(hasPublic))
.catch(report);
});
}
await refresh(hasPublic);
}
main().catch(report);
</script>
</body></html>`;