feat(backend): keep several versions of a project script
Only the active version lives in script.json and script.md, so everything that reads the script keeps working unchanged. The other versions are stored whole (text, plans, setups) in script/drafts/, with endpoints to fork, activate, rename and delete them.
0d901d7 parent: b963e6a modified
backend/studio/modules/projects/api.py +54 -4 | @@ -352,12 +352,18 @@ def put_doc(pid: str, name: str, req: DocRequest): | ||
| 352 | 352 | |
| 353 | 353 | # --- Script : les blocs du texte et les plans qui s'y rattachent (voir script.py) ------------- |
| 354 | 354 | |
| 355 | +def _script_idle(pid: str) -> None: | |
| 356 | + script_job = jobs.get(service.load(pid)["jobs"].get("script") or "") | |
| 357 | + if script_job and not script_job.finished: | |
| 358 | + raise HTTPException(409, "Claude est en train d'écrire le script") | |
| 359 | + | |
| 360 | + | |
| 355 | 361 | @router.get("/{pid}/script") |
| 356 | 362 | def get_script(pid: str): |
| 357 | 363 | _check(pid) |
| 358 | 364 | data = script.load(pid) |
| 359 | 365 | return {"blocks": data["blocks"], "plans": data["plans"], "setups": data["setups"], "sections": data["sections"], |
| 360 | - "mtime": service.doc_mtime(pid, service.SCRIPT_FILE)} | |
| 366 | + "drafts": script.drafts(pid), "mtime": service.doc_mtime(pid, service.SCRIPT_FILE)} | |
| 361 | 367 | |
| 362 | 368 | |
| 363 | 369 | class ScriptBlock(BaseModel): |
| @@ -372,9 +378,7 @@ class ScriptRequest(BaseModel): | ||
| 372 | 378 | @router.put("/{pid}/script") |
| 373 | 379 | def put_script(pid: str, req: ScriptRequest): |
| 374 | 380 | _check(pid) |
| 375 | - script_job = jobs.get(service.load(pid)["jobs"].get("script") or "") | |
| 376 | - if script_job and not script_job.finished: | |
| 377 | - raise HTTPException(409, "Claude est en train d'écrire le script") | |
| 381 | + _script_idle(pid) | |
| 378 | 382 | try: |
| 379 | 383 | script.save(pid, [b.model_dump() for b in req.blocks]) |
| 380 | 384 | except ValueError as e: |
| @@ -408,6 +412,52 @@ def put_setups(pid: str, req: SetupsRequest): | ||
| 408 | 412 | return script.save_setups(pid, req.setups, req.sections) |
| 409 | 413 | |
| 410 | 414 | |
| 415 | +# les versions du script (V1, V2…) : seule l'active est dans script.json et script.md | |
| 416 | + | |
| 417 | +@router.post("/{pid}/script/drafts") | |
| 418 | +def fork_draft(pid: str): | |
| 419 | + _check(pid) | |
| 420 | + _script_idle(pid) | |
| 421 | + script.fork(pid) | |
| 422 | + return {"drafts": script.drafts(pid)} | |
| 423 | + | |
| 424 | + | |
| 425 | +@router.post("/{pid}/script/drafts/{draft_id}/activate") | |
| 426 | +def activate_draft(pid: str, draft_id: str): | |
| 427 | + _check(pid) | |
| 428 | + _script_idle(pid) | |
| 429 | + try: | |
| 430 | + script.activate(pid, draft_id) | |
| 431 | + except ValueError as e: | |
| 432 | + raise HTTPException(404, str(e)) from e | |
| 433 | + return {"drafts": script.drafts(pid)} | |
| 434 | + | |
| 435 | + | |
| 436 | +class DraftRequest(BaseModel): | |
| 437 | + name: str | |
| 438 | + | |
| 439 | + | |
| 440 | +@router.patch("/{pid}/script/drafts/{draft_id}") | |
| 441 | +def rename_draft(pid: str, draft_id: str, req: DraftRequest): | |
| 442 | + _check(pid) | |
| 443 | + try: | |
| 444 | + script.rename(pid, draft_id, req.name) | |
| 445 | + except ValueError as e: | |
| 446 | + raise HTTPException(400, str(e)) from e | |
| 447 | + return {"drafts": script.drafts(pid)} | |
| 448 | + | |
| 449 | + | |
| 450 | +@router.delete("/{pid}/script/drafts/{draft_id}") | |
| 451 | +def delete_draft(pid: str, draft_id: str): | |
| 452 | + _check(pid) | |
| 453 | + _script_idle(pid) | |
| 454 | + try: | |
| 455 | + script.delete(pid, draft_id) | |
| 456 | + except ValueError as e: | |
| 457 | + raise HTTPException(400, str(e)) from e | |
| 458 | + return {"drafts": script.drafts(pid)} | |
| 459 | + | |
| 460 | + | |
| 411 | 461 | # --- Claude : angles, titres, script, assistant ------------------------------------------------ |
| 412 | 462 | |
| 413 | 463 | class AnglesRequest(BaseModel): |
| @@ -352,12 +352,18 @@ def put_doc(pid: str, name: str, req: DocRequest): | |||
| 352 | 352 | ||
| 353 | # --- Script : les blocs du texte et les plans qui s'y rattachent (voir script.py) ------------- | 353 | # --- Script : les blocs du texte et les plans qui s'y rattachent (voir script.py) ------------- |
| 354 | 354 | ||
| 355 | +def _script_idle(pid: str) -> None: | ||
| 356 | + script_job = jobs.get(service.load(pid)["jobs"].get("script") or "") | ||
| 357 | + if script_job and not script_job.finished: | ||
| 358 | + raise HTTPException(409, "Claude est en train d'écrire le script") | ||
| 359 | + | ||
| 360 | + | ||
| 355 | @router.get("/{pid}/script") | 361 | @router.get("/{pid}/script") |
| 356 | def get_script(pid: str): | 362 | def get_script(pid: str): |
| 357 | _check(pid) | 363 | _check(pid) |
| 358 | data = script.load(pid) | 364 | data = script.load(pid) |
| 359 | return {"blocks": data["blocks"], "plans": data["plans"], "setups": data["setups"], "sections": data["sections"], | 365 | return {"blocks": data["blocks"], "plans": data["plans"], "setups": data["setups"], "sections": data["sections"], |
| 360 | - "mtime": service.doc_mtime(pid, service.SCRIPT_FILE)} | 366 | + "drafts": script.drafts(pid), "mtime": service.doc_mtime(pid, service.SCRIPT_FILE)} |
| 361 | 367 | ||
| 362 | 368 | ||
| 363 | class ScriptBlock(BaseModel): | 369 | class ScriptBlock(BaseModel): |
| @@ -372,9 +378,7 @@ class ScriptRequest(BaseModel): | |||
| 372 | @router.put("/{pid}/script") | 378 | @router.put("/{pid}/script") |
| 373 | def put_script(pid: str, req: ScriptRequest): | 379 | def put_script(pid: str, req: ScriptRequest): |
| 374 | _check(pid) | 380 | _check(pid) |
| 375 | - script_job = jobs.get(service.load(pid)["jobs"].get("script") or "") | 381 | + _script_idle(pid) |
| 376 | - if script_job and not script_job.finished: | ||
| 377 | - raise HTTPException(409, "Claude est en train d'écrire le script") | ||
| 378 | try: | 382 | try: |
| 379 | script.save(pid, [b.model_dump() for b in req.blocks]) | 383 | script.save(pid, [b.model_dump() for b in req.blocks]) |
| 380 | except ValueError as e: | 384 | except ValueError as e: |
| @@ -408,6 +412,52 @@ def put_setups(pid: str, req: SetupsRequest): | |||
| 408 | return script.save_setups(pid, req.setups, req.sections) | 412 | return script.save_setups(pid, req.setups, req.sections) |
| 409 | 413 | ||
| 410 | 414 | ||
| 415 | +# les versions du script (V1, V2…) : seule l'active est dans script.json et script.md | ||
| 416 | + | ||
| 417 | +@router.post("/{pid}/script/drafts") | ||
| 418 | +def fork_draft(pid: str): | ||
| 419 | + _check(pid) | ||
| 420 | + _script_idle(pid) | ||
| 421 | + script.fork(pid) | ||
| 422 | + return {"drafts": script.drafts(pid)} | ||
| 423 | + | ||
| 424 | + | ||
| 425 | +@router.post("/{pid}/script/drafts/{draft_id}/activate") | ||
| 426 | +def activate_draft(pid: str, draft_id: str): | ||
| 427 | + _check(pid) | ||
| 428 | + _script_idle(pid) | ||
| 429 | + try: | ||
| 430 | + script.activate(pid, draft_id) | ||
| 431 | + except ValueError as e: | ||
| 432 | + raise HTTPException(404, str(e)) from e | ||
| 433 | + return {"drafts": script.drafts(pid)} | ||
| 434 | + | ||
| 435 | + | ||
| 436 | +class DraftRequest(BaseModel): | ||
| 437 | + name: str | ||
| 438 | + | ||
| 439 | + | ||
| 440 | +@router.patch("/{pid}/script/drafts/{draft_id}") | ||
| 441 | +def rename_draft(pid: str, draft_id: str, req: DraftRequest): | ||
| 442 | + _check(pid) | ||
| 443 | + try: | ||
| 444 | + script.rename(pid, draft_id, req.name) | ||
| 445 | + except ValueError as e: | ||
| 446 | + raise HTTPException(400, str(e)) from e | ||
| 447 | + return {"drafts": script.drafts(pid)} | ||
| 448 | + | ||
| 449 | + | ||
| 450 | +@router.delete("/{pid}/script/drafts/{draft_id}") | ||
| 451 | +def delete_draft(pid: str, draft_id: str): | ||
| 452 | + _check(pid) | ||
| 453 | + _script_idle(pid) | ||
| 454 | + try: | ||
| 455 | + script.delete(pid, draft_id) | ||
| 456 | + except ValueError as e: | ||
| 457 | + raise HTTPException(400, str(e)) from e | ||
| 458 | + return {"drafts": script.drafts(pid)} | ||
| 459 | + | ||
| 460 | + | ||
| 411 | # --- Claude : angles, titres, script, assistant ------------------------------------------------ | 461 | # --- Claude : angles, titres, script, assistant ------------------------------------------------ |
| 412 | 462 | ||
| 413 | class AnglesRequest(BaseModel): | 463 | class AnglesRequest(BaseModel): |
modified
backend/studio/modules/projects/script.py +120 -1 | @@ -7,6 +7,11 @@ Le script d'un projet : script/script.json en est la source. | ||
| 7 | 7 | setups les configurations de tournage (cadre, lieu, lumière) : {id, name, note}, A, B, C… dans l'ordre |
| 8 | 8 | sections le setup de chaque section : {id du bloc titre: id du setup} |
| 9 | 9 | md_hash l'empreinte du script.md écrit au dernier enregistrement |
| 10 | + draft la version du script (V1, V2…) : {id, name, created} | |
| 11 | + | |
| 12 | +Les versions : on repart d'une copie du script (V2) sans perdre l'ancienne (V1). Seule la version | |
| 13 | +active est dans script.json et script.md, que tout le reste lit ; les autres attendent, complètes | |
| 14 | +(texte, plans, setups), dans script/drafts/<id>.json. Changer de version, c'est échanger les fichiers. | |
| 10 | 15 | |
| 11 | 16 | Un plan : {id, block, end, q, type, frame, setup, note, media, on, by} |
| 12 | 17 | block le bloc où commence le passage qu'il illustre, `end` celui où il finit (None : le même) |
| @@ -41,6 +46,7 @@ from . import service | ||
| 41 | 46 | |
| 42 | 47 | JSON_FILE = "script.json" |
| 43 | 48 | VERSIONS_DIR = "versions" |
| 49 | +DRAFTS_DIR = "drafts" | |
| 44 | 50 | # une copie au plus toutes les 10 minutes pendant l'édition, les 50 dernières gardées |
| 45 | 51 | VERSION_EVERY = 10 * 60 |
| 46 | 52 | VERSION_KEEP = 50 |
| @@ -212,7 +218,9 @@ def _read(pid: str) -> dict: | ||
| 212 | 218 | data = json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {} |
| 213 | 219 | return {"blocks": data.get("blocks", []), "plans": data.get("plans", []), "setups": data.get("setups", []), |
| 214 | 220 | "sections": data.get("sections", {}), "md_hash": data.get("md_hash"), |
| 215 | - "shots_migrated": data.get("shots_migrated", False)} | |
| 221 | + "shots_migrated": data.get("shots_migrated", False), | |
| 222 | + "draft": data.get("draft") or {"id": "v1", "name": "V1", | |
| 223 | + "created": path.stat().st_mtime if path.is_file() else time.time()}} | |
| 216 | 224 | |
| 217 | 225 | |
| 218 | 226 | def _write(pid: str, data: dict) -> None: |
| @@ -336,3 +344,114 @@ def save_setups(pid: str, setups: list[dict], sections: dict[str, str]) -> dict: | ||
| 336 | 344 | plan["setup"] = "" |
| 337 | 345 | _write(pid, data) |
| 338 | 346 | return {"setups": data["setups"], "sections": data["sections"], "plans": data["plans"]} |
| 347 | + | |
| 348 | + | |
| 349 | +# --- Versions ------------------------------------------------------------------------------ | |
| 350 | + | |
| 351 | +def _drafts_dir(pid: str): | |
| 352 | + return service.project_dir(pid) / service.SCRIPT_DIR / DRAFTS_DIR | |
| 353 | + | |
| 354 | + | |
| 355 | +def _draft_path(pid: str, draft_id: str): | |
| 356 | + if not re.fullmatch(r"[a-z0-9]+", draft_id): | |
| 357 | + raise ValueError("version inconnue") | |
| 358 | + return _drafts_dir(pid) / f"{draft_id}.json" | |
| 359 | + | |
| 360 | + | |
| 361 | +def _stored(pid: str) -> list[dict]: | |
| 362 | + """Les versions rangées (pas l'active), de la plus ancienne à la plus récente.""" | |
| 363 | + folder = _drafts_dir(pid) | |
| 364 | + stored = [json.loads(p.read_text(encoding="utf-8")) for p in folder.glob("*.json")] if folder.is_dir() else [] | |
| 365 | + return sorted(stored, key=lambda d: d["draft"]["created"]) | |
| 366 | + | |
| 367 | + | |
| 368 | +def drafts(pid: str) -> list[dict]: | |
| 369 | + """Les versions du script, de la plus ancienne à la plus récente : {id, name, created, active, words}.""" | |
| 370 | + active = _read(pid) | |
| 371 | + out = [{**d["draft"], "active": d is active, "words": len(render(d["blocks"]).split())} | |
| 372 | + for d in [active, *_stored(pid)]] | |
| 373 | + return sorted(out, key=lambda d: d["created"]) | |
| 374 | + | |
| 375 | + | |
| 376 | +def _stash(pid: str, data: dict) -> None: | |
| 377 | + """Range une version dans drafts/, avec ses plans et ses setups.""" | |
| 378 | + folder = _drafts_dir(pid) | |
| 379 | + folder.mkdir(exist_ok=True) | |
| 380 | + path = folder / f"{data['draft']['id']}.json" | |
| 381 | + tmp = path.with_suffix(".tmp") | |
| 382 | + tmp.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8") | |
| 383 | + tmp.replace(path) | |
| 384 | + | |
| 385 | + | |
| 386 | +def _install(pid: str, data: dict) -> None: | |
| 387 | + """`data` devient la version active : script.json et sa vue script.md.""" | |
| 388 | + md = render(data["blocks"]) | |
| 389 | + data["md_hash"] = _hash(md) | |
| 390 | + _write_md(pid, md) | |
| 391 | + _write(pid, data) | |
| 392 | + | |
| 393 | + | |
| 394 | +def fork(pid: str) -> None: | |
| 395 | + """Une nouvelle version, copie de l'active (mêmes blocs, mêmes plans), qui prend sa place.""" | |
| 396 | + load(pid) | |
| 397 | + with _lock(pid): | |
| 398 | + data = _read(pid) | |
| 399 | + _stash(pid, data) | |
| 400 | + names = [d["draft"]["name"] for d in [data, *_stored(pid)]] | |
| 401 | + numbers = [int(m.group(1)) for n in names if (m := re.fullmatch(r"V(\d+)", n))] | |
| 402 | + data["draft"] = {"id": "v" + uuid.uuid4().hex[:8], "name": f"V{max(numbers, default=1) + 1}", | |
| 403 | + "created": time.time()} | |
| 404 | + _write(pid, data) | |
| 405 | + | |
| 406 | + | |
| 407 | +def activate(pid: str, draft_id: str) -> None: | |
| 408 | + """La version `draft_id` devient l'active ; l'actuelle est rangée dans drafts/.""" | |
| 409 | + load(pid) | |
| 410 | + with _lock(pid): | |
| 411 | + current = _read(pid) | |
| 412 | + if current["draft"]["id"] == draft_id: | |
| 413 | + return | |
| 414 | + path = _draft_path(pid, draft_id) | |
| 415 | + if not path.is_file(): | |
| 416 | + raise ValueError("version inconnue") | |
| 417 | + _stash(pid, current) | |
| 418 | + _install(pid, json.loads(path.read_text(encoding="utf-8"))) | |
| 419 | + path.unlink() | |
| 420 | + | |
| 421 | + | |
| 422 | +def rename(pid: str, draft_id: str, name: str) -> None: | |
| 423 | + name = " ".join(name.split())[:40] | |
| 424 | + if not name: | |
| 425 | + raise ValueError("nom vide") | |
| 426 | + with _lock(pid): | |
| 427 | + current = _read(pid) | |
| 428 | + if current["draft"]["id"] == draft_id: | |
| 429 | + current["draft"]["name"] = name | |
| 430 | + _write(pid, current) | |
| 431 | + return | |
| 432 | + path = _draft_path(pid, draft_id) | |
| 433 | + if not path.is_file(): | |
| 434 | + raise ValueError("version inconnue") | |
| 435 | + data = json.loads(path.read_text(encoding="utf-8")) | |
| 436 | + data["draft"]["name"] = name | |
| 437 | + _stash(pid, data) | |
| 438 | + | |
| 439 | + | |
| 440 | +def delete(pid: str, draft_id: str) -> None: | |
| 441 | + """Supprime une version ; si c'est l'active, la plus récente des autres prend sa place. | |
| 442 | + Son texte reste dans les copies de script/versions/.""" | |
| 443 | + with _lock(pid): | |
| 444 | + current = _read(pid) | |
| 445 | + others = _stored(pid) | |
| 446 | + if not others: | |
| 447 | + raise ValueError("c'est la seule version du script") | |
| 448 | + if current["draft"]["id"] != draft_id: | |
| 449 | + path = _draft_path(pid, draft_id) | |
| 450 | + if not path.is_file(): | |
| 451 | + raise ValueError("version inconnue") | |
| 452 | + _snapshot(pid, render(json.loads(path.read_text(encoding="utf-8"))["blocks"]), force=True) | |
| 453 | + path.unlink() | |
| 454 | + return | |
| 455 | + _snapshot(pid, render(current["blocks"]), force=True) | |
| 456 | + _install(pid, others[-1]) | |
| 457 | + _draft_path(pid, others[-1]["draft"]["id"]).unlink() | |
| @@ -7,6 +7,11 @@ Le script d'un projet : script/script.json en est la source. | |||
| 7 | setups les configurations de tournage (cadre, lieu, lumière) : {id, name, note}, A, B, C… dans l'ordre | 7 | setups les configurations de tournage (cadre, lieu, lumière) : {id, name, note}, A, B, C… dans l'ordre |
| 8 | sections le setup de chaque section : {id du bloc titre: id du setup} | 8 | sections le setup de chaque section : {id du bloc titre: id du setup} |
| 9 | md_hash l'empreinte du script.md écrit au dernier enregistrement | 9 | md_hash l'empreinte du script.md écrit au dernier enregistrement |
| 10 | + draft la version du script (V1, V2…) : {id, name, created} | ||
| 11 | + | ||
| 12 | +Les versions : on repart d'une copie du script (V2) sans perdre l'ancienne (V1). Seule la version | ||
| 13 | +active est dans script.json et script.md, que tout le reste lit ; les autres attendent, complètes | ||
| 14 | +(texte, plans, setups), dans script/drafts/<id>.json. Changer de version, c'est échanger les fichiers. | ||
| 10 | 15 | ||
| 11 | Un plan : {id, block, end, q, type, frame, setup, note, media, on, by} | 16 | Un plan : {id, block, end, q, type, frame, setup, note, media, on, by} |
| 12 | block le bloc où commence le passage qu'il illustre, `end` celui où il finit (None : le même) | 17 | block le bloc où commence le passage qu'il illustre, `end` celui où il finit (None : le même) |
| @@ -41,6 +46,7 @@ from . import service | |||
| 41 | 46 | ||
| 42 | JSON_FILE = "script.json" | 47 | JSON_FILE = "script.json" |
| 43 | VERSIONS_DIR = "versions" | 48 | VERSIONS_DIR = "versions" |
| 49 | +DRAFTS_DIR = "drafts" | ||
| 44 | # une copie au plus toutes les 10 minutes pendant l'édition, les 50 dernières gardées | 50 | # une copie au plus toutes les 10 minutes pendant l'édition, les 50 dernières gardées |
| 45 | VERSION_EVERY = 10 * 60 | 51 | VERSION_EVERY = 10 * 60 |
| 46 | VERSION_KEEP = 50 | 52 | VERSION_KEEP = 50 |
| @@ -212,7 +218,9 @@ def _read(pid: str) -> dict: | |||
| 212 | data = json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {} | 218 | data = json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {} |
| 213 | return {"blocks": data.get("blocks", []), "plans": data.get("plans", []), "setups": data.get("setups", []), | 219 | return {"blocks": data.get("blocks", []), "plans": data.get("plans", []), "setups": data.get("setups", []), |
| 214 | "sections": data.get("sections", {}), "md_hash": data.get("md_hash"), | 220 | "sections": data.get("sections", {}), "md_hash": data.get("md_hash"), |
| 215 | - "shots_migrated": data.get("shots_migrated", False)} | 221 | + "shots_migrated": data.get("shots_migrated", False), |
| 222 | + "draft": data.get("draft") or {"id": "v1", "name": "V1", | ||
| 223 | + "created": path.stat().st_mtime if path.is_file() else time.time()}} | ||
| 216 | 224 | ||
| 217 | 225 | ||
| 218 | def _write(pid: str, data: dict) -> None: | 226 | def _write(pid: str, data: dict) -> None: |
| @@ -336,3 +344,114 @@ def save_setups(pid: str, setups: list[dict], sections: dict[str, str]) -> dict: | |||
| 336 | plan["setup"] = "" | 344 | plan["setup"] = "" |
| 337 | _write(pid, data) | 345 | _write(pid, data) |
| 338 | return {"setups": data["setups"], "sections": data["sections"], "plans": data["plans"]} | 346 | return {"setups": data["setups"], "sections": data["sections"], "plans": data["plans"]} |
| 347 | + | ||
| 348 | + | ||
| 349 | +# --- Versions ------------------------------------------------------------------------------ | ||
| 350 | + | ||
| 351 | +def _drafts_dir(pid: str): | ||
| 352 | + return service.project_dir(pid) / service.SCRIPT_DIR / DRAFTS_DIR | ||
| 353 | + | ||
| 354 | + | ||
| 355 | +def _draft_path(pid: str, draft_id: str): | ||
| 356 | + if not re.fullmatch(r"[a-z0-9]+", draft_id): | ||
| 357 | + raise ValueError("version inconnue") | ||
| 358 | + return _drafts_dir(pid) / f"{draft_id}.json" | ||
| 359 | + | ||
| 360 | + | ||
| 361 | +def _stored(pid: str) -> list[dict]: | ||
| 362 | + """Les versions rangées (pas l'active), de la plus ancienne à la plus récente.""" | ||
| 363 | + folder = _drafts_dir(pid) | ||
| 364 | + stored = [json.loads(p.read_text(encoding="utf-8")) for p in folder.glob("*.json")] if folder.is_dir() else [] | ||
| 365 | + return sorted(stored, key=lambda d: d["draft"]["created"]) | ||
| 366 | + | ||
| 367 | + | ||
| 368 | +def drafts(pid: str) -> list[dict]: | ||
| 369 | + """Les versions du script, de la plus ancienne à la plus récente : {id, name, created, active, words}.""" | ||
| 370 | + active = _read(pid) | ||
| 371 | + out = [{**d["draft"], "active": d is active, "words": len(render(d["blocks"]).split())} | ||
| 372 | + for d in [active, *_stored(pid)]] | ||
| 373 | + return sorted(out, key=lambda d: d["created"]) | ||
| 374 | + | ||
| 375 | + | ||
| 376 | +def _stash(pid: str, data: dict) -> None: | ||
| 377 | + """Range une version dans drafts/, avec ses plans et ses setups.""" | ||
| 378 | + folder = _drafts_dir(pid) | ||
| 379 | + folder.mkdir(exist_ok=True) | ||
| 380 | + path = folder / f"{data['draft']['id']}.json" | ||
| 381 | + tmp = path.with_suffix(".tmp") | ||
| 382 | + tmp.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8") | ||
| 383 | + tmp.replace(path) | ||
| 384 | + | ||
| 385 | + | ||
| 386 | +def _install(pid: str, data: dict) -> None: | ||
| 387 | + """`data` devient la version active : script.json et sa vue script.md.""" | ||
| 388 | + md = render(data["blocks"]) | ||
| 389 | + data["md_hash"] = _hash(md) | ||
| 390 | + _write_md(pid, md) | ||
| 391 | + _write(pid, data) | ||
| 392 | + | ||
| 393 | + | ||
| 394 | +def fork(pid: str) -> None: | ||
| 395 | + """Une nouvelle version, copie de l'active (mêmes blocs, mêmes plans), qui prend sa place.""" | ||
| 396 | + load(pid) | ||
| 397 | + with _lock(pid): | ||
| 398 | + data = _read(pid) | ||
| 399 | + _stash(pid, data) | ||
| 400 | + names = [d["draft"]["name"] for d in [data, *_stored(pid)]] | ||
| 401 | + numbers = [int(m.group(1)) for n in names if (m := re.fullmatch(r"V(\d+)", n))] | ||
| 402 | + data["draft"] = {"id": "v" + uuid.uuid4().hex[:8], "name": f"V{max(numbers, default=1) + 1}", | ||
| 403 | + "created": time.time()} | ||
| 404 | + _write(pid, data) | ||
| 405 | + | ||
| 406 | + | ||
| 407 | +def activate(pid: str, draft_id: str) -> None: | ||
| 408 | + """La version `draft_id` devient l'active ; l'actuelle est rangée dans drafts/.""" | ||
| 409 | + load(pid) | ||
| 410 | + with _lock(pid): | ||
| 411 | + current = _read(pid) | ||
| 412 | + if current["draft"]["id"] == draft_id: | ||
| 413 | + return | ||
| 414 | + path = _draft_path(pid, draft_id) | ||
| 415 | + if not path.is_file(): | ||
| 416 | + raise ValueError("version inconnue") | ||
| 417 | + _stash(pid, current) | ||
| 418 | + _install(pid, json.loads(path.read_text(encoding="utf-8"))) | ||
| 419 | + path.unlink() | ||
| 420 | + | ||
| 421 | + | ||
| 422 | +def rename(pid: str, draft_id: str, name: str) -> None: | ||
| 423 | + name = " ".join(name.split())[:40] | ||
| 424 | + if not name: | ||
| 425 | + raise ValueError("nom vide") | ||
| 426 | + with _lock(pid): | ||
| 427 | + current = _read(pid) | ||
| 428 | + if current["draft"]["id"] == draft_id: | ||
| 429 | + current["draft"]["name"] = name | ||
| 430 | + _write(pid, current) | ||
| 431 | + return | ||
| 432 | + path = _draft_path(pid, draft_id) | ||
| 433 | + if not path.is_file(): | ||
| 434 | + raise ValueError("version inconnue") | ||
| 435 | + data = json.loads(path.read_text(encoding="utf-8")) | ||
| 436 | + data["draft"]["name"] = name | ||
| 437 | + _stash(pid, data) | ||
| 438 | + | ||
| 439 | + | ||
| 440 | +def delete(pid: str, draft_id: str) -> None: | ||
| 441 | + """Supprime une version ; si c'est l'active, la plus récente des autres prend sa place. | ||
| 442 | + Son texte reste dans les copies de script/versions/.""" | ||
| 443 | + with _lock(pid): | ||
| 444 | + current = _read(pid) | ||
| 445 | + others = _stored(pid) | ||
| 446 | + if not others: | ||
| 447 | + raise ValueError("c'est la seule version du script") | ||
| 448 | + if current["draft"]["id"] != draft_id: | ||
| 449 | + path = _draft_path(pid, draft_id) | ||
| 450 | + if not path.is_file(): | ||
| 451 | + raise ValueError("version inconnue") | ||
| 452 | + _snapshot(pid, render(json.loads(path.read_text(encoding="utf-8"))["blocks"]), force=True) | ||
| 453 | + path.unlink() | ||
| 454 | + return | ||
| 455 | + _snapshot(pid, render(current["blocks"]), force=True) | ||
| 456 | + _install(pid, others[-1]) | ||
| 457 | + _draft_path(pid, others[-1]["draft"]["id"]).unlink() | ||