share links added

This commit is contained in:
Pagwin 2026-06-17 22:58:33 -04:00
parent 9fc9057dfe
commit 742d6cce23
4 changed files with 131 additions and 7 deletions

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
__pycache__ __pycache__
.venv .venv
*.pdf *.pdf
*.db

View file

@ -6,4 +6,11 @@ services:
environment: environment:
- HOST=0.0.0.0 - HOST=0.0.0.0
- PORT=8000 - PORT=8000
# Stored solves live on the mounted volume so they survive restarts.
- DB_PATH=/data/solves.db
volumes:
- solves:/data
restart: unless-stopped restart: unless-stopped
volumes:
solves:

View file

@ -862,7 +862,7 @@
} }
const data = await resp.json(); const data = await resp.json();
if (!resp.ok) {pending.remove(); errBox.textContent = data.error || "Server error"; return;} if (!resp.ok) {pending.remove(); errBox.textContent = data.error || "Server error"; return;}
renderSolution(data, pending); renderSolution(data, pending, token);
} catch (e) {pending.remove(); errBox.textContent = e.message;} } catch (e) {pending.remove(); errBox.textContent = e.message;}
finally {solvingHere = false; activeToken = null; setSolveDisabled(false);} finally {solvingHere = false; activeToken = null; setSolveDisabled(false);}
} }
@ -891,7 +891,7 @@
setInterval(pollStatus, 1000); setInterval(pollStatus, 1000);
pollStatus(); pollStatus();
function renderSolution(s, placeholder) { function renderSolution(s, placeholder, token) {
// Collapse any previously-shown solutions so the new one is the focus. // Collapse any previously-shown solutions so the new one is the focus.
for (const d of document.querySelectorAll("#output details.solution")) d.open = false; for (const d of document.querySelectorAll("#output details.solution")) d.open = false;
@ -900,6 +900,19 @@
details.append(el("summary", {}, details.append(el("summary", {},
`Solution ${n} — ${s.status}, objective ${s.objective_value ?? "—"}`)); `Solution ${n} — ${s.status}, objective ${s.objective_value ?? "—"}`));
const out = details; const out = details;
// A shareable permalink to this stored solve, looked up by its UUID.
if (token) {
const url = location.origin + location.pathname + "?solve=" + encodeURIComponent(token);
out.append(el("p", {}, el("button", {
class: "mini", type: "button",
onclick: (ev) => {
navigator.clipboard?.writeText(url);
ev.target.textContent = "Link copied!";
setTimeout(() => {ev.target.textContent = "Copy share link";}, 1500);
},
}, "Copy share link")));
}
out.append(el("p", { out.append(el("p", {
html: html:
`<b>Status:</b> ${s.status} &nbsp; <b>Objective:</b> ${s.objective_value ?? "—"} &nbsp; ` + `<b>Status:</b> ${s.status} &nbsp; <b>Objective:</b> ${s.objective_value ?? "—"} &nbsp; ` +
@ -1096,6 +1109,27 @@
"brass": 1, "brass": 1,
"electrum": 2 "electrum": 2
}) })
// --- deep link: /?solve=<uuid> loads a previously stored solve ---
async function loadSharedSolve(token) {
const out = document.getElementById("output");
const errBox = document.getElementById("error");
const pending = el("p", {class: "pending"}, "Loading shared solve…");
out.prepend(pending);
try {
const resp = await fetch("/solve/" + encodeURIComponent(token), {cache: "no-store"});
if (resp.status === 404) {
pending.remove();
errBox.textContent = "No stored solve found for that link (it may have been evicted).";
return;
}
const rec = await resp.json();
if (!resp.ok) {pending.remove(); errBox.textContent = rec.error || "Server error"; return;}
renderSolution(rec.solution, pending, rec.token);
} catch (e) {pending.remove(); errBox.textContent = e.message;}
}
const sharedToken = new URLSearchParams(location.search).get("solve");
if (sharedToken) loadSharedSolve(sharedToken);
</script> </script>
</body> </body>

92
main.py
View file

@ -16,10 +16,12 @@ import json
import os import os
import select import select
import socket import socket
import sqlite3
import threading import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from urllib.parse import parse_qs, urlsplit from urllib.parse import parse_qs, unquote, urlsplit
from solve import problem_from_dict, solve, solution_to_dict from solve import problem_from_dict, solve, solution_to_dict
@ -27,6 +29,72 @@ from solve import problem_from_dict, solve, solution_to_dict
# The UI is a single static page served from index.html next to this module. # The UI is a single static page served from index.html next to this module.
INDEX_HTML = (Path(__file__).resolve().parent / "index.html").read_text(encoding="utf-8") INDEX_HTML = (Path(__file__).resolve().parent / "index.html").read_text(encoding="utf-8")
# Completed solves are persisted to SQLite so they can be looked up later by
# their UUID (GET /solve/<uuid>) and shared via /?solve=<uuid> deep links. Point
# DB_PATH at a mounted volume so history survives container restarts/redeploys.
# Old rows are evicted so the DB stays bounded (keep the newest MAX_SOLVES).
DB_PATH = os.environ.get(
"DB_PATH", str(Path(__file__).resolve().parent / "data" / "solves.db"))
MAX_SOLVES = int(os.environ.get("MAX_SOLVES", "500"))
def _db():
conn = sqlite3.connect(DB_PATH, timeout=5.0)
# WAL lets the concurrent /solve/<uuid> readers run alongside a writer.
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
return conn
def init_db():
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
conn = _db()
try:
with conn:
conn.execute(
"CREATE TABLE IF NOT EXISTS solves ("
"token TEXT PRIMARY KEY, ts REAL NOT NULL, status TEXT, "
"problem TEXT NOT NULL, solution TEXT NOT NULL)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_solves_ts ON solves(ts)")
finally:
conn.close()
def store_solve(token, problem, solution):
# token is client-generated; skip blanks. INSERT OR REPLACE means a repeated
# token overwrites (a client could clobber its own/another's row — acceptable
# for this app; switch to a server-issued id if that ever matters).
if not token:
return
conn = _db()
try:
with conn:
conn.execute(
"INSERT OR REPLACE INTO solves (token, ts, status, problem, solution) "
"VALUES (?, ?, ?, ?, ?)",
(token, time.time(), solution.get("status"),
json.dumps(problem), json.dumps(solution)))
conn.execute(
"DELETE FROM solves WHERE token NOT IN "
"(SELECT token FROM solves ORDER BY ts DESC LIMIT ?)",
(MAX_SOLVES,))
finally:
conn.close()
def fetch_solve(token):
conn = _db()
try:
row = conn.execute(
"SELECT token, ts, status, problem, solution FROM solves WHERE token = ?",
(token,)).fetchone()
finally:
conn.close()
if row is None:
return None
return {"token": row[0], "ts": row[1], "status": row[2],
"problem": json.loads(row[3]), "solution": json.loads(row[4])}
# Only one solve may run at a time across all viewers. While it's held, other # Only one solve may run at a time across all viewers. While it's held, other
# clients get an immediate 429 from /solve and disable their button (they learn # clients get an immediate 429 from /solve and disable their button (they learn
# the busy state by polling /status). # the busy state by polling /status).
@ -63,10 +131,18 @@ class Handler(BaseHTTPRequestHandler):
self.wfile.write(body) self.wfile.write(body)
def do_GET(self): def do_GET(self):
if self.path in ("/", "/index.html"): path = urlsplit(self.path).path
if path in ("/", "/index.html"):
self._send(200, INDEX_HTML, "text/html; charset=utf-8") self._send(200, INDEX_HTML, "text/html; charset=utf-8")
elif self.path == "/status": elif path == "/status":
self._send(200, json.dumps({"solving": _solve_lock.locked()}), no_cache=True) self._send(200, json.dumps({"solving": _solve_lock.locked()}), no_cache=True)
elif path.startswith("/solve/"):
token = unquote(path[len("/solve/"):])
rec = fetch_solve(token)
if rec is None:
self._send(404, json.dumps({"error": "not found"}), no_cache=True)
else:
self._send(200, json.dumps(rec), no_cache=True)
else: else:
self._send(404, json.dumps({"error": "not found"})) self._send(404, json.dumps({"error": "not found"}))
@ -99,7 +175,8 @@ class Handler(BaseHTTPRequestHandler):
try: try:
length = int(self.headers.get("Content-Length", 0)) length = int(self.headers.get("Content-Length", 0))
payload = json.loads(self.rfile.read(length) or b"{}") payload = json.loads(self.rfile.read(length) or b"{}")
problem = problem_from_dict(payload.get("problem", {})) raw_problem = payload.get("problem", {})
problem = problem_from_dict(raw_problem)
max_time = float(payload.get("max_time_seconds", 30.0)) max_time = float(payload.get("max_time_seconds", 30.0))
token = str(payload.get("token") or "") token = str(payload.get("token") or "")
@ -143,7 +220,11 @@ class Handler(BaseHTTPRequestHandler):
if "err" in result: if "err" in result:
raise result["err"] raise result["err"]
self._send(200, json.dumps(solution_to_dict(result["sol"]))) sol_dict = solution_to_dict(result["sol"])
# Persist the completed solve so it can be looked up by its UUID.
# (Cancelled solves take the early return above and aren't stored.)
store_solve(token, raw_problem, sol_dict)
self._send(200, json.dumps(sol_dict))
except Exception as exc: # surface errors to the browser except Exception as exc: # surface errors to the browser
self._send(400, json.dumps({"error": f"{type(exc).__name__}: {exc}"})) self._send(400, json.dumps({"error": f"{type(exc).__name__}: {exc}"}))
finally: finally:
@ -171,6 +252,7 @@ class Handler(BaseHTTPRequestHandler):
def main(): def main():
init_db()
host = os.environ.get("HOST", "127.0.0.1") host = os.environ.get("HOST", "127.0.0.1")
port = int(os.environ.get("PORT", "8000")) port = int(os.environ.get("PORT", "8000"))
server = ThreadingHTTPServer((host, port), Handler) server = ThreadingHTTPServer((host, port), Handler)