diff --git a/edit/server.py b/edit/server.py index 01d0359..48314c2 100644 --- a/edit/server.py +++ b/edit/server.py @@ -6,7 +6,9 @@ import asyncio import os import re import secrets +import shutil import sqlite3 +import tempfile import time from contextlib import contextmanager from dataclasses import dataclass @@ -74,6 +76,42 @@ async def _git_file_hash(file_path: Path) -> str: return "" +async def _three_way_merge( + file_path: Path, user_content: str, base_hash: str, handle: str +) -> tuple[str, bool]: + """Returns (merged_content, has_conflict). Falls back to user_content on error.""" + rel = str(file_path.relative_to(_ROOT)) + env = _git_env() + proc = await asyncio.create_subprocess_exec( + "git", "show", f"{base_hash}:{rel}", + cwd=str(_ROOT), env=env, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, + ) + stdout, _ = await proc.communicate() + if proc.returncode != 0: + return user_content, False + base_content = stdout.decode() + current_content = file_path.read_text(encoding="utf-8") + tmpdir = tempfile.mkdtemp() + try: + p_current = Path(tmpdir) / "current.md" + p_base = Path(tmpdir) / "base.md" + p_user = Path(tmpdir) / "user.md" + p_current.write_text(current_content, encoding="utf-8") + p_base.write_text(base_content, encoding="utf-8") + p_user.write_text(user_content, encoding="utf-8") + proc = await asyncio.create_subprocess_exec( + "git", "merge-file", + "-L", "attuale", "-L", "base", "-L", handle, + str(p_current), str(p_base), str(p_user), + stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, + ) + await proc.wait() + return p_current.read_text(encoding="utf-8"), proc.returncode > 0 + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + + async def _git_commit(file_path: Path, handle: str, verb: str) -> None: rel = str(file_path.relative_to(_ROOT)) env = _git_env() @@ -319,13 +357,22 @@ async def get_page(slug: str, user: User = Depends(require_auth)) -> JSONRespons @app.post("/pages/{slug:path}") async def save_page(slug: str, body: PageBody, user: User = Depends(require_auth)) -> JSONResponse: path = _slug_to_path(slug, pages_dir) - if body.base_hash is not None: - current = await _git_file_hash(path) - if current and current != body.base_hash: - raise HTTPException(409, "Pagina modificata da qualcun altro — ricarica prima di salvare") - result = _save(slug, body, pages_dir) + content = body.content + if body.base_hash and path.exists(): + current_hash = await _git_file_hash(path) + if current_hash and current_hash != body.base_hash: + merged, conflict = await _three_way_merge(path, body.content, body.base_hash, user.handle) + if conflict: + raise HTTPException(409, detail={ + "message": "Conflitto — risolvi i marcatori e salva di nuovo", + "content": merged, + "base_hash": current_hash, + }) + content = merged + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") await _git_commit(path, user.handle, "edited") - return result + return JSONResponse({"slug": str(path.relative_to(pages_dir).with_suffix("")), "saved": True}) @app.delete("/pages/{slug:path}") async def delete_page(slug: str, user: User = Depends(require_auth)) -> JSONResponse: @@ -348,13 +395,22 @@ async def get_story(slug: str, user: User = Depends(require_auth)) -> JSONRespon @app.post("/stories/{slug:path}") async def save_story(slug: str, body: PageBody, user: User = Depends(require_auth)) -> JSONResponse: path = _slug_to_path(slug, stories_dir) - if body.base_hash is not None: - current = await _git_file_hash(path) - if current and current != body.base_hash: - raise HTTPException(409, "Pagina modificata da qualcun altro — ricarica prima di salvare") - result = _save(slug, body, stories_dir) + content = body.content + if body.base_hash and path.exists(): + current_hash = await _git_file_hash(path) + if current_hash and current_hash != body.base_hash: + merged, conflict = await _three_way_merge(path, body.content, body.base_hash, user.handle) + if conflict: + raise HTTPException(409, detail={ + "message": "Conflitto — risolvi i marcatori e salva di nuovo", + "content": merged, + "base_hash": current_hash, + }) + content = merged + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") await _git_commit(path, user.handle, "edited") - return result + return JSONResponse({"slug": str(path.relative_to(stories_dir).with_suffix("")), "saved": True}) @app.delete("/stories/{slug:path}") async def delete_story(slug: str, user: User = Depends(require_auth)) -> JSONResponse: diff --git a/site b/site index 1539bdb..476f605 160000 --- a/site +++ b/site @@ -1 +1 @@ -Subproject commit 1539bdb3a78146685af9e5d369b325c2119b29ec +Subproject commit 476f605a4a6b2e2dd5c6859b6a46717e680b2cf6