/** * 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, rightVersion: null, diffText: '', }; // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- export async function openBackupViewer(vault, path) { state.vault = vault; state.path = path; state.backups = []; state.leftVersion = null; state.rightVersion = null; state.diffText = ''; const overlay = createOverlay(); document.body.appendChild(overlay); state.overlay = overlay; requestAnimationFrame(() => overlay.classList.add('active')); setBody(renderLoading()); try { const data = await api(`/api/file/${encodeURIComponent(vault)}/backups?path=${encodeURIComponent(path)}`); state.backups = data.backups || []; } catch (err) { const msg = err?.detail || err?.message || 'Erreur inconnue'; setBody(renderError(`Erreur API: ${msg}`)); return; } if (state.backups.length === 0) { setBody(renderEmpty()); return; } state.leftVersion = state.backups[0].timestamp; state.rightVersion = null; renderMainView(); 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); // Close button OUTSIDE the panel — never touched by innerHTML, always present const closeBtn = document.createElement('button'); closeBtn.className = 'editor-btn backup-close-btn'; closeBtn.id = 'backup-close'; closeBtn.title = 'Fermer'; closeBtn.style.cssText = 'position:absolute;top:12px;right:12px;z-index:1002;flex-shrink:0'; closeBtn.innerHTML = ''; closeBtn.addEventListener('click', (e) => { e.stopPropagation(); close(); }); overlay.appendChild(closeBtn); // Keyboard: Escape closes the viewer _escHandler = (e) => { if (e.key === 'Escape' && state.overlay) close(); }; document.addEventListener('keydown', _escHandler); return overlay; } /** Replace panel content. Close button is static in the overlay, never replaced. */ function setBody(html) { const panel = document.getElementById('backup-viewer-panel'); if (panel) panel.innerHTML = html; } let _escHandler = null; function close() { if (!state.overlay) return; if (_escHandler) { document.removeEventListener('keydown', _escHandler); _escHandler = null; } state.overlay.classList.remove('active'); setTimeout(() => { if (state.overlay) state.overlay.remove(); state.overlay = null; }, 200); } // --------------------------------------------------------------------------- // Rendering // --------------------------------------------------------------------------- function renderLoading() { return `

Backups

Chargement des backups...

`; } function renderError(msg) { return \`

Backups

${escapeHtml(msg)}

`; } function renderEmpty() { return \`

Backups — \${escapeHtml(state.path)}

Aucun backup disponible pour ce fichier.

Les backups sont créés automatiquement avant chaque modification.

`; } function renderMainView() { const b = state.backups; const leftOpts = b.map(bk => { const sel = bk.timestamp === state.leftVersion ? 'selected' : ''; return ``; }).join(''); const rightOpts = [ ``, ...b.map(bk => { const sel = bk.timestamp === state.rightVersion ? 'selected' : ''; return ``; }), ].join(''); const html = `

Backups

${escapeHtml(state.path)}
\${b.length} backup\${b.length > 1 ? 's' : ''}
vs
`; setBody(html); bindEvents(); } // --------------------------------------------------------------------------- // Event bindings // --------------------------------------------------------------------------- function bindEvents() { const leftSelect = document.getElementById('backup-left-select'); leftSelect?.addEventListener('change', async () => { state.leftVersion = parseInt(leftSelect.value); await loadDiff(); }); const rightSelect = document.getElementById('backup-right-select'); rightSelect?.addEventListener('change', async () => { const val = rightSelect.value; state.rightVersion = val === 'current' ? null : parseInt(val); await loadDiff(); }); 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 = `
`; 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 = `
Erreur: ${escapeHtml(err?.detail || err?.message || 'Unknown')}
`; } } 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'); await refreshSidebarTreePreservingState(); close(); 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 = ' Restaurer'; safeCreateIcons(); } } } // --------------------------------------------------------------------------- // Diff rendering // --------------------------------------------------------------------------- function renderDiff(diffText) { if (!diffText) { return '
Aucune différence — les versions sont identiques.
'; } let html = '
'; 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 += ``; } html += '
${prefix}
${escapeHtml(line)}
'; 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; }