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:
+9
-9
@@ -126,16 +126,16 @@
|
||||
- [x] **Protection** : pas de drop sur soi-même, pas de drop dans un enfant, pas de cross-vault
|
||||
- [x] **SSE + index** : mise à jour de l'index et broadcast SSE après move
|
||||
|
||||
### 6. Diff viewer backups
|
||||
### ✅ 6. Diff viewer backups — FAIT
|
||||
- **Effort** : 1 jour | **Impact** : 🟢
|
||||
- Comparer versions sauvegardées dans `.obsigate-backup/`
|
||||
- **Pourquoi** : ObsiGate fait un backup automatique avant chaque écriture de fichier (mécanisme déjà en place dans `secret_redactor.py` et les endpoints d'édition). Ces snapshots horodatés s'accumulent dans `.obsigate-backup/` mais ne sont pas exploitables depuis l'UI.
|
||||
- **Ce que ça met en place** :
|
||||
- Un endpoint `GET /api/file/{vault_name}/backups` qui liste les backups disponibles pour un fichier (avec horodatage)
|
||||
- Un endpoint `GET /api/file/{vault_name}/diff` qui prend deux versions (ou une version + l'actuelle) et retourne un diff unifié (ligne par ligne)
|
||||
- Une UI de comparaison dans le panneau de droite : sélecteur de version (dropdown avec les timestamps), affichage du diff côte à côte ou unifié avec coloration syntaxique (lignes ajoutées en vert, supprimées en rouge)
|
||||
- Un bouton "Restaurer" pour revenir à une version précédente
|
||||
- Accessible depuis le menu contextuel d'un fichier → "View backups"
|
||||
- Comparer versions sauvegardées dans `.obsigate-backup/` ✅
|
||||
- **Fichiers** : `backend/main.py` (3 endpoints + helpers), `frontend/js/backups.js`, `frontend/js/ui.js`, `frontend/style.css`
|
||||
- [x] **`GET /api/file/{vault}/backups`** — liste tous les backups horodatés d'un fichier (triés plus récent d'abord)
|
||||
- [x] **`GET /api/file/{vault}/diff`** — diff unifié entre deux versions (backup vs backup, ou backup vs actuel)
|
||||
- [x] **`POST /api/file/{vault}/restore`** — restaure une version backup (backup automatique de la version actuelle avant restauration)
|
||||
- [x] **UI modale** : panneau avec sélecteur gauche (backup) / droite (version actuelle ou autre backup), diff coloré (vert/rouge), bouton Restaurer avec confirmation
|
||||
- [x] **Menu contextuel** : clic droit sur un fichier → "Backups / Versions"
|
||||
- [x] **Coloration syntaxique diff** : lignes ajoutées en vert, supprimées en rouge, en-têtes hunk en accent
|
||||
|
||||
### 7. Fichiers récents par vault
|
||||
- **Effort** : 0.5 jour | **Impact** : 🟡
|
||||
|
||||
+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,
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* Backup & Diff viewer module for ObsiGate.
|
||||
*
|
||||
* Provides a modal UI to browse file backups, compare versions with
|
||||
* a unified diff view, and restore previous versions.
|
||||
*
|
||||
* Usage:
|
||||
* import { openBackupViewer } from './backups.js';
|
||||
* openBackupViewer(vaultName, filePath);
|
||||
*/
|
||||
|
||||
import { api } from './auth.js';
|
||||
import { showToast } from './ui.js';
|
||||
import { refreshSidebarTreePreservingState } from './sidebar.js';
|
||||
import { safeCreateIcons } from './utils.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const state = {
|
||||
vault: null,
|
||||
path: null,
|
||||
backups: [],
|
||||
overlay: null,
|
||||
leftVersion: null, // timestamp of backup shown on left
|
||||
rightVersion: null, // timestamp of backup shown on right, or null for current
|
||||
diffText: '',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Open the backup/diff viewer for a file.
|
||||
* @param {string} vault - Vault name
|
||||
* @param {string} path - Relative file path
|
||||
*/
|
||||
export async function openBackupViewer(vault, path) {
|
||||
state.vault = vault;
|
||||
state.path = path;
|
||||
state.backups = [];
|
||||
state.leftVersion = null;
|
||||
state.rightVersion = null;
|
||||
state.diffText = '';
|
||||
|
||||
// Create and show overlay
|
||||
const overlay = createOverlay();
|
||||
document.body.appendChild(overlay);
|
||||
state.overlay = overlay;
|
||||
requestAnimationFrame(() => overlay.classList.add('active'));
|
||||
|
||||
// Show loading state
|
||||
setBody(renderLoading());
|
||||
|
||||
// Fetch backups
|
||||
try {
|
||||
const data = await api(`/api/file/${encodeURIComponent(vault)}/backups?path=${encodeURIComponent(path)}`);
|
||||
state.backups = data.backups || [];
|
||||
} catch (err) {
|
||||
setBody(renderError('Failed to load backups'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.backups.length === 0) {
|
||||
setBody(renderEmpty());
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-select: left = most recent, right = current
|
||||
state.leftVersion = state.backups[0].timestamp;
|
||||
state.rightVersion = null;
|
||||
|
||||
// Render version selectors
|
||||
renderMainView();
|
||||
|
||||
// Load initial diff
|
||||
await loadDiff();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DOM helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createOverlay() {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'obsigate-modal-overlay backup-viewer-overlay';
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) close();
|
||||
});
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'backup-viewer-panel';
|
||||
panel.id = 'backup-viewer-panel';
|
||||
overlay.appendChild(panel);
|
||||
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function setBody(html) {
|
||||
const panel = document.getElementById('backup-viewer-panel');
|
||||
if (panel) panel.innerHTML = html;
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (state.overlay) {
|
||||
state.overlay.classList.remove('active');
|
||||
setTimeout(() => {
|
||||
if (state.overlay) state.overlay.remove();
|
||||
state.overlay = null;
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderLoading() {
|
||||
return `
|
||||
<div class="backup-header">
|
||||
<h3>Backups</h3>
|
||||
<button class="backup-close-btn" title="Fermer">×</button>
|
||||
</div>
|
||||
<div class="backup-body">
|
||||
<div class="tree-loading" style="padding:2rem;text-align:center">
|
||||
<div class="loading-spinner" style="width:24px;height:24px;border-width:3px;margin:0 auto"></div>
|
||||
<p style="margin-top:0.75rem;color:var(--text-muted);font-size:0.85rem">Chargement des backups...</p>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderError(msg) {
|
||||
return `
|
||||
<div class="backup-header">
|
||||
<h3>Backups</h3>
|
||||
<button class="backup-close-btn" title="Fermer">×</button>
|
||||
</div>
|
||||
<div class="backup-body" style="text-align:center;padding:2rem;color:var(--text-muted)">
|
||||
<p>${escapeHtml(msg)}</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderEmpty() {
|
||||
return `
|
||||
<div class="backup-header">
|
||||
<h3>Backups — ${escapeHtml(state.path)}</h3>
|
||||
<button class="backup-close-btn" title="Fermer">×</button>
|
||||
</div>
|
||||
<div class="backup-body" style="text-align:center;padding:2rem;color:var(--text-muted)">
|
||||
<p>Aucun backup disponible pour ce fichier.</p>
|
||||
<p style="font-size:0.8rem;margin-top:0.5rem">Les backups sont créés automatiquement avant chaque modification.</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderMainView() {
|
||||
const b = state.backups;
|
||||
const leftOpts = b.map(bk => {
|
||||
const sel = bk.timestamp === state.leftVersion ? 'selected' : '';
|
||||
return `<option value="${bk.timestamp}" ${sel}>${formatTimestamp(bk.timestamp)} (${formatSize(bk.size)})</option>`;
|
||||
}).join('');
|
||||
|
||||
const rightOpts = [
|
||||
`<option value="current" ${state.rightVersion === null ? 'selected' : ''}>Version actuelle</option>`,
|
||||
...b.map(bk => {
|
||||
const sel = bk.timestamp === state.rightVersion ? 'selected' : '';
|
||||
return `<option value="${bk.timestamp}" ${sel}>${formatTimestamp(bk.timestamp)} (${formatSize(bk.size)})</option>`;
|
||||
}),
|
||||
].join('');
|
||||
|
||||
const html = `
|
||||
<div class="backup-header">
|
||||
<div class="backup-header-left">
|
||||
<h3>Backups</h3>
|
||||
<span class="backup-file-path">${escapeHtml(state.path)}</span>
|
||||
</div>
|
||||
<div class="backup-header-right">
|
||||
<span class="backup-count">${b.length} backup${b.length > 1 ? 's' : ''}</span>
|
||||
<button class="backup-close-btn" title="Fermer">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="backup-toolbar">
|
||||
<div class="backup-selector-group">
|
||||
<label>Comparer :</label>
|
||||
<select class="backup-select" id="backup-left-select">${leftOpts}</select>
|
||||
</div>
|
||||
<span class="backup-vs-label">vs</span>
|
||||
<div class="backup-selector-group">
|
||||
<select class="backup-select" id="backup-right-select">${rightOpts}</select>
|
||||
</div>
|
||||
<button class="backup-restore-btn" id="backup-restore-btn" title="Restaurer la version de gauche">
|
||||
<i data-lucide="rotate-ccw" class="icon-sm"></i> Restaurer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="backup-diff-container" id="backup-diff-container">
|
||||
<div class="backup-diff-loading">
|
||||
<div class="loading-spinner" style="width:24px;height:24px;border-width:3px;margin:0 auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="backup-footer">
|
||||
<span class="backup-footer-hint">Backups automatiques avant chaque modification</span>
|
||||
</div>`;
|
||||
|
||||
setBody(html);
|
||||
|
||||
// Bind events
|
||||
bindEvents();
|
||||
safeCreateIcons();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event bindings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function bindEvents() {
|
||||
const panel = document.getElementById('backup-viewer-panel');
|
||||
if (!panel) return;
|
||||
|
||||
// Close button
|
||||
panel.querySelector('.backup-close-btn')?.addEventListener('click', close);
|
||||
|
||||
// Left select
|
||||
const leftSelect = document.getElementById('backup-left-select');
|
||||
leftSelect?.addEventListener('change', async () => {
|
||||
state.leftVersion = parseInt(leftSelect.value);
|
||||
await loadDiff();
|
||||
});
|
||||
|
||||
// Right select
|
||||
const rightSelect = document.getElementById('backup-right-select');
|
||||
rightSelect?.addEventListener('change', async () => {
|
||||
const val = rightSelect.value;
|
||||
state.rightVersion = val === 'current' ? null : parseInt(val);
|
||||
await loadDiff();
|
||||
});
|
||||
|
||||
// Restore button
|
||||
const restoreBtn = document.getElementById('backup-restore-btn');
|
||||
restoreBtn?.addEventListener('click', async () => {
|
||||
await restoreBackup();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data loading
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function loadDiff() {
|
||||
const container = document.getElementById('backup-diff-container');
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="backup-diff-loading">
|
||||
<div class="loading-spinner" style="width:24px;height:24px;border-width:3px;margin:0 auto"></div>
|
||||
</div>`;
|
||||
|
||||
let url = `/api/file/${encodeURIComponent(state.vault)}/diff?path=${encodeURIComponent(state.path)}&version=${state.leftVersion}`;
|
||||
if (state.rightVersion !== null) {
|
||||
url += `&compare_with=${state.rightVersion}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await api(url);
|
||||
state.diffText = data.diff || '';
|
||||
container.innerHTML = renderDiff(state.diffText);
|
||||
} catch (err) {
|
||||
container.innerHTML = `<div class="backup-diff-error">Erreur: ${escapeHtml(err?.detail || err?.message || 'Unknown')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreBackup() {
|
||||
if (!state.leftVersion) return;
|
||||
|
||||
const confirmed = confirm(
|
||||
`Restaurer "${state.path}" à la version du ${formatTimestamp(state.leftVersion)} ?\n\n` +
|
||||
`La version actuelle sera sauvegardée avant la restauration.`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
const restoreBtn = document.getElementById('backup-restore-btn');
|
||||
if (restoreBtn) {
|
||||
restoreBtn.disabled = true;
|
||||
restoreBtn.textContent = 'Restauration...';
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await api(
|
||||
`/api/file/${encodeURIComponent(state.vault)}/restore?path=${encodeURIComponent(state.path)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ version: state.leftVersion }),
|
||||
}
|
||||
);
|
||||
|
||||
if (resp && resp.success) {
|
||||
showToast('Fichier restauré avec succès', 'success');
|
||||
// Refresh sidebar and close
|
||||
await refreshSidebarTreePreservingState();
|
||||
close();
|
||||
// Reload current file if open
|
||||
if (typeof TabManager !== 'undefined' && TabManager.reloadCurrent) {
|
||||
TabManager.reloadCurrent();
|
||||
}
|
||||
} else {
|
||||
showToast('Échec de la restauration', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(`Erreur: ${err?.detail || err?.message || 'Unknown'}`, 'error');
|
||||
} finally {
|
||||
if (restoreBtn) {
|
||||
restoreBtn.disabled = false;
|
||||
restoreBtn.innerHTML = '<i data-lucide="rotate-ccw" class="icon-sm"></i> Restaurer';
|
||||
safeCreateIcons();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Diff rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderDiff(diffText) {
|
||||
if (!diffText) {
|
||||
return '<div class="backup-diff-empty">Aucune différence — les versions sont identiques.</div>';
|
||||
}
|
||||
|
||||
let html = '<div class="backup-diff-output"><table class="backup-diff-table">';
|
||||
const lines = diffText.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
let cssClass = 'diff-context';
|
||||
let prefix = ' ';
|
||||
|
||||
if (line.startsWith('@@')) {
|
||||
cssClass = 'diff-hunk';
|
||||
prefix = '';
|
||||
} else if (line.startsWith('+')) {
|
||||
cssClass = 'diff-add';
|
||||
prefix = '+';
|
||||
} else if (line.startsWith('-')) {
|
||||
cssClass = 'diff-del';
|
||||
prefix = '-';
|
||||
} else if (line.startsWith('---') || line.startsWith('+++')) {
|
||||
cssClass = 'diff-header-line';
|
||||
prefix = '';
|
||||
}
|
||||
|
||||
html += `<tr class="${cssClass}"><td class="diff-prefix">${prefix}</td><td class="diff-content"><pre>${escapeHtml(line)}</pre></td></tr>`;
|
||||
}
|
||||
|
||||
html += '</table></div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatTimestamp(ts) {
|
||||
const d = new Date(ts * 1000);
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' o';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' Ko';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' Mo';
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
@@ -1675,6 +1675,8 @@ export const ContextMenuManager = {
|
||||
this._addItem('trash-2', 'Supprimer', () => this._deleteFile(), isReadonly);
|
||||
this._addSeparator();
|
||||
this._addItem('bookmark-plus', 'Ajouter aux bookmarks', () => this._toggleBookmark(), false);
|
||||
this._addSeparator();
|
||||
this._addItem('history', 'Backups / Versions', () => this._openBackupViewer(), false);
|
||||
}
|
||||
|
||||
this._menu.classList.add('active');
|
||||
@@ -1800,6 +1802,16 @@ export const ContextMenuManager = {
|
||||
DashboardBookmarkWidget.load();
|
||||
}
|
||||
} catch (err) { showToast("Erreur: " + err.message, "error"); }
|
||||
},
|
||||
|
||||
_openBackupViewer() {
|
||||
// Dynamically import the backup viewer module to avoid circular deps
|
||||
import('./backups.js').then(mod => {
|
||||
mod.openBackupViewer(this._targetVault, this._targetPath);
|
||||
}).catch(err => {
|
||||
showToast("Erreur lors du chargement du viewer: " + err.message, "error");
|
||||
});
|
||||
this.hide();
|
||||
}
|
||||
};
|
||||
export const TabManager = {
|
||||
|
||||
@@ -6868,3 +6868,282 @@ body.popup-mode .content-area {
|
||||
background: rgba(239,68,68,0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Backup / Diff Viewer Modal
|
||||
========================================================================== */
|
||||
|
||||
.backup-viewer-panel {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: min(90vw, 960px);
|
||||
height: min(85vh, 700px);
|
||||
background: var(--bg-primary, #1e1e2e);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 25px 60px rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.backup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.backup-header-left {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.backup-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.backup-file-path {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono, 'SF Mono', 'Fira Code', monospace);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.backup-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.backup-count {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-tertiary);
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.backup-close-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.4rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.backup-close-btn:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.backup-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.backup-selector-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.backup-selector-group label {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.backup-select {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.8rem;
|
||||
font-family: var(--font-mono);
|
||||
max-width: 240px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.backup-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.backup-vs-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.backup-restore-btn {
|
||||
margin-left: auto;
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
color: var(--accent);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.backup-restore-btn:hover {
|
||||
background: color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.backup-restore-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.backup-restore-btn .icon-sm {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.backup-diff-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.backup-diff-loading,
|
||||
.backup-diff-error,
|
||||
.backup-diff-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.backup-diff-error {
|
||||
color: var(--danger, #ef4444);
|
||||
}
|
||||
|
||||
.backup-diff-output {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.backup-diff-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-family: var(--font-mono, 'SF Mono', 'Fira Code', 'Cascadia Code', monospace);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.55;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.backup-diff-table td {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.backup-diff-table td.diff-prefix {
|
||||
width: 22px;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
font-weight: 600;
|
||||
padding: 1px 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.backup-diff-table td.diff-content {
|
||||
padding: 1px 8px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.backup-diff-table td.diff-content pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.backup-diff-table tr.diff-add td {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
}
|
||||
|
||||
.backup-diff-table tr.diff-add td.diff-prefix {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.backup-diff-table tr.diff-del td {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
}
|
||||
|
||||
.backup-diff-table tr.diff-del td.diff-prefix {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.backup-diff-table tr.diff-hunk td {
|
||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.backup-diff-table tr.diff-header-line td {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.backup-diff-table tr.diff-context td {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.backup-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.backup-footer {
|
||||
padding: 6px 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.backup-footer-hint {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user