feat: diff viewer backups — point 6 (backups, diff, restore)
CI / lint (push) Failing after 4s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / security (push) Successful in 8s

- Backend: 3 nouveaux endpoints
  - GET /api/file/{vault}/backups — liste les backups horodatés
  - GET /api/file/{vault}/diff — diff unifié entre versions
  - POST /api/file/{vault}/restore — restaure une version (backup auto avant)
- Frontend: module backups.js — UI modale avec:
  - Sélecteur gauche (backup) / droite (version actuelle ou autre backup)
  - Diff coloré: vert pour ajouts, rouge pour suppressions
  - Bouton Restaurer avec confirmation
- Menu contextuel: clic droit fichier → 'Backups / Versions'
- CSS: styles complets pour la modale, toolbar, table de diff
- ROADMAP: point 6 marqué comme complété
This commit is contained in:
2026-06-03 22:22:50 -04:00
parent c07de4fd65
commit d1ba15c3aa
5 changed files with 967 additions and 9 deletions
+287
View File
@@ -333,6 +333,50 @@ class FileMoveResponse(BaseModel):
item_type: str = Field(description="Type of item moved: 'file' or 'directory'")
class BackupEntry(BaseModel):
"""A single backup version of a file."""
timestamp: int = Field(description="Unix timestamp of when the backup was created")
datetime: str = Field(description="ISO 8601 datetime string")
size: int = Field(description="File size in bytes")
filename: str = Field(description="Backup filename on disk")
class BackupListResponse(BaseModel):
"""Response listing all available backups for a file."""
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path")
backups: list[BackupEntry] = Field(description="Available backups, newest first")
class DiffRequest(BaseModel):
"""Request parameters for generating a diff."""
version: int = Field(description="Timestamp of the backup version to compare")
compare_with: Optional[int] = Field(default=None, description="Timestamp of another backup version. If omitted, compares with the current file.")
class DiffResponse(BaseModel):
"""Response containing a unified diff between two file versions."""
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path")
version: int = Field(description="Backup version timestamp (left/old side)")
compare_with: Optional[int] = Field(default=None, description="Other backup version or null for current file (right/new side)")
diff: str = Field(description="Unified diff (empty if no changes)")
class RestoreRequest(BaseModel):
"""Request to restore a file from a backup."""
version: int = Field(description="Timestamp of the backup version to restore")
class RestoreResponse(BaseModel):
"""Response after restoring a file from backup."""
success: bool = Field(description="Whether restore succeeded")
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path")
restored_from: int = Field(description="Timestamp of the backup used")
current_backed_up: Optional[int] = Field(default=None, description="Timestamp of the backup created from the current version before restore, if any")
# ---------------------------------------------------------------------------
# SSE Manager — Server-Sent Events for real-time notifications
# ---------------------------------------------------------------------------
@@ -1875,6 +1919,249 @@ async def api_file_move(
raise HTTPException(status_code=500, detail=f"Error moving item: {str(e)}")
# ---------------------------------------------------------------------------
# Backup & Diff endpoints
# ---------------------------------------------------------------------------
def _get_backup_root() -> Path:
"""Return the backup root directory as configured."""
return Path(os.environ.get("OBSIGATE_BACKUP_DIR", ".obsigate-backup"))
def _get_backup_dir(vault_name: str, relative_path: str) -> Path:
"""Return the directory where backups for a specific file are stored."""
backup_root = _get_backup_root()
return backup_root / vault_name / Path(relative_path).parent
def _list_backup_files(vault_name: str, relative_path: str) -> list[dict]:
"""List all backup files for a given vault file, sorted newest first.
Backup filename format: {original_filename}.{timestamp}.bak
Returns a list of dicts with timestamp, datetime, size, filename.
"""
backup_dir = _get_backup_dir(vault_name, relative_path)
if not backup_dir.exists():
return []
original_name = Path(relative_path).name
prefix = original_name + "."
backups = []
for f in backup_dir.iterdir():
if not f.is_file():
continue
name = f.name
if not name.startswith(prefix) or not name.endswith(".bak"):
continue
# Extract timestamp: strip prefix and .bak suffix
ts_part = name[len(prefix):-len(".bak")]
try:
ts = int(ts_part)
except (ValueError, TypeError):
continue
dt = datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
backups.append({
"timestamp": ts,
"datetime": dt,
"size": f.stat().st_size,
"filename": name,
})
backups.sort(key=lambda b: b["timestamp"], reverse=True)
return backups
@app.get("/api/file/{vault_name}/backups", response_model=BackupListResponse)
async def api_file_backups(
vault_name: str,
path: str = Query(..., description="Relative path to file"),
current_user=Depends(require_auth),
):
"""List all available backups for a file.
Args:
vault_name: Name of the vault.
path: Relative path of the file within the vault.
Returns:
BackupListResponse with backups sorted newest first.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"])
file_path = _resolve_safe_path(vault_root, path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
backups = _list_backup_files(vault_name, path)
return {"vault": vault_name, "path": path, "backups": backups}
@app.get("/api/file/{vault_name}/diff", response_model=DiffResponse)
async def api_file_diff(
vault_name: str,
path: str = Query(..., description="Relative path to file"),
version: int = Query(..., description="Timestamp of the backup version (left/old side)"),
compare_with: Optional[int] = Query(default=None, description="Timestamp of another backup (right/new side). If omitted, compares with the current file."),
current_user=Depends(require_auth),
):
"""Generate a unified diff between a backup version and another version or the current file.
Args:
vault_name: Name of the vault.
path: Relative path of the file within the vault.
version: Timestamp of the backup to use as the old/left side.
compare_with: Optional timestamp of another backup as the new/right side.
If omitted, the current file on disk is used.
Returns:
DiffResponse containing the unified diff string.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"])
file_path = _resolve_safe_path(vault_root, path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
original_name = Path(path).name
def _read_backup(ts: int) -> tuple[str, str]:
"""Read a backup file. Returns (content, label)."""
backup_dir = _get_backup_dir(vault_name, path)
backup_path = backup_dir / f"{original_name}.{ts}.bak"
if not backup_path.exists():
raise HTTPException(status_code=404, detail=f"Backup version {ts} not found for {path}")
content = backup_path.read_text(encoding="utf-8")
dt = datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
return content, f"{path}@{dt}"
# Read left side (backup version)
left_content, left_label = _read_backup(version)
# Read right side
if compare_with is not None:
right_content, right_label = _read_backup(compare_with)
else:
right_content = file_path.read_text(encoding="utf-8")
right_label = f"{path} (current)"
# Generate unified diff using difflib
import difflib
left_lines = left_content.splitlines(keepends=True)
right_lines = right_content.splitlines(keepends=True)
diff_lines = list(difflib.unified_diff(
left_lines, right_lines,
fromfile=left_label, tofile=right_label,
))
return {
"vault": vault_name,
"path": path,
"version": version,
"compare_with": compare_with,
"diff": "".join(diff_lines),
}
@app.post("/api/file/{vault_name}/restore", response_model=RestoreResponse)
async def api_file_restore(
vault_name: str,
path: str = Query(..., description="Relative path to file"),
body: RestoreRequest = ..., # type: ignore
current_user=Depends(require_auth),
):
"""Restore a file from a backup version.
The current file is backed up before being overwritten (so the operation is reversible).
Args:
vault_name: Name of the vault.
path: Relative path of the file within the vault.
body: RestoreRequest with the backup version timestamp.
Returns:
RestoreResponse confirming the restore.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"])
# Check if vault is writable
if not _check_vault_writable(vault_root):
raise HTTPException(status_code=403, detail="Vault is read-only")
file_path = _resolve_safe_path(vault_root, path)
original_name = Path(path).name
backup_dir = _get_backup_dir(vault_name, path)
backup_path = backup_dir / f"{original_name}.{body.version}.bak"
if not backup_path.exists():
raise HTTPException(status_code=404, detail=f"Backup version {body.version} not found for {path}")
try:
# Backup the current file before restoring
current_backed_up = None
if file_path.exists() and file_path.is_file():
_backup_file(file_path, vault_name, path)
current_backed_up = int(time.time())
# Read backup content
backup_content = backup_path.read_text(encoding="utf-8")
# Write restored content
file_path.write_text(backup_content, encoding="utf-8")
logger.info(f"File restored from backup: {vault_name}/{path} <- version {body.version}")
# Update index
await update_single_file(vault_name, path)
# Broadcast SSE event
await sse_manager.broadcast("file_restored", {
"vault": vault_name,
"path": path,
"restored_from": body.version,
"current_backed_up": current_backed_up,
})
await dispatch_webhooks("file_restored", {"vault": vault_name, "path": path, "restored_from": body.version})
return {
"success": True,
"vault": vault_name,
"path": path,
"restored_from": body.version,
"current_backed_up": current_backed_up,
}
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied: cannot restore file")
except Exception as e:
logger.error(f"Error restoring file {vault_name}/{path}: {e}")
raise HTTPException(status_code=500, detail=f"Error restoring file: {str(e)}")
@app.get("/api/file/{vault_name}/backlinks")
async def api_file_backlinks(
vault_name: str,