/** * 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'; // Global handler: same pattern as Graph View's initGraphView() document.addEventListener('click', function(e) { const btn = e.target.closest('#backup-close'); if (btn && state.overlay) close(); }); // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- const state = { vault: null, path: null, backups: [], overlay: null, leftVersion: null, rightVersion: null, diffText: '', diffLeft: '', diffRight: '', diffMode: 'unified', // 'unified' or 'sidebyside' }; // --------------------------------------------------------------------------- // 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(); }); // Side-by-side toggle const modeBtn = document.getElementById('backup-diff-mode-btn'); modeBtn?.addEventListener('click', () => { state.diffMode = state.diffMode === 'sidebyside' ? 'unified' : 'sidebyside'; const container = document.getElementById('backup-diff-container'); if (container) { container.innerHTML = state.diffMode === 'sidebyside' ? renderDiffSideBySide(state.diffLeft, state.diffRight) : renderDiff(state.diffText); } // Update icon and text const icon = modeBtn.querySelector('i'); if (icon) icon.setAttribute('data-lucide', state.diffMode === 'sidebyside' ? 'columns-2' : 'align-justify'); modeBtn.childNodes.forEach(n => { if (n.nodeType === 3) n.remove(); }); modeBtn.appendChild(document.createTextNode(state.diffMode === 'sidebyside' ? ' Unifiee' : ' Cote-a-cote')); safeCreateIcons(); }); } // --------------------------------------------------------------------------- // 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 || ''; state.diffLeft = data.left_content || ''; state.diffRight = data.right_content || ''; container.innerHTML = state.diffMode === 'sidebyside' ? renderDiffSideBySide(state.diffLeft, state.diffRight) : 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; } // --------------------------------------------------------------------------- // Side-by-side diff rendering // --------------------------------------------------------------------------- function renderDiffSideBySide(leftText, rightText) { if (!leftText && !rightText) { return '
Aucune différence — les versions sont identiques.
'; } const leftLines = (leftText || '').split('\n'); const rightLines = (rightText || '').split('\n'); // Compute matching opcodes using difflib-style SequenceMatcher // We build two arrays of line groups: equal, delete (left-only), insert (right-only), replace const sm = new difflibSeqMatcher(leftLines, rightLines); const ops = sm.getOpcodes(); let html = '
'; for (const [tag, i1, i2, j1, j2] of ops) { if (tag === 'equal') { for (let k = 0; k < (i2 - i1); k++) { html += ``; } } else if (tag === 'delete') { for (let k = i1; k < i2; k++) { html += ``; } } else if (tag === 'insert') { for (let k = j1; k < j2; k++) { html += ``; } } else if (tag === 'replace') { const maxLen = Math.max(i2 - i1, j2 - j1); for (let k = 0; k < maxLen; k++) { const leftLine = (i1 + k < i2) ? escapeHtml(leftLines[i1 + k]) : ''; const rightLine = (j1 + k < j2) ? escapeHtml(rightLines[j1 + k]) : ''; const leftClass = (i1 + k < i2) ? 'diff-del' : 'diff-empty-cell'; const rightClass = (j1 + k < j2) ? 'diff-add' : 'diff-empty-cell'; html += ``; } } } html += '
${escapeHtml(leftLines[i1 + k])}
${escapeHtml(rightLines[j1 + k])}
${escapeHtml(leftLines[k])}
${escapeHtml(rightLines[k])}
${leftLine}
${rightLine}
'; return html; } // Minimal SequenceMatcher for line-based diff (no external deps) class difflibSeqMatcher { constructor(a, b) { this.a = a; this.b = b; } getOpcodes() { // Simple LCS-based diff using dynamic programming const m = this.a.length, n = this.b.length; const dp = Array.from({length: m + 1}, () => new Array(n + 1).fill(0)); for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { if (this.a[i - 1] === this.b[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1; else dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); } } // Backtrack to build opcodes const ops = []; let i = m, j = n; const equalStart = (i2, j2) => { let ei = i2, ej = j2; while (ei > 0 && ej > 0 && this.a[ei - 1] === this.b[ej - 1]) { ei--; ej--; } return [ei, ej]; }; while (i > 0 || j > 0) { if (i > 0 && j > 0 && this.a[i - 1] === this.b[j - 1]) { const [ei, ej] = equalStart(i, j); if (ei < i) ops.unshift(['equal', ei, i, ej, j]); i = ei; j = ej; } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { ops.unshift(['insert', i, i, j - 1, j]); j--; } else if (i > 0 && (j === 0 || dp[i][j - 1] < dp[i - 1][j])) { ops.unshift(['delete', i - 1, i, j, j]); i--; } } // Merge adjacent delete+insert into replace const merged = []; for (let k = 0; k < ops.length; k++) { if (merged.length > 0 && merged[merged.length - 1][0] === 'delete' && ops[k][0] === 'insert' && merged[merged.length - 1][4] === ops[k][3]) { const del = merged.pop(); merged.push(['replace', del[1], del[2], ops[k][3], ops[k][4]]); } else { merged.push(ops[k]); } } return merged; } } // --------------------------------------------------------------------------- // 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; }