From 742d6cce23aa4f82964a86a6a431d27324a1b8b6 Mon Sep 17 00:00:00 2001 From: Pagwin Date: Wed, 17 Jun 2026 22:58:33 -0400 Subject: [PATCH] share links added --- .gitignore | 1 + docker-compose.yml | 7 ++++ index.html | 38 ++++++++++++++++++- main.py | 92 +++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 131 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 14a1b3f..231c729 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ __pycache__ .venv *.pdf +*.db diff --git a/docker-compose.yml b/docker-compose.yml index 7af6eaa..2328c3e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,4 +6,11 @@ services: environment: - HOST=0.0.0.0 - PORT=8000 + # Stored solves live on the mounted volume so they survive restarts. + - DB_PATH=/data/solves.db + volumes: + - solves:/data restart: unless-stopped + +volumes: + solves: diff --git a/index.html b/index.html index 608a6b4..bcd47da 100644 --- a/index.html +++ b/index.html @@ -862,7 +862,7 @@ } const data = await resp.json(); 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;} finally {solvingHere = false; activeToken = null; setSolveDisabled(false);} } @@ -891,7 +891,7 @@ setInterval(pollStatus, 1000); pollStatus(); - function renderSolution(s, placeholder) { + function renderSolution(s, placeholder, token) { // Collapse any previously-shown solutions so the new one is the focus. for (const d of document.querySelectorAll("#output details.solution")) d.open = false; @@ -900,6 +900,19 @@ details.append(el("summary", {}, `Solution ${n} — ${s.status}, objective ${s.objective_value ?? "—"}`)); 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", { html: `Status: ${s.status}   Objective: ${s.objective_value ?? "—"}   ` + @@ -1096,6 +1109,27 @@ "brass": 1, "electrum": 2 }) + + // --- deep link: /?solve= 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); diff --git a/main.py b/main.py index a48bc6f..2d0987b 100644 --- a/main.py +++ b/main.py @@ -16,10 +16,12 @@ import json import os import select import socket +import sqlite3 import threading +import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer 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 @@ -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. 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/) and shared via /?solve= 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/ 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 # clients get an immediate 429 from /solve and disable their button (they learn # the busy state by polling /status). @@ -63,10 +131,18 @@ class Handler(BaseHTTPRequestHandler): self.wfile.write(body) 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") - elif self.path == "/status": + elif path == "/status": 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: self._send(404, json.dumps({"error": "not found"})) @@ -99,7 +175,8 @@ class Handler(BaseHTTPRequestHandler): try: length = int(self.headers.get("Content-Length", 0)) 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)) token = str(payload.get("token") or "") @@ -143,7 +220,11 @@ class Handler(BaseHTTPRequestHandler): if "err" in result: 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 self._send(400, json.dumps({"error": f"{type(exc).__name__}: {exc}"})) finally: @@ -171,6 +252,7 @@ class Handler(BaseHTTPRequestHandler): def main(): + init_db() host = os.environ.get("HOST", "127.0.0.1") port = int(os.environ.get("PORT", "8000")) server = ThreadingHTTPServer((host, port), Handler)