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
131 lines
4.8 KiB
TypeScript
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>`;
|