From 47dc79683f0eac459ee9584bfa78c8f2b1e4792c Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 4 Jun 2026 21:36:05 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20backup=20manager=20#15=20#16=20#17=20#1?= =?UTF-8?q?8=20#19=20=E2=80=94=20preview,=20restore,=20purge=20vault,=20co?= =?UTF-8?q?mpress,=20auto-backup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #15: Click backup item to preview content in side panel (100KB limit) #16: Purge all backups per vault (red 'Vider' button) #17: /api/backups/compress — gzip backups older than N days #18: /api/backups/auto — backup files modified since N hours #19: Restore button per backup in manager with timestamp extraction + Professional side-panel preview, action buttons on hover, vault grouping --- backend/main.py | 117 ++++++++++++++++++++++++ frontend/js/backup-manager.js | 164 +++++++++++++++++++++++++++++----- frontend/style.css | 56 ++++++++++++ 3 files changed, 317 insertions(+), 20 deletions(-) diff --git a/backend/main.py b/backend/main.py index 5ccc1d9..87e947c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -3400,6 +3400,123 @@ async def api_backups_purge( return {"deleted": 0} + +@app.get("/api/backups/content") +async def api_backups_content( + path: str = Query(..., description="Full path to backup file"), + current_user=Depends(require_auth), +): + """Return the content of a specific backup file.""" + try: + fpath = Path(path) + if ".obsigate-backup" not in str(fpath): + raise HTTPException(status_code=403, detail="Access denied") + if not fpath.exists() or not fpath.is_file(): + raise HTTPException(status_code=404, detail="Backup not found") + content = fpath.read_text(encoding="utf-8", errors="replace") + # Truncate large files to 100KB + if len(content) > 102400: + content = content[:102400] + "\n\n... (tronque a 100 Ko)" + return {"content": content, "name": fpath.name, "size": len(content)} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/backups/compress") +async def api_backups_compress( + body: dict = Body(...), + current_user=Depends(require_auth), +): + """Compress backups older than N days. Body: {older_than_days: 30, dry_run: false}""" + import gzip as gz_mod + older_than = body.get("older_than_days", 30) + dry_run = body.get("dry_run", False) + cutoff = time.time() - (older_than * 86400) + compressed = 0 + saved_bytes = 0 + + for vault_name in index.keys(): + if not check_vault_access(vault_name, current_user): + continue + vd = get_vault_data(vault_name) + if not vd: + continue + vault_root = Path(vd["path"]) + backup_root = Path(os.environ.get("OBSIGATE_BACKUP_DIR", ".obsigate-backup")) + if not backup_root.is_absolute(): + backup_root = vault_root / backup_root + vault_dir = backup_root / vault_name + if not vault_dir.exists(): + continue + for fpath in vault_dir.rglob("*.bak"): + if not fpath.is_file(): + continue + if fpath.name.endswith(".bak.gz"): + continue + mtime = fpath.stat().st_mtime + if mtime > cutoff: + continue + if not dry_run: + try: + gz_path = fpath.with_suffix(fpath.suffix + ".gz") + data = fpath.read_bytes() + with gz_mod.open(str(gz_path), "wb", compresslevel=6) as gzf: + gzf.write(data) + orig_size = len(data) + gz_size = gz_path.stat().st_size + if gz_size < orig_size: + fpath.unlink() + saved_bytes += (orig_size - gz_size) + else: + gz_path.unlink() # compression didn't help + compressed += 1 + except Exception as e: + logger.warning(f"Failed to compress {fpath}: {e}") + else: + compressed += 1 + + return {"compressed": compressed, "saved_bytes": saved_bytes, "dry_run": dry_run} + + +@app.post("/api/backups/auto") +async def api_backups_auto( + body: dict = Body(...), + current_user=Depends(require_auth), +): + """Create backups for files modified since a given time. Body: {since_hours: 24}""" + since_hours = body.get("since_hours", 24) + cutoff = time.time() - (since_hours * 3600) + backed_up = 0 + + for vault_name in index.keys(): + if not check_vault_access(vault_name, current_user): + continue + vd = get_vault_data(vault_name) + if not vd: + continue + vault_root = Path(vd["path"]) + for fpath in vault_root.rglob("*"): + if not fpath.is_file(): + continue + if fpath.name.startswith('.'): + continue + if any(p.startswith('.') or p in {'.obsidian', '.trash', '.git', '.obsigate-backup', '__pycache__', 'node_modules'} for p in fpath.relative_to(vault_root).parts): + continue + mtime = fpath.stat().st_mtime + if mtime < cutoff: + continue + try: + rel = str(fpath.relative_to(vault_root)).replace("\\", "/") + _backup_file(fpath, vault_name, rel) + backed_up += 1 + except Exception as e: + logger.warning(f"Auto-backup failed for {rel}: {e}") + + return {"backed_up": backed_up, "since_hours": since_hours} + + # --------------------------------------------------------------------------- # Configuration API # --------------------------------------------------------------------------- diff --git a/frontend/js/backup-manager.js b/frontend/js/backup-manager.js index 84ecd43..d9c8e26 100644 --- a/frontend/js/backup-manager.js +++ b/frontend/js/backup-manager.js @@ -1,14 +1,14 @@ /** * Backup Manager module for ObsiGate. * - * Provides a full-page view to browse, inspect, and delete all backups - * across all vaults. Accessible from the backup/diff viewer. + * Full-page view to browse, preview, restore, and delete backups across vaults. + * Accessible from the backup/diff viewer footer. */ import { api } from './auth.js'; import { showToast } from './ui.js'; import { safeCreateIcons } from './utils.js'; -var state = { backups: [], filter: '' }; +var state = { backups: [], filter: '', preview: null }; export function openBackupManager() { var area = document.getElementById('content-area'); @@ -28,9 +28,14 @@ export function openBackupManager() { '' + '' + '
' + - '
' + - '
' + - '
' + + '
' + + '
' + + '' + '
'; @@ -42,7 +47,11 @@ export function openBackupManager() { render(); }); document.getElementById('bm-refresh').addEventListener('click', load); + document.getElementById('bm-preview-close').addEventListener('click', function() { + document.getElementById('bm-preview').style.display = 'none'; + }); + state.preview = null; load(); } @@ -85,7 +94,6 @@ function render() { return; } - // Group by vault var grouped = {}; filtered.forEach(function(b) { if (!grouped[b.vault]) grouped[b.vault] = []; @@ -95,12 +103,18 @@ function render() { var html = ''; var vaultNames = Object.keys(grouped).sort(); vaultNames.forEach(function(vault) { + var vaultBackups = grouped[vault]; + var vaultSize = vaultBackups.reduce(function(s, b) { return s + b.size; }, 0); + html += '
'; - html += '

' + escapeHtml(vault) + ' (' + grouped[vault].length + ')

'; + html += '
'; + html += '

' + escapeHtml(vault) + '

'; + html += '' + vaultBackups.length + ' backups · ' + formatSize(vaultSize) + ''; + html += ''; + html += '
'; - // Group by file var byFile = {}; - grouped[vault].forEach(function(b) { + vaultBackups.forEach(function(b) { if (!byFile[b.file]) byFile[b.file] = []; byFile[b.file].push(b); }); @@ -115,17 +129,21 @@ function render() { html += '
'; html += ' ' + escapeHtml(file) + ''; html += '' + backups.length + ' versions · ' + formatSize(totalFileSize) + ''; - html += ''; + html += ''; html += '
'; html += '
'; backups.forEach(function(b) { var d = new Date(b.timestamp * 1000); var ds = d.getFullYear() + '-' + pad(d.getMonth()+1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()); - html += '
'; + html += '
'; html += '' + ds + ''; html += '' + formatSize(b.size) + ''; - html += ''; + html += '
'; + html += ''; + html += ''; + html += ''; + html += '
'; html += '
'; }); html += '
'; @@ -138,23 +156,115 @@ function render() { list.innerHTML = html; safeCreateIcons(); - // Bind delete buttons - list.querySelectorAll('.bm-backup-delete').forEach(function(btn) { - btn.addEventListener('click', function() { - if (confirm('Supprimer ce backup ?')) { - deleteBackups([btn.dataset.path]); + // Bind vault purge + list.querySelectorAll('.bm-vault-purge').forEach(function(btn) { + btn.addEventListener('click', function(e) { + e.stopPropagation(); + if (confirm('Supprimer TOUS les backups du vault "' + btn.dataset.vault + '" ?')) { + purgeVault(btn.dataset.vault); } }); }); - // Bind purge buttons + // Bind file purge list.querySelectorAll('.bm-file-purge').forEach(function(btn) { - btn.addEventListener('click', function() { + btn.addEventListener('click', function(e) { + e.stopPropagation(); if (confirm('Supprimer TOUS les backups de "' + btn.dataset.file + '" ?')) { purgeFile(btn.dataset.vault, btn.dataset.file); } }); }); + + // Bind backup item clicks (preview) and action buttons + list.querySelectorAll('.bm-backup-item').forEach(function(item) { + item.addEventListener('click', function(e) { + // Don't trigger preview if clicking an action button + if (e.target.closest('button')) return; + state.preview = item.dataset.path; + render(); + previewBackup(item.dataset.path); + }); + }); + + // Bind action buttons + list.querySelectorAll('.bm-backup-preview').forEach(function(btn) { + btn.addEventListener('click', function(e) { + e.stopPropagation(); + var item = btn.closest('.bm-backup-item'); + state.preview = item.dataset.path; + render(); + previewBackup(item.dataset.path); + }); + }); + + list.querySelectorAll('.bm-backup-restore').forEach(function(btn) { + btn.addEventListener('click', function(e) { + e.stopPropagation(); + var item = btn.closest('.bm-backup-item'); + restoreBackup(item.dataset.vault, item.dataset.file); + }); + }); + + list.querySelectorAll('.bm-backup-delete').forEach(function(btn) { + btn.addEventListener('click', function(e) { + e.stopPropagation(); + if (confirm('Supprimer ce backup ?')) { + deleteBackups([btn.dataset.path]); + } + }); + }); +} + +async function previewBackup(fpath) { + var panel = document.getElementById('bm-preview'); + var content = document.getElementById('bm-preview-content'); + var title = document.getElementById('bm-preview-title'); + panel.style.display = 'block'; + content.textContent = 'Chargement...'; + + try { + var data = await api('/api/backups/content?path=' + encodeURIComponent(fpath)); + content.textContent = data.content || '(fichier vide)'; + title.textContent = 'Apercu - ' + (data.name || ''); + } catch (err) { + content.textContent = 'Erreur: ' + (err.message || 'Inconnue'); + } +} + +async function restoreBackup(vault, file) { + // Find the backup timestamp from the currently selected backup + var item = document.querySelector('.bm-backup-item.active'); + if (!item) { showToast('Selectionnez un backup a restaurer', 'error'); return; } + + var backupPath = item.dataset.path; + // Extract timestamp from the filename + var parts = backupPath.replace(/\\/g, '/').split('/').pop().split('.'); + // The timestamp is the second-to-last component before .bak + var ts = null; + for (var i = parts.length - 1; i >= 0; i--) { + if (parts[i] === 'bak' && i > 0 && /^\d+$/.test(parts[i-1])) { + ts = parseInt(parts[i-1]); + break; + } + } + if (!ts) { showToast('Impossible de trouver le timestamp du backup', 'error'); return; } + + if (!confirm('Restaurer "' + file + '" a la version du backup selectionne ?\nLa version actuelle sera sauvegardee.')) return; + + try { + var resp = await api('/api/file/' + encodeURIComponent(vault) + '/restore?path=' + encodeURIComponent(file), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ version: ts }), + }); + if (resp && resp.success) { + showToast('Fichier restaure avec succes', 'success'); + load(); + } + } catch (err) { + showToast('Erreur: ' + (err.message || 'Inconnue'), 'error'); + } } async function deleteBackups(paths) { @@ -185,6 +295,20 @@ async function purgeFile(vault, file) { } } +async function purgeVault(vault) { + try { + var resp = await api('/api/backups/purge', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ vault: vault }), + }); + showToast(resp.deleted + ' backup(s) supprime(s)', 'success'); + load(); + } catch (err) { + showToast('Erreur: ' + err.message, 'error'); + } +} + function formatSize(bytes) { if (bytes < 1024) return bytes + ' o'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' Ko'; diff --git a/frontend/style.css b/frontend/style.css index 35ae68c..6d255c4 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -7615,3 +7615,59 @@ body.popup-mode .content-area { text-align: center; padding: 2rem; color: var(--text-muted); font-size: 0.85rem; } .backup-manager-error { color: var(--danger); } + +/* Manager body: list + preview side panel */ +.backup-manager-body { display: flex; gap: 0; min-height: 400px; } + +.backup-manager-list { flex: 1; min-width: 0; } + +.backup-manager-preview { + width: 400px; flex-shrink: 0; + border-left: 1px solid var(--border); + background: var(--bg-primary); + display: flex; flex-direction: column; +} +.bm-preview-header { + display: flex; align-items: center; justify-content: space-between; + padding: 10px 14px; border-bottom: 1px solid var(--border); +} +.bm-preview-header h3 { margin: 0; font-size: 0.85rem; } +.bm-preview-content { + flex: 1; overflow: auto; padding: 12px 14px; + font-family: var(--font-mono); font-size: 0.75rem; line-height: 1.55; + white-space: pre-wrap; word-break: break-all; + color: var(--text-secondary); margin: 0; +} + +/* Vault header with purge button */ +.bm-vault-header { + display: flex; align-items: center; gap: 12px; + padding: 8px 0; border-bottom: 1px solid var(--border); margin-bottom: 8px; +} +.bm-vault-meta { font-size: 0.7rem; color: var(--text-muted); flex: 1; } + +.bm-vault-purge { + padding: 3px 10px; border: 1px solid var(--border); border-radius: 4px; + background: transparent; color: var(--danger); cursor: pointer; + font-size: 0.7rem; display: flex; align-items: center; gap: 4px; + transition: all 150ms ease; +} +.bm-vault-purge:hover { background: rgba(239,68,68,0.08); border-color: var(--danger); } + +/* Backup item actions */ +.bm-backup-actions { display: flex; gap: 2px; opacity: 0; transition: opacity 0.12s; } +.bm-backup-item:hover .bm-backup-actions { opacity: 1; } + +.bm-backup-preview, +.bm-backup-restore { + width: 22px; height: 22px; border: none; border-radius: 4px; + background: transparent; color: var(--text-muted); cursor: pointer; + display: flex; align-items: center; justify-content: center; +} +.bm-backup-preview:hover { color: var(--accent); background: rgba(59,130,246,0.1); } +.bm-backup-restore:hover { color: #22c55e; background: rgba(34,197,94,0.1); } + +.bm-backup-item.active { + background: color-mix(in srgb, var(--accent) 10%, transparent); + border-radius: 4px; +}