SimpleDocReview / sdr-api-shim.js
Eric Putnam
Claude Opus 5 (1M context)
feat: deploy simpledocreview as a browser-only static space
3d968d9
Raw History Blame Contribute Delete
25.5 kB
/**
* sdr-api-shim.js - wires sdr-api-core.js into the page in place of app.py.
*
* A Static Space serves files and nothing else, so the `http://127.0.0.1:4173`
* server upstream's `static/app.js` talks to does not exist. This file answers
* those requests in the browser:
*
* 1. an IndexedDB store standing in for data/reviews.json and uploads/,
* 2. an XMLHttpRequest (and fetch) interceptor for every /api/ path,
* 3. a click interceptor for the Download button, which upstream implements
* as a navigation rather than a request,
* 4. a one-line notice, because on a public URL "your documents stay in this
* browser" is not what a visitor would assume.
*
* Nothing here reimplements review logic - that all lives in sdr-api-core.js.
* This file is only the environment.
*
* Loaded by a <script> tag that scripts/build-static.ps1 inserts ahead of
* upstream's static/app.js. Upstream's files are never edited.
*/
(function () {
"use strict";
if (typeof window === "undefined" || window.__sdrApiShimInstalled) return;
window.__sdrApiShimInstalled = true;
var Core = window.SdrApiCore;
if (!Core) {
console.error("[SimpleDocReview] sdr-api-core.js did not load; the app cannot run.");
return;
}
var DB_NAME = "simpledocreview";
var DB_VERSION = 1;
var RECORD_STORE = "records";
var UPLOAD_STORE = "uploads";
var RECORD_KEY = "db";
// ------------------------------------------------------------------- store
function openDatabase() {
return new Promise(function (resolve, reject) {
if (!window.indexedDB) {
reject(new Error("IndexedDB is unavailable."));
return;
}
var request = window.indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = function () {
var database = request.result;
if (!database.objectStoreNames.contains(RECORD_STORE)) {
database.createObjectStore(RECORD_STORE);
}
if (!database.objectStoreNames.contains(UPLOAD_STORE)) {
database.createObjectStore(UPLOAD_STORE);
}
};
request.onsuccess = function () { resolve(request.result); };
request.onerror = function () { reject(request.error || new Error("IndexedDB open failed.")); };
request.onblocked = function () { reject(new Error("IndexedDB is blocked by another tab.")); };
});
}
function transact(storeName, mode, work) {
return openDatabase().then(function (database) {
return new Promise(function (resolve, reject) {
var transaction = database.transaction(storeName, mode);
var request = work(transaction.objectStore(storeName));
transaction.oncomplete = function () {
database.close();
resolve(request ? request.result : undefined);
};
transaction.onerror = function () {
database.close();
reject(transaction.error || new Error("IndexedDB transaction failed."));
};
transaction.onabort = function () {
database.close();
reject(transaction.error || new Error("IndexedDB transaction aborted."));
};
});
});
}
var indexedDbStore = {
load: function () {
return transact(RECORD_STORE, "readonly", function (store) {
return store.get(RECORD_KEY);
}).then(function (value) {
return value || null;
});
},
save: function (data) {
return transact(RECORD_STORE, "readwrite", function (store) {
return store.put(data, RECORD_KEY);
});
},
putUpload: function (name, bytes) {
return transact(UPLOAD_STORE, "readwrite", function (store) {
return store.put(bytes, name);
});
},
getUpload: function (name) {
if (!name) return Promise.resolve(null);
return transact(UPLOAD_STORE, "readonly", function (store) {
return store.get(name);
}).then(function (value) {
if (!value) return null;
return value instanceof Uint8Array ? value : new Uint8Array(value);
});
},
clear: function () {
return transact(RECORD_STORE, "readwrite", function (store) {
return store.clear();
}).then(function () {
return transact(UPLOAD_STORE, "readwrite", function (store) {
return store.clear();
});
});
}
};
/**
* Fallback for browsers that refuse storage.
*
* The Space renders inside an iframe on huggingface.co, and some browsers
* partition or block storage for embedded third-party origins. Losing
* persistence is better than a dead page, so the app still works for the
* length of the visit and the notice says so.
*/
function createMemoryStore() {
var records = null;
var uploads = {};
return {
volatile: true,
load: function () { return Promise.resolve(records); },
save: function (data) { records = data; return Promise.resolve(); },
putUpload: function (name, bytes) { uploads[name] = bytes; return Promise.resolve(); },
getUpload: function (name) { return Promise.resolve(uploads[name] || null); },
clear: function () { records = null; uploads = {}; return Promise.resolve(); }
};
}
/**
* Probe storage once, then keep using whichever backend answered.
*
* The probe result is cached rather than retried per request, so a page that
* starts in memory stays consistent instead of writing half its records to
* one backend and half to the other.
*/
var activeStore = null;
var storeProbe = null;
function resolveStore() {
if (activeStore) return Promise.resolve(activeStore);
if (!storeProbe) {
storeProbe = indexedDbStore
.load()
.then(function () {
activeStore = indexedDbStore;
return activeStore;
})
.catch(function (error) {
console.warn(
"[SimpleDocReview] IndexedDB unavailable (" + error.message + "); " +
"documents will be kept in memory for this visit only."
);
activeStore = createMemoryStore();
return activeStore;
});
}
return storeProbe;
}
var store = {
load: function () { return resolveStore().then(function (s) { return s.load(); }); },
save: function (data) { return resolveStore().then(function (s) { return s.save(data); }); },
putUpload: function (n, b) { return resolveStore().then(function (s) { return s.putUpload(n, b); }); },
getUpload: function (n) { return resolveStore().then(function (s) { return s.getUpload(n); }); },
clear: function () { return resolveStore().then(function (s) { return s.clear(); }); }
};
var api = Core.createApi({ store: store });
// ------------------------------------------------------------ request body
function apiError(message, status) {
var error = new Error(message);
error.apiStatus = status || 400;
return error;
}
/**
* Turn an XHR/fetch body into what the core API expects.
*
* Upload is the only interesting case: upstream posts multipart/form-data,
* which app.py parses back apart. Here the FormData object is already
* structured, so the parse step is just reading the parts out - the file as
* bytes, everything else as a text field.
*/
function normalizeBody(body) {
if (body === null || body === undefined) return Promise.resolve(null);
if (typeof body === "string") return Promise.resolve(body);
if (typeof FormData !== "undefined" && body instanceof FormData) {
var file = body.get("file");
if (!file || typeof file === "string") {
return Promise.reject(apiError("No file part was found in the upload.", 400));
}
var fields = {};
body.forEach(function (value, key) {
if (typeof value === "string") fields[key] = value.trim();
});
return file.arrayBuffer().then(function (buffer) {
return {
filename: file.name,
bytes: new Uint8Array(buffer),
fields: fields
};
});
}
if (typeof Blob !== "undefined" && body instanceof Blob) return body.text();
if (body instanceof ArrayBuffer) return Promise.resolve(new TextDecoder().decode(body));
if (body && body.buffer instanceof ArrayBuffer) {
return Promise.resolve(new TextDecoder().decode(body));
}
return Promise.resolve(String(body));
}
function toPathname(url) {
try {
return new URL(String(url), window.location.href).pathname;
} catch (error) {
return String(url);
}
}
function isApiUrl(url) {
return api.isApiPath(toPathname(url));
}
// ----------------------------------------------------- selected document id
/**
* The id of the document currently on screen.
*
* Upstream's Download button navigates to /api/documents/<id>/download rather
* than requesting it, and a page cannot cancel an assignment to
* window.location.href. The click is intercepted instead (see below), which
* means the id has to come from somewhere other than the event.
*
* Every render of the document panel is driven by a response for that
* document - selectDocument() and refreshSelected() both fetch it by id, and
* the reviewer actions post to it - so the last /api/documents/<id> path to
* pass through here names what the panel is showing.
*/
var lastDocumentId = null;
function rememberDocumentId(pathname) {
var match = /^\/api\/documents\/([^/]+)/.exec(pathname);
if (match && match[1] !== "download") lastDocumentId = decodeURIComponent(match[1]);
}
function dispatchToCore(method, url, body) {
var pathname = toPathname(url);
rememberDocumentId(pathname);
return normalizeBody(body).then(function (payload) {
return api.handle(method, pathname, payload);
});
}
// ------------------------------------------------- XMLHttpRequest override
var RealXMLHttpRequest = window.XMLHttpRequest;
var SIMULATED_PROPERTIES = {
readyState: true,
status: true,
statusText: true,
response: true,
responseText: true,
responseURL: true
};
var HANDLER_PROPERTIES = {
onreadystatechange: true,
onload: true,
onloadstart: true,
onloadend: true,
onerror: true,
onabort: true,
ontimeout: true,
onprogress: true
};
/**
* An XMLHttpRequest that answers /api/ locally and forwards everything else.
*
* A Proxy over a real XMLHttpRequest rather than a hand-written stand-in: any
* member this shim does not simulate still reaches the genuine object, so a
* future upstream change that uses more of the XHR surface keeps working
* instead of hitting a gap in a fake.
*/
function createInterceptingXhr() {
var real = new RealXMLHttpRequest();
var state = {
intercepted: false,
method: "GET",
url: "",
overrides: {},
handlers: {},
listeners: {},
sent: false
};
var proxy;
function dispatch(type) {
var event = {
type: type,
target: proxy,
currentTarget: proxy,
lengthComputable: false,
loaded: 0,
total: 0
};
var handler = state.handlers["on" + type];
if (typeof handler === "function") handler.call(proxy, event);
(state.listeners[type] || []).forEach(function (listener) {
if (typeof listener === "function") listener.call(proxy, event);
else if (listener && typeof listener.handleEvent === "function") listener.handleEvent(event);
});
}
function settle(response) {
state.overrides.readyState = 4;
state.overrides.status = response.status;
state.overrides.statusText = response.status === 200 ? "OK" : "";
state.overrides.responseURL = new URL(state.url, window.location.href).href;
// A download reply carries bytes, not text. Upstream never reads one
// through XHR (it navigates instead), so the body is left empty rather
// than lossily decoded.
state.overrides.responseText = response.body || "";
state.overrides.response = response.body || "";
state.overrides.__headers = response.headers || {};
state.overrides.__bytes = response.bytes || null;
dispatch("readystatechange");
dispatch("load");
dispatch("loadend");
}
function failed(error) {
if (error && error.apiStatus) {
settle({
status: error.apiStatus,
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({ error: error.message })
});
return;
}
console.error("[SimpleDocReview] local API failed:", error);
state.overrides.readyState = 4;
state.overrides.status = 0;
state.overrides.responseText = "";
state.overrides.response = "";
dispatch("readystatechange");
dispatch("error");
dispatch("loadend");
}
var members = {
open: function (method, url) {
state.method = String(method || "GET");
state.url = String(url);
state.intercepted = isApiUrl(state.url);
state.overrides = {};
state.sent = false;
if (state.intercepted) {
state.overrides.readyState = 1;
dispatch("readystatechange");
return undefined;
}
return real.open.apply(real, arguments);
},
send: function (body) {
if (!state.intercepted) return real.send.apply(real, arguments);
if (state.sent) throw new Error("InvalidStateError: send() already called.");
state.sent = true;
dispatch("loadstart");
dispatchToCore(state.method, state.url, body).then(settle, failed);
return undefined;
},
setRequestHeader: function () {
if (state.intercepted) return undefined;
return real.setRequestHeader.apply(real, arguments);
},
getResponseHeader: function (name) {
if (!state.intercepted) return real.getResponseHeader.apply(real, arguments);
var headers = state.overrides.__headers || {};
var wanted = String(name).toLowerCase();
var found = Object.keys(headers).filter(function (key) {
return key.toLowerCase() === wanted;
})[0];
return found ? headers[found] : null;
},
getAllResponseHeaders: function () {
if (!state.intercepted) return real.getAllResponseHeaders.apply(real, arguments);
var headers = state.overrides.__headers || {};
return Object.keys(headers)
.map(function (key) { return key.toLowerCase() + ": " + headers[key] + "\r\n"; })
.join("");
},
abort: function () {
if (!state.intercepted) return real.abort.apply(real, arguments);
// The work is already running locally and has nothing to cancel; report
// the aborted state so callers waiting on it are not left hanging.
state.overrides.readyState = 4;
state.overrides.status = 0;
dispatch("readystatechange");
dispatch("abort");
dispatch("loadend");
return undefined;
},
addEventListener: function (type, listener) {
if (!state.listeners[type]) state.listeners[type] = [];
state.listeners[type].push(listener);
return real.addEventListener.apply(real, arguments);
},
removeEventListener: function (type, listener) {
state.listeners[type] = (state.listeners[type] || []).filter(function (item) {
return item !== listener;
});
return real.removeEventListener.apply(real, arguments);
}
};
proxy = new Proxy(real, {
get: function (target, property) {
if (Object.prototype.hasOwnProperty.call(members, property)) return members[property];
if (Object.prototype.hasOwnProperty.call(HANDLER_PROPERTIES, property)) {
return state.handlers[property] === undefined ? null : state.handlers[property];
}
if (state.intercepted && Object.prototype.hasOwnProperty.call(SIMULATED_PROPERTIES, property)) {
var override = state.overrides[property];
if (override !== undefined) return override;
if (property === "readyState" || property === "status") return 0;
return "";
}
var value = target[property];
return typeof value === "function" ? value.bind(target) : value;
},
set: function (target, property, value) {
if (Object.prototype.hasOwnProperty.call(HANDLER_PROPERTIES, property)) {
state.handlers[property] = value;
return true;
}
target[property] = value;
return true;
}
});
return proxy;
}
function ShimXMLHttpRequest() {
return createInterceptingXhr();
}
ShimXMLHttpRequest.prototype = RealXMLHttpRequest.prototype;
["UNSENT", "OPENED", "HEADERS_RECEIVED", "LOADING", "DONE"].forEach(function (name) {
ShimXMLHttpRequest[name] = RealXMLHttpRequest[name];
});
window.XMLHttpRequest = ShimXMLHttpRequest;
// ------------------------------------------------------------ fetch override
// Upstream uses XHR throughout. This covers /api/ requests made with fetch so
// that a future upstream rewrite does not silently start 404ing.
if (typeof window.fetch === "function") {
var realFetch = window.fetch.bind(window);
window.fetch = function (input, init) {
var url = typeof input === "string"
? input
: input instanceof URL ? input.toString() : input && input.url;
if (!url || !isApiUrl(url)) return realFetch(input, init);
var method = ((init && init.method) || (input && input.method) || "GET").toUpperCase();
var body = init ? init.body : null;
return dispatchToCore(method, url, body).then(function (response) {
var payload = response.bytes ? response.bytes : response.body;
return new Response(payload, { status: response.status, headers: response.headers });
}, function (error) {
if (error && error.apiStatus) {
return new Response(JSON.stringify({ error: error.message }), {
status: error.apiStatus,
headers: { "Content-Type": "application/json; charset=utf-8" }
});
}
throw error;
});
};
}
// --------------------------------------------------------------- downloads
/**
* Serve the Download button from IndexedDB.
*
* upstream: els.downloadButton.onclick = () => {
* window.location.href = `/api/documents/${document.id}/download`;
* }
*
* A navigation cannot be intercepted, so the click is caught in the capture
* phase at the document level - before the target phase, which is what stops
* upstream's own handler from running - and the file is handed over as a blob
* URL instead.
*/
document.addEventListener(
"click",
function (event) {
var target = event.target;
if (!target || typeof target.closest !== "function") return;
if (!target.closest("#downloadButton")) return;
event.preventDefault();
event.stopPropagation();
if (typeof event.stopImmediatePropagation === "function") event.stopImmediatePropagation();
if (!lastDocumentId) {
console.warn("[SimpleDocReview] No document is selected, so there is nothing to download.");
return;
}
api.handle("GET", "/api/documents/" + encodeURIComponent(lastDocumentId) + "/download", null)
.then(function (response) {
if (response.status !== 200 || !response.bytes) {
var message = "The original upload could not be found.";
try {
message = JSON.parse(response.body).error || message;
} catch (error) { /* keep the default */ }
console.warn("[SimpleDocReview] " + message);
return;
}
var blob = new Blob([response.bytes], {
type: response.headers["Content-Type"] || "application/octet-stream"
});
var url = URL.createObjectURL(blob);
var link = document.createElement("a");
link.href = url;
link.download = response.filename || "document";
link.rel = "noopener";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Revoke on a later turn: Firefox cancels an in-flight download if the
// object URL disappears in the same task as the click.
setTimeout(function () { URL.revokeObjectURL(url); }, 30000);
})
.catch(function (error) {
console.error("[SimpleDocReview] Download failed:", error);
});
},
true
);
// ------------------------------------------------------------------ notice
var UPSTREAM_URL = "https://github.com/eputnam77/SimpleDocReview";
/**
* Say what this is.
*
* Upstream's README can leave storage unexplained, because you started the
* server yourself and the files are on your disk. A public URL cannot: a
* visitor's default assumption is a shared service that keeps their records,
* and this deployment keeps nothing. Somebody who ran a real approval round
* here and then cleared their browser would lose it with no warning.
*
* So the page says so twice: a badge that never goes away, and one notice
* explaining where the data lives. Both are injected rather than edited into
* upstream's markup, and both are styled inline so they do not depend on
* upstream's stylesheet.
*/
function addDemoBadge() {
var heading = document.querySelector(".brand h1");
if (!heading || heading.querySelector(".sdr-demo-badge")) return;
var badge = document.createElement("span");
badge.className = "sdr-demo-badge";
badge.textContent = "Demo";
badge.title = "Runs entirely in your browser. Records are not stored on a server.";
// Upstream's palette tokens, so the badge retints with the app rather than
// sitting on it in a colour of its own.
badge.style.cssText = [
"margin-left:8px", "vertical-align:middle", "white-space:nowrap",
"padding:2px 6px", "border-radius:5px",
"border:1px solid var(--line, #d9e1e4)",
"color:var(--muted, #64737c)",
"font-size:.6em", "font-weight:700",
"letter-spacing:.08em", "text-transform:uppercase"
].join(";");
heading.appendChild(badge);
var dot = document.querySelector(".brand .live-dot");
if (dot) dot.title = "Demo build - no server";
}
function showStorageNotice() {
if (window.sessionStorage && window.sessionStorage.getItem("sdrNoticeDismissed")) return;
// Borrowed from upstream's own .toast rule so the notice reads as part of
// the app: same ink, same radius, same shadow, same right margin. It sits
// one toast-height higher so an actual toast still has its usual corner.
var bar = document.createElement("aside");
bar.setAttribute("role", "note");
bar.style.cssText = [
"position:fixed", "right:22px", "bottom:84px", "z-index:60",
"max-width:min(460px, calc(100vw - 44px))",
"display:flex", "gap:12px", "align-items:flex-start",
"padding:12px 14px", "border-radius:8px",
"background:#172126", "color:#fff",
"box-shadow:0 18px 45px rgba(23, 33, 38, 0.28)",
"font-size:13px", "line-height:1.5"
].join(";");
var text = document.createElement("p");
text.style.cssText = "margin:0";
text.innerHTML =
"<strong>Demo, not a review system.</strong> " +
(activeStore && activeStore.volatile
? "This browser blocks storage for embedded pages, so anything you add lasts only until you close the tab. "
: "Documents and review records live only in this browser, and clearing site data deletes them. ") +
'Nothing is sent to a server. For real reviews, <a href="' + UPSTREAM_URL +
'" target="_blank" rel="noopener" style="color:inherit;text-decoration:underline">run it locally</a>.';
var dismiss = document.createElement("button");
dismiss.type = "button";
dismiss.textContent = "Got it";
dismiss.style.cssText = [
"flex:0 0 auto", "cursor:pointer", "border-radius:6px", "border:0",
"background:rgba(255,255,255,0.14)", "color:inherit",
"padding:5px 10px", "font-size:inherit", "font-weight:600"
].join(";");
dismiss.addEventListener("click", function () {
bar.remove();
if (window.sessionStorage) window.sessionStorage.setItem("sdrNoticeDismissed", "1");
});
bar.appendChild(text);
bar.appendChild(dismiss);
document.body.appendChild(bar);
}
// Wait for the store probe so the notice can tell the truth about persistence.
function whenReady(callback) {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", callback, { once: true });
} else {
callback();
}
}
whenReady(function () {
addDemoBadge();
resolveStore().then(showStorageNotice, showStorageNotice);
});
// ------------------------------------------------------------------ console
/**
* Debug surface. `__sdrStaticApi.reset()` clears the workspace, which is the
* only way to start over other than clearing site data in the browser.
*/
window.__sdrStaticApi = {
api: api,
store: store,
reset: function () {
return store.clear().then(function () {
window.location.reload();
});
}
};
})();