diff --git a/main.py b/main.py
index a95810c..763a365 100644
--- a/main.py
+++ b/main.py
@@ -1266,6 +1266,56 @@ async def delete_run(request: Request, run_id: str):
return RedirectResponse(url=f"{BASE_PATH}/", status_code=303)
+@app.post("/d/{run_id}/fork", response_class=HTMLResponse)
+async def fork_run(request: Request, run_id: str):
+ src_dir = _run_dir_for_id(run_id)
+ if src_dir is None:
+ return RedirectResponse(url=f"{BASE_PATH}/?error=Invalid%20run%20id", status_code=303)
+
+ client_ip = request.client.host if request.client else "unknown"
+
+ # Forking counts as a convert: enforce daily convert quota
+ if not _consume_rate_limit(client_ip, key="convert", limit=CONVERT_DAILY_LIMIT):
+ logger.info("fork.rate_limit_exceeded from=%s ip=%s", run_id, client_ip)
+ return RedirectResponse(url=f"{BASE_PATH}/?error=Rate%20limit%20exceeded", status_code=303)
+
+ try:
+ new_id = _new_run_id()
+ new_dir = RUNS_DIR / new_id
+ new_dir.mkdir(parents=True, exist_ok=False)
+
+ # Copy a selected set of files so the fork is usable: source .tex, original upload, and common artifacts
+ candidates = list(src_dir.glob("original.*")) + [
+ src_dir / "diagram.tex",
+ src_dir / "diagram.pdf",
+ src_dir / "diagram.png",
+ src_dir / "diagram.svg",
+ src_dir / "preview.png",
+ src_dir / "run.log.txt",
+ src_dir / "meta.json",
+ ]
+ for p in candidates:
+ if p.exists() and p.is_file():
+ shutil.copy2(p, new_dir / p.name)
+
+ # Update or create meta for the new run
+ meta = _read_run_meta(new_dir)
+ meta["forked_from"] = run_id
+ meta["created_at"] = time.time()
+ meta["updated_at"] = time.time()
+ _write_run_meta(new_dir, meta)
+
+ logger.info("fork.ok from=%s to=%s ip=%s", run_id, new_id, client_ip)
+ run_log = RunLogger(new_dir, logger)
+ run_log.line(f"fork.from={run_id} by_ip={client_ip}")
+ run_log.line("rate.consumed key=convert")
+ except Exception:
+ logger.exception("fork.error from=%s", run_id)
+ return RedirectResponse(url=f"{BASE_PATH}/d/{run_id}?error=Fork%20failed", status_code=303)
+
+ return RedirectResponse(url=f"{BASE_PATH}/d/{new_id}", status_code=303)
+
+
@app.get("/d/{run_id}/files/{filename}")
async def get_run_file(run_id: str, filename: str):
path = _safe_join_run_file(run_id, filename)
diff --git a/templates/edit.html b/templates/edit.html
index eed2b75..4567442 100644
--- a/templates/edit.html
+++ b/templates/edit.html
@@ -86,6 +86,15 @@
Delete Diagram
+
@@ -143,13 +152,14 @@
const currentTex = latexArea ? latexArea.value : ""
let historyEntries = []
- // Validate edit/compile/delete actions.
+ // Validate edit/compile/delete/fork actions.
if (editForm && instructionsInput) {
editForm.addEventListener("submit", e => {
const submitter = e.submitter
const action = (submitter && submitter.getAttribute("formaction")) || ""
const isAlternateAction = !!action
const isDeleteAction = action.endsWith("/delete")
+ const isForkAction = action.endsWith("/fork")
const val = (instructionsInput.value || "").trim()
if (isDeleteAction) {
@@ -159,6 +169,13 @@
return
}
+ if (isForkAction) {
+ if (!confirm("Create a fork (copy) of this run?")) {
+ e.preventDefault()
+ }
+ return
+ }
+
if (!isAlternateAction && !val) {
e.preventDefault()
}