feat: diff viewer backups — point 6 (backups, diff, restore)
- 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:
+287
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user