/**
* 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, // timestamp of backup shown on left
rightVersion: null, // timestamp of backup shown on right, or null for current
diffText: '',
};
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Open the backup/diff viewer for a file.
* @param {string} vault - Vault name
* @param {string} path - Relative file path
*/
export async function openBackupViewer(vault, path) {
state.vault = vault;
state.path = path;
state.backups = [];
state.leftVersion = null;
state.rightVersion = null;
state.diffText = '';
// Create and show overlay
const overlay = createOverlay();
document.body.appendChild(overlay);
state.overlay = overlay;
requestAnimationFrame(() => overlay.classList.add('active'));
// Show loading state
setBody(renderLoading());
// Fetch backups
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;
}
// Auto-select: left = most recent, right = current
state.leftVersion = state.backups[0].timestamp;
state.rightVersion = null;
// Render version selectors
renderMainView();
// Load initial diff
await loadDiff();
}
// ---------------------------------------------------------------------------
// DOM helpers
// ---------------------------------------------------------------------------
// Global click handler: catches close button clicks anywhere in the document
document.addEventListener('click', function(e) {
const btn = e.target.closest ? e.target.closest('.backup-close-btn') : null;
if (btn && state.overlay) {
e.stopPropagation();
e.preventDefault();
close();
}
}, true); // capture phase — fires before other handlers
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);
// Keyboard: Escape closes the viewer
_escHandler = (e) => {
if (e.key === 'Escape' && state.overlay) {
close();
}
};
document.addEventListener('keydown', _escHandler);
return overlay;
}
function setBody(html) {
const panel = document.getElementById('backup-viewer-panel');
if (panel) panel.innerHTML = html;
}
let _escHandler = null;
function close() {
if (!state.overlay) return;
// Remove keyboard listener
if (_escHandler) {
document.removeEventListener('keydown', _escHandler);
_escHandler = null;
}
state.overlay.classList.remove('active');
setTimeout(() => {
if (state.overlay) state.overlay.remove();
state.overlay = null;
}, 200);
}
// Expose globally for inline onclick handlers
window._backupViewerClose = close;
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
function renderLoading() {
return `
Chargement des backups...
`;
}
function renderError(msg) {
return \`
`;
}
function renderEmpty() {
return \`
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 = `
`;
setBody(html);
// Bind events
bindEvents();
safeCreateIcons();
}
// ---------------------------------------------------------------------------
// Event bindings
// ---------------------------------------------------------------------------
function bindEvents() {
const panel = document.getElementById('backup-viewer-panel');
if (!panel) return;
// Left select
const leftSelect = document.getElementById('backup-left-select');
leftSelect?.addEventListener('change', async () => {
state.leftVersion = parseInt(leftSelect.value);
await loadDiff();
});
// Right select
const rightSelect = document.getElementById('backup-right-select');
rightSelect?.addEventListener('change', async () => {
const val = rightSelect.value;
state.rightVersion = val === 'current' ? null : parseInt(val);
await loadDiff();
});
// Restore button
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');
// Refresh sidebar and close
await refreshSidebarTreePreservingState();
close();
// Reload current file if open
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 += `| ${prefix} | ${escapeHtml(line)} |
`;
}
html += '
';
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;
}