share links added
This commit is contained in:
parent
9fc9057dfe
commit
742d6cce23
4 changed files with 131 additions and 7 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
||||||
__pycache__
|
__pycache__
|
||||||
.venv
|
.venv
|
||||||
*.pdf
|
*.pdf
|
||||||
|
*.db
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
38
index.html
38
index.html
|
|
@ -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} <b>Objective:</b> ${s.objective_value ?? "—"} ` +
|
`<b>Status:</b> ${s.status} <b>Objective:</b> ${s.objective_value ?? "—"} ` +
|
||||||
|
|
@ -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
92
main.py
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue