diff --git a/backend/main.py b/backend/main.py
index f6b7e4f..43f19b9 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -735,6 +735,17 @@ def _backup_file(file_path: Path, vault_name: str, relative_path: str):
backup_path = backup_dir / backup_name
shutil.copy2(file_path, backup_path)
logger.info(f"Backup created: {relative_path} -> {backup_path}")
+
+ # Auto-cleanup: keep only the N most recent backups
+ config = _load_config()
+ max_backups = config.get("max_backups_per_file", 10)
+ all_backups = sorted(
+ [f for f in backup_dir.iterdir() if f.is_file() and f.name.startswith(file_path.name + ".") and f.name.endswith(".bak")],
+ key=lambda f: f.stat().st_mtime, reverse=True
+ )
+ for old in all_backups[max_backups:]:
+ old.unlink()
+ logger.debug(f"Auto-cleanup: removed old backup {old.name}")
except Exception as e:
logger.warning(f"Failed to backup {relative_path} (vault={vault_name}): {e}", exc_info=True)
@@ -3258,6 +3269,132 @@ async def api_get_all_vault_settings(current_user=Depends(require_auth)):
return all_settings
+# ---------------------------------------------------------------------------
+# Backup Management API
+# ---------------------------------------------------------------------------
+
+@app.get("/api/backups")
+async def api_backups_list(
+ vault: Optional[str] = Query(None, description="Filter by vault name"),
+ current_user=Depends(require_auth),
+):
+ """List all backups across vaults, grouped by file."""
+ result = []
+ for vault_name in index.keys():
+ if vault and vault_name != vault:
+ continue
+ 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_backup_dir = backup_root / vault_name
+ if not vault_backup_dir.exists():
+ continue
+ for fpath in vault_backup_dir.rglob("*.bak"):
+ if not fpath.is_file():
+ continue
+ stat = fpath.st_size
+ ts_part = fpath.name.rsplit(".", 2)
+ if len(ts_part) < 3 or not ts_part[-2].isdigit():
+ continue
+ ts = int(ts_part[-2])
+ rel_dir = str(fpath.parent.relative_to(vault_backup_dir)).replace("\\", "/")
+ rel_file = rel_dir + "/" + ts_part[0] if rel_dir != "." else ts_part[0]
+ result.append({
+ "vault": vault_name,
+ "file": rel_file,
+ "backup_file": fpath.name,
+ "timestamp": ts,
+ "datetime": datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(),
+ "size": stat,
+ "full_path": str(fpath),
+ })
+
+ result.sort(key=lambda x: x["timestamp"], reverse=True)
+ total_size = sum(r["size"] for r in result)
+ return {"backups": result, "total": len(result), "total_size_bytes": total_size}
+
+
+@app.post("/api/backups/delete")
+async def api_backups_delete(
+ body: dict = Body(...),
+ current_user=Depends(require_auth),
+):
+ """Delete one or more backup files."""
+ paths = body.get("paths", [])
+ if not paths:
+ raise HTTPException(status_code=400, detail="No backup paths provided")
+
+ deleted = 0
+ for p in paths:
+ try:
+ fpath = Path(p)
+ # Security: ensure path is within a backup directory
+ if ".obsigate-backup" not in str(fpath):
+ continue
+ if fpath.exists() and fpath.is_file():
+ fpath.unlink()
+ deleted += 1
+ except Exception as e:
+ logger.warning(f"Failed to delete backup {p}: {e}")
+
+ return {"deleted": deleted}
+
+
+@app.post("/api/backups/purge")
+async def api_backups_purge(
+ body: dict = Body(...),
+ current_user=Depends(require_auth),
+):
+ """Purge all backups for a specific file or entire vault."""
+ vault_name = body.get("vault")
+ file_path = body.get("file") # optional
+
+ if not vault_name:
+ raise HTTPException(status_code=400, detail="Vault name required")
+
+ if not check_vault_access(vault_name, current_user):
+ raise HTTPException(status_code=403, detail="Access denied")
+
+ vd = get_vault_data(vault_name)
+ if not vd:
+ raise HTTPException(status_code=404, detail="Vault not found")
+
+ 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
+
+ if file_path:
+ # Delete backups for specific file
+ backup_dir = backup_root / vault_name / Path(file_path).parent
+ if backup_dir.exists():
+ fname = Path(file_path).name
+ deleted = 0
+ for f in backup_dir.iterdir():
+ if f.is_file() and f.name.startswith(fname + ".") and f.name.endswith(".bak"):
+ f.unlink()
+ deleted += 1
+ return {"deleted": deleted}
+ return {"deleted": 0}
+ else:
+ # Delete all backups for vault
+ vault_backup_dir = backup_root / vault_name
+ if vault_backup_dir.exists():
+ deleted = 0
+ for f in vault_backup_dir.rglob("*.bak"):
+ if f.is_file():
+ f.unlink()
+ deleted += 1
+ return {"deleted": deleted}
+ return {"deleted": 0}
+
+
# ---------------------------------------------------------------------------
# Configuration API
# ---------------------------------------------------------------------------
@@ -3283,6 +3420,7 @@ _DEFAULT_CONFIG = {
"tag_boost": 2.0,
"prefix_max_expansions": 50,
"recent_files_limit": 20,
+ "max_backups_per_file": 10,
}
diff --git a/frontend/index.html b/frontend/index.html
index 96308a2..8837a8a 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1284,10 +1284,29 @@
value="20"
/>
Entre 5 et 100 fichiers (sauvegardé sur le
+ >Entre 5 et 100 fichiers (sauvegarde sur le
serveur)
+
+
+
+ Nombre maximum de versions backup conservees par fichier (1-50)
+
diff --git a/frontend/js/backup-manager.js b/frontend/js/backup-manager.js
new file mode 100644
index 0000000..84ecd43
--- /dev/null
+++ b/frontend/js/backup-manager.js
@@ -0,0 +1,200 @@
+/**
+ * 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.
+ */
+import { api } from './auth.js';
+import { showToast } from './ui.js';
+import { safeCreateIcons } from './utils.js';
+
+var state = { backups: [], filter: '' };
+
+export function openBackupManager() {
+ var area = document.getElementById('content-area');
+ if (!area) return;
+ area.innerHTML = '';
+
+ var container = document.createElement('div');
+ container.className = 'backup-manager';
+ container.id = 'backup-manager';
+
+ container.innerHTML =
+ '' +
+ '' +
+ '';
+
+ area.appendChild(container);
+ safeCreateIcons();
+
+ document.getElementById('bm-filter').addEventListener('input', function() {
+ state.filter = this.value.toLowerCase();
+ render();
+ });
+ document.getElementById('bm-refresh').addEventListener('click', load);
+
+ load();
+}
+
+async function load() {
+ var list = document.getElementById('bm-list');
+ list.innerHTML = '';
+
+ try {
+ var data = await api('/api/backups');
+ state.backups = data.backups || [];
+ updateStats(data);
+ render();
+ } catch (err) {
+ list.innerHTML = 'Erreur: ' + escapeHtml(err.message || 'Inconnue') + '
';
+ }
+}
+
+function updateStats(data) {
+ var stats = document.getElementById('bm-stats');
+ if (!stats) return;
+ var totalSize = formatSize(data.total_size_bytes || 0);
+ stats.innerHTML =
+ '' + data.total + ' backups' +
+ '' + totalSize + ' total';
+}
+
+function render() {
+ var list = document.getElementById('bm-list');
+ if (!list) return;
+
+ var filtered = state.backups;
+ if (state.filter) {
+ filtered = state.backups.filter(function(b) {
+ return b.file.toLowerCase().indexOf(state.filter) !== -1 || b.vault.toLowerCase().indexOf(state.filter) !== -1;
+ });
+ }
+
+ if (filtered.length === 0) {
+ list.innerHTML = 'Aucun backup trouve.
';
+ return;
+ }
+
+ // Group by vault
+ var grouped = {};
+ filtered.forEach(function(b) {
+ if (!grouped[b.vault]) grouped[b.vault] = [];
+ grouped[b.vault].push(b);
+ });
+
+ var html = '';
+ var vaultNames = Object.keys(grouped).sort();
+ vaultNames.forEach(function(vault) {
+ html += '';
+ html += '
' + escapeHtml(vault) + ' (' + grouped[vault].length + ')
';
+
+ // Group by file
+ var byFile = {};
+ grouped[vault].forEach(function(b) {
+ if (!byFile[b.file]) byFile[b.file] = [];
+ byFile[b.file].push(b);
+ });
+
+ var fileNames = Object.keys(byFile).sort();
+ fileNames.forEach(function(file) {
+ var backups = byFile[file];
+ backups.sort(function(a, b) { return b.timestamp - a.timestamp; });
+ var totalFileSize = backups.reduce(function(s, b) { return s + b.size; }, 0);
+
+ 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 += '' + ds + '';
+ html += '' + formatSize(b.size) + '';
+ html += '';
+ html += '
';
+ });
+ html += '
';
+ html += '
';
+ });
+
+ html += '
';
+ });
+
+ 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 purge buttons
+ list.querySelectorAll('.bm-file-purge').forEach(function(btn) {
+ btn.addEventListener('click', function() {
+ if (confirm('Supprimer TOUS les backups de "' + btn.dataset.file + '" ?')) {
+ purgeFile(btn.dataset.vault, btn.dataset.file);
+ }
+ });
+ });
+}
+
+async function deleteBackups(paths) {
+ try {
+ var resp = await api('/api/backups/delete', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ paths: paths }),
+ });
+ showToast(resp.deleted + ' backup(s) supprime(s)', 'success');
+ load();
+ } catch (err) {
+ showToast('Erreur: ' + err.message, 'error');
+ }
+}
+
+async function purgeFile(vault, file) {
+ try {
+ var resp = await api('/api/backups/purge', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ vault: vault, file: file }),
+ });
+ 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';
+ return (bytes / (1024 * 1024)).toFixed(1) + ' Mo';
+}
+
+function pad(n) { return String(n).padStart(2, '0'); }
+
+function escapeHtml(str) {
+ var div = document.createElement('div');
+ div.textContent = str;
+ return div.innerHTML;
+}
diff --git a/frontend/js/backups.js b/frontend/js/backups.js
index c51b26b..ff21204 100644
--- a/frontend/js/backups.js
+++ b/frontend/js/backups.js
@@ -213,6 +213,7 @@ function renderMainView() {
'';
setBody(html);
@@ -249,6 +250,14 @@ function bindEvents() {
}
var modeBtn = document.getElementById('backup-diff-mode-btn');
+ var manageBtn = document.getElementById('backup-manage-btn');
+ if (manageBtn) {
+ manageBtn.addEventListener('click', function() {
+ close();
+ import('./backup-manager.js').then(function(m) { m.openBackupManager(); });
+ });
+ }
+
if (modeBtn) {
modeBtn.addEventListener('click', function() {
state.diffMode = state.diffMode === 'sidebyside' ? 'unified' : 'sidebyside';
diff --git a/frontend/js/config.js b/frontend/js/config.js
index ce59ef4..53ff49b 100644
--- a/frontend/js/config.js
+++ b/frontend/js/config.js
@@ -665,6 +665,7 @@ async function loadConfigFields() {
_setField("cfg-tag-boost", data.tag_boost);
_setField("cfg-prefix-exp", data.prefix_max_expansions);
_setField("cfg-recent-limit", data.recent_files_limit || 20);
+ _setField("cfg-max-backups", data.max_backups_per_file || 10);
// Watcher config
_setCheckbox("cfg-watcher-enabled", data.watcher_enabled !== false);
_setCheckbox("cfg-watcher-polling", data.watcher_use_polling === true);
@@ -716,6 +717,7 @@ async function saveBackendConfig() {
tag_boost: _getFieldNum("cfg-tag-boost", 2.0),
prefix_max_expansions: _getFieldNum("cfg-prefix-exp", 50),
recent_files_limit: _getFieldNum("cfg-recent-limit", 20),
+ max_backups_per_file: _getFieldNum("cfg-max-backups", 10),
watcher_enabled: _getCheckbox("cfg-watcher-enabled"),
watcher_use_polling: _getCheckbox("cfg-watcher-polling"),
watcher_polling_interval: _getFieldNum("cfg-watcher-interval", 5.0),
diff --git a/frontend/style.css b/frontend/style.css
index 6639be5..35ae68c 100644
--- a/frontend/style.css
+++ b/frontend/style.css
@@ -7499,3 +7499,119 @@ body.popup-mode .content-area {
width: 14px;
height: 14px;
}
+
+/* ── Backup Manager ── */
+.backup-manager {
+ padding: 16px 24px;
+ max-width: 1000px;
+ margin: 0 auto;
+}
+
+.backup-manager-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 12px;
+ gap: 12px;
+}
+
+.backup-manager-header h2 {
+ margin: 0;
+ font-size: 1.1rem;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.backup-manager-actions {
+ display: flex;
+ gap: 6px;
+}
+
+.backup-manager-filter {
+ padding: 6px 10px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ font-size: 0.8rem;
+ min-width: 200px;
+}
+
+.backup-manager-btn {
+ width: 32px; height: 32px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: transparent;
+ color: var(--text-secondary);
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.backup-manager-btn:hover { color: var(--accent); border-color: var(--accent); }
+
+.backup-manager-stats { display: flex; gap: 16px; margin-bottom: 14px; }
+.bm-stat { font-size: 0.8rem; color: var(--text-muted); }
+
+.bm-vault-group { margin-bottom: 16px; }
+.bm-vault-name {
+ font-size: 0.85rem; font-weight: 600; margin: 0 0 6px 0;
+ display: flex; align-items: center; gap: 6px;
+}
+
+.bm-file-group {
+ border: 1px solid var(--border); border-radius: 6px;
+ margin-bottom: 8px; overflow: hidden;
+}
+
+.bm-file-header {
+ display: flex; align-items: center; gap: 12px;
+ padding: 8px 12px; background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border);
+}
+
+.bm-file-name { font-size: 0.8rem; font-weight: 500; display: flex; align-items: center; gap: 4px; }
+.bm-file-meta { font-size: 0.7rem; color: var(--text-muted); flex: 1; }
+
+.bm-file-purge {
+ width: 28px; height: 28px; border: 1px solid var(--border); border-radius: 4px;
+ background: transparent; color: var(--text-muted); cursor: pointer;
+ display: flex; align-items: center; justify-content: center;
+}
+.bm-file-purge:hover { color: var(--danger); border-color: var(--danger); }
+
+.bm-backup-list { padding: 2px 0; }
+
+.bm-backup-item {
+ display: flex; align-items: center; gap: 12px;
+ padding: 4px 12px; font-size: 0.75rem;
+}
+.bm-backup-item:hover { background: var(--bg-secondary); }
+
+.bm-backup-date { color: var(--text-secondary); min-width: 130px; font-family: var(--font-mono); }
+.bm-backup-size { color: var(--text-muted); flex: 1; }
+
+.bm-backup-delete {
+ 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;
+ opacity: 0;
+}
+.bm-backup-item:hover .bm-backup-delete { opacity: 1; }
+.bm-backup-delete:hover { color: var(--danger); background: rgba(239,68,68,0.1); }
+
+.backup-footer-manage-btn {
+ background: none; border: 1px solid var(--border);
+ border-radius: 4px; color: var(--accent);
+ font-size: 0.7rem; cursor: pointer; padding: 3px 10px;
+ transition: all 150ms ease;
+}
+.backup-footer-manage-btn:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
+
+.backup-manager-empty,
+.backup-manager-error {
+ text-align: center; padding: 2rem; color: var(--text-muted); font-size: 0.85rem;
+}
+.backup-manager-error { color: var(--danger); }