hlassiege/darylpublic Fork
⑂ 0d901d7
Commits
⬇ Clone ▾
git clone https://git.rickub.com/hlassiege/daryl.git
git clone ssh://git@rickub.com/hlassiege/daryl.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

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.
hlassiege committed 2026-10-12T10:10:12+02:00 Browse files
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):
352352
353353 # --- Script : les blocs du texte et les plans qui s'y rattachent (voir script.py) -------------
354354
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+
355361 @router.get("/{pid}/script")
356362 def get_script(pid: str):
357363 _check(pid)
358364 data = script.load(pid)
359365 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)}
361367
362368
363369 class ScriptBlock(BaseModel):
@@ -372,9 +378,7 @@ class ScriptRequest(BaseModel):
372378 @router.put("/{pid}/script")
373379 def put_script(pid: str, req: ScriptRequest):
374380 _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)
378382 try:
379383 script.save(pid, [b.model_dump() for b in req.blocks])
380384 except ValueError as e:
@@ -408,6 +412,52 @@ def put_setups(pid: str, req: SetupsRequest):
408412 return script.save_setups(pid, req.setups, req.sections)
409413
410414
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+
411461 # --- Claude : angles, titres, script, assistant ------------------------------------------------
412462
413463 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.
77 setups les configurations de tournage (cadre, lieu, lumière) : {id, name, note}, A, B, C… dans l'ordre
88 sections le setup de chaque section : {id du bloc titre: id du setup}
99 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.
1015
1116 Un plan : {id, block, end, q, type, frame, setup, note, media, on, by}
1217 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
4146
4247 JSON_FILE = "script.json"
4348 VERSIONS_DIR = "versions"
49+DRAFTS_DIR = "drafts"
4450 # une copie au plus toutes les 10 minutes pendant l'édition, les 50 dernières gardées
4551 VERSION_EVERY = 10 * 60
4652 VERSION_KEEP = 50
@@ -212,7 +218,9 @@ def _read(pid: str) -> dict:
212218 data = json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {}
213219 return {"blocks": data.get("blocks", []), "plans": data.get("plans", []), "setups": data.get("setups", []),
214220 "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()}}
216224
217225
218226 def _write(pid: str, data: dict) -> None:
@@ -336,3 +344,114 @@ def save_setups(pid: str, setups: list[dict], sections: dict[str, str]) -> dict:
336344 plan["setup"] = ""
337345 _write(pid, data)
338346 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'ordre7 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 enregistrement9 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ées50 # une copie au plus toutes les 10 minutes pendant l'édition, les 50 dernières gardées
45 VERSION_EVERY = 10 * 6051 VERSION_EVERY = 10 * 60
46 VERSION_KEEP = 5052 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()