/** * 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: '', diffLeft: '', diffRight: '', diffMode: 'unified', }; // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- export async function openBackupViewer(vault, path) { state.vault = vault; state.path = path; state.backups = []; state.leftVersion = null; state.rightVersion = null; state.diffText = ''; state.diffLeft = ''; state.diffRight = ''; const overlay = createOverlay(); document.body.appendChild(overlay); state.overlay = overlay; requestAnimationFrame(function() { overlay.classList.add('active'); }); setBody(renderLoading()); try { var url = '/api/file/' + encodeURIComponent(vault) + '/backups?path=' + encodeURIComponent(path); var data = await api(url); state.backups = data.backups || []; } catch (err) { var msg = err && err.detail || err && 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 // --------------------------------------------------------------------------- var _escHandler = null; function createOverlay() { var overlay = document.createElement('div'); overlay.className = 'obsigate-modal-overlay backup-viewer-overlay'; overlay.addEventListener('click', function(e) { if (e.target === overlay) close(); }); var panel = document.createElement('div'); panel.className = 'backup-viewer-panel'; panel.id = 'backup-viewer-panel'; overlay.appendChild(panel); // Close button -- static, never replaced by innerHTML var 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', function(e) { e.stopPropagation(); close(); }); overlay.appendChild(closeBtn); // Escape key _escHandler = function(e) { if (e.key === 'Escape' && state.overlay) close(); }; document.addEventListener('keydown', _escHandler); return overlay; } function setBody(html) { var panel = document.getElementById('backup-viewer-panel'); if (panel) panel.innerHTML = html; } function close() { if (!state.overlay) return; if (_escHandler) { document.removeEventListener('keydown', _escHandler); _escHandler = null; } state.overlay.classList.remove('active'); setTimeout(function() { if (state.overlay) state.overlay.remove(); state.overlay = null; }, 200); } // Global click handler for close button document.addEventListener('click', function(e) { var btn = e.target.closest ? e.target.closest('#backup-close') : null; if (btn && state.overlay) close(); }); // --------------------------------------------------------------------------- // 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 crees automatiquement avant chaque modification.

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

Backups

' + '' + escapeHtml(state.path) + '' + '
' + '
' + '' + b.length + ' backup' + (b.length > 1 ? 's' : '') + '' + '
' + '
' + '
' + '
' + '' + '' + '
' + 'vs' + '
' + '' + '
' + '' + '' + '
' + '
' + '
' + '
' + '
' + '
' + ''; setBody(html); bindEvents(); } // --------------------------------------------------------------------------- // Event bindings // --------------------------------------------------------------------------- function bindEvents() { var leftSelect = document.getElementById('backup-left-select'); if (leftSelect) { leftSelect.addEventListener('change', async function() { state.leftVersion = parseInt(leftSelect.value); await loadDiff(); }); } var rightSelect = document.getElementById('backup-right-select'); if (rightSelect) { rightSelect.addEventListener('change', async function() { var val = rightSelect.value; state.rightVersion = val === 'current' ? null : parseInt(val); await loadDiff(); }); } var restoreBtn = document.getElementById('backup-restore-btn'); if (restoreBtn) { restoreBtn.addEventListener('click', async function() { await restoreBackup(); }); } 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'; var container = document.getElementById('backup-diff-container'); if (container) { container.innerHTML = state.diffMode === 'sidebyside' ? renderDiffSideBySide(state.diffLeft, state.diffRight) : renderDiff(state.diffText); } var icon = modeBtn.querySelector('i'); if (icon) icon.setAttribute('data-lucide', state.diffMode === 'sidebyside' ? 'columns-2' : 'align-justify'); var textNodes = []; for (var i = 0; i < modeBtn.childNodes.length; i++) { if (modeBtn.childNodes[i].nodeType === 3) textNodes.push(modeBtn.childNodes[i]); } textNodes.forEach(function(n) { n.remove(); }); modeBtn.appendChild(document.createTextNode(state.diffMode === 'sidebyside' ? ' Unifiee' : ' Cote-a-cote')); safeCreateIcons(); }); } } // --------------------------------------------------------------------------- // Data loading // --------------------------------------------------------------------------- async function loadDiff() { var container = document.getElementById('backup-diff-container'); if (!container) return; container.innerHTML = '
' + '
' + '
'; var url = '/api/file/' + encodeURIComponent(state.vault) + '/diff?path=' + encodeURIComponent(state.path) + '&version=' + state.leftVersion; if (state.rightVersion !== null) { url += '&compare_with=' + state.rightVersion; } try { var 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 && err.detail) || (err && err.message) || 'Unknown') + '
'; } } async function restoreBackup() { if (!state.leftVersion) return; var confirmed = confirm( 'Restaurer "' + state.path + '" a la version du ' + formatTimestamp(state.leftVersion) + ' ?\n\n' + 'La version actuelle sera sauvegardee avant la restauration.' ); if (!confirmed) return; var restoreBtn = document.getElementById('backup-restore-btn'); if (restoreBtn) { restoreBtn.disabled = true; restoreBtn.textContent = 'Restauration...'; } try { var 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 restaure avec succes', 'success'); await refreshSidebarTreePreservingState(); close(); if (typeof TabManager !== 'undefined' && TabManager.reloadCurrent) { TabManager.reloadCurrent(); } } else { showToast('Echec de la restauration', 'error'); } } catch (err) { showToast('Erreur: ' + ((err && err.detail) || (err && err.message) || 'Unknown'), 'error'); } finally { if (restoreBtn) { restoreBtn.disabled = false; restoreBtn.innerHTML = ' Restaurer'; safeCreateIcons(); } } } // --------------------------------------------------------------------------- // Diff rendering // --------------------------------------------------------------------------- function renderDiff(diffText) { if (!diffText) { return '
Aucune difference - les versions sont identiques.
'; } var html = '
'; var lines = diffText.split('\n'); for (var i = 0; i < lines.length; i++) { var line = lines[i]; var cssClass = 'diff-context'; var prefix = ' '; if (line.indexOf('@@') === 0) { cssClass = 'diff-hunk'; prefix = ''; } else if (line.indexOf('+') === 0) { cssClass = 'diff-add'; prefix = '+'; } else if (line.indexOf('-') === 0) { cssClass = 'diff-del'; prefix = '-'; } else if (line.indexOf('---') === 0 || line.indexOf('+++') === 0) { 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 difference - les versions sont identiques.
'; } var leftLines = (leftText || '').split('\n'); var rightLines = (rightText || '').split('\n'); var sm = new DifflibSeqMatcher(leftLines, rightLines); var ops = sm.getOpcodes(); var html = '
'; for (var oi = 0; oi < ops.length; oi++) { var op = ops[oi]; var tag = op[0], i1 = op[1], i2 = op[2], j1 = op[3], j2 = op[4]; if (tag === 'equal') { for (var k = 0; k < (i2 - i1); k++) { html += ''; } } else if (tag === 'delete') { for (var k2 = i1; k2 < i2; k2++) { html += ''; } } else if (tag === 'insert') { for (var k3 = j1; k3 < j2; k3++) { html += ''; } } else if (tag === 'replace') { var maxLen = Math.max(i2 - i1, j2 - j1); for (var k4 = 0; k4 < maxLen; k4++) { var leftLine = (i1 + k4 < i2) ? escapeHtml(leftLines[i1 + k4]) : ''; var rightLine = (j1 + k4 < j2) ? escapeHtml(rightLines[j1 + k4]) : ''; var leftClass = (i1 + k4 < i2) ? 'diff-del' : 'diff-empty-cell'; var rightClass = (j1 + k4 < j2) ? 'diff-add' : 'diff-empty-cell'; html += ''; } } } html += '
' + escapeHtml(leftLines[i1 + k]) + '
' + escapeHtml(rightLines[j1 + k]) + '
' + escapeHtml(leftLines[k2]) + '
' + escapeHtml(rightLines[k3]) + '
' + leftLine + '
' + rightLine + '
'; return html; } // Minimal SequenceMatcher for line-based diff function DifflibSeqMatcher(a, b) { this.a = a; this.b = b; } DifflibSeqMatcher.prototype.getOpcodes = function() { var m = this.a.length, n = this.b.length; var dp = []; for (var i = 0; i <= m; i++) { dp[i] = []; for (var j = 0; j <= n; j++) dp[i][j] = 0; } for (var i = 1; i <= m; i++) { for (var 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]); } } var ops = []; var i = m, j = n; while (i > 0 || j > 0) { if (i > 0 && j > 0 && this.a[i - 1] === this.b[j - 1]) { var ei = i - 1, ej = j - 1; while (ei > 0 && ej > 0 && this.a[ei - 1] === this.b[ej - 1]) { ei--; ej--; } 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--; } } var merged = []; for (var 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]) { var 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) { var d = new Date(ts * 1000); var pad = function(n) { return 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) { var div = document.createElement('div'); div.textContent = str; return div.innerHTML; }