feat: diff viewer backups — point 6 (backups, diff, restore)
- Backend: 3 nouveaux endpoints
- GET /api/file/{vault}/backups — liste les backups horodatés
- GET /api/file/{vault}/diff — diff unifié entre versions
- POST /api/file/{vault}/restore — restaure une version (backup auto avant)
- Frontend: module backups.js — UI modale avec:
- Sélecteur gauche (backup) / droite (version actuelle ou autre backup)
- Diff coloré: vert pour ajouts, rouge pour suppressions
- Bouton Restaurer avec confirmation
- Menu contextuel: clic droit fichier → 'Backups / Versions'
- CSS: styles complets pour la modale, toolbar, table de diff
- ROADMAP: point 6 marqué comme complété
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* 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) {
|
||||
setBody(renderError('Failed to load backups'));
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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);
|
||||
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function setBody(html) {
|
||||
const panel = document.getElementById('backup-viewer-panel');
|
||||
if (panel) panel.innerHTML = html;
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (state.overlay) {
|
||||
state.overlay.classList.remove('active');
|
||||
setTimeout(() => {
|
||||
if (state.overlay) state.overlay.remove();
|
||||
state.overlay = null;
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderLoading() {
|
||||
return `
|
||||
<div class="backup-header">
|
||||
<h3>Backups</h3>
|
||||
<button class="backup-close-btn" title="Fermer">×</button>
|
||||
</div>
|
||||
<div class="backup-body">
|
||||
<div class="tree-loading" style="padding:2rem;text-align:center">
|
||||
<div class="loading-spinner" style="width:24px;height:24px;border-width:3px;margin:0 auto"></div>
|
||||
<p style="margin-top:0.75rem;color:var(--text-muted);font-size:0.85rem">Chargement des backups...</p>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderError(msg) {
|
||||
return `
|
||||
<div class="backup-header">
|
||||
<h3>Backups</h3>
|
||||
<button class="backup-close-btn" title="Fermer">×</button>
|
||||
</div>
|
||||
<div class="backup-body" style="text-align:center;padding:2rem;color:var(--text-muted)">
|
||||
<p>${escapeHtml(msg)}</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderEmpty() {
|
||||
return `
|
||||
<div class="backup-header">
|
||||
<h3>Backups — ${escapeHtml(state.path)}</h3>
|
||||
<button class="backup-close-btn" title="Fermer">×</button>
|
||||
</div>
|
||||
<div class="backup-body" style="text-align:center;padding:2rem;color:var(--text-muted)">
|
||||
<p>Aucun backup disponible pour ce fichier.</p>
|
||||
<p style="font-size:0.8rem;margin-top:0.5rem">Les backups sont créés automatiquement avant chaque modification.</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderMainView() {
|
||||
const b = state.backups;
|
||||
const leftOpts = b.map(bk => {
|
||||
const sel = bk.timestamp === state.leftVersion ? 'selected' : '';
|
||||
return `<option value="${bk.timestamp}" ${sel}>${formatTimestamp(bk.timestamp)} (${formatSize(bk.size)})</option>`;
|
||||
}).join('');
|
||||
|
||||
const rightOpts = [
|
||||
`<option value="current" ${state.rightVersion === null ? 'selected' : ''}>Version actuelle</option>`,
|
||||
...b.map(bk => {
|
||||
const sel = bk.timestamp === state.rightVersion ? 'selected' : '';
|
||||
return `<option value="${bk.timestamp}" ${sel}>${formatTimestamp(bk.timestamp)} (${formatSize(bk.size)})</option>`;
|
||||
}),
|
||||
].join('');
|
||||
|
||||
const html = `
|
||||
<div class="backup-header">
|
||||
<div class="backup-header-left">
|
||||
<h3>Backups</h3>
|
||||
<span class="backup-file-path">${escapeHtml(state.path)}</span>
|
||||
</div>
|
||||
<div class="backup-header-right">
|
||||
<span class="backup-count">${b.length} backup${b.length > 1 ? 's' : ''}</span>
|
||||
<button class="backup-close-btn" title="Fermer">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="backup-toolbar">
|
||||
<div class="backup-selector-group">
|
||||
<label>Comparer :</label>
|
||||
<select class="backup-select" id="backup-left-select">${leftOpts}</select>
|
||||
</div>
|
||||
<span class="backup-vs-label">vs</span>
|
||||
<div class="backup-selector-group">
|
||||
<select class="backup-select" id="backup-right-select">${rightOpts}</select>
|
||||
</div>
|
||||
<button class="backup-restore-btn" id="backup-restore-btn" title="Restaurer la version de gauche">
|
||||
<i data-lucide="rotate-ccw" class="icon-sm"></i> Restaurer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="backup-diff-container" id="backup-diff-container">
|
||||
<div class="backup-diff-loading">
|
||||
<div class="loading-spinner" style="width:24px;height:24px;border-width:3px;margin:0 auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="backup-footer">
|
||||
<span class="backup-footer-hint">Backups automatiques avant chaque modification</span>
|
||||
</div>`;
|
||||
|
||||
setBody(html);
|
||||
|
||||
// Bind events
|
||||
bindEvents();
|
||||
safeCreateIcons();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event bindings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function bindEvents() {
|
||||
const panel = document.getElementById('backup-viewer-panel');
|
||||
if (!panel) return;
|
||||
|
||||
// Close button
|
||||
panel.querySelector('.backup-close-btn')?.addEventListener('click', close);
|
||||
|
||||
// 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 = `
|
||||
<div class="backup-diff-loading">
|
||||
<div class="loading-spinner" style="width:24px;height:24px;border-width:3px;margin:0 auto"></div>
|
||||
</div>`;
|
||||
|
||||
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 = `<div class="backup-diff-error">Erreur: ${escapeHtml(err?.detail || err?.message || 'Unknown')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
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 = '<i data-lucide="rotate-ccw" class="icon-sm"></i> Restaurer';
|
||||
safeCreateIcons();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Diff rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderDiff(diffText) {
|
||||
if (!diffText) {
|
||||
return '<div class="backup-diff-empty">Aucune différence — les versions sont identiques.</div>';
|
||||
}
|
||||
|
||||
let html = '<div class="backup-diff-output"><table class="backup-diff-table">';
|
||||
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 += `<tr class="${cssClass}"><td class="diff-prefix">${prefix}</td><td class="diff-content"><pre>${escapeHtml(line)}</pre></td></tr>`;
|
||||
}
|
||||
|
||||
html += '</table></div>';
|
||||
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;
|
||||
}
|
||||
@@ -1675,6 +1675,8 @@ export const ContextMenuManager = {
|
||||
this._addItem('trash-2', 'Supprimer', () => this._deleteFile(), isReadonly);
|
||||
this._addSeparator();
|
||||
this._addItem('bookmark-plus', 'Ajouter aux bookmarks', () => this._toggleBookmark(), false);
|
||||
this._addSeparator();
|
||||
this._addItem('history', 'Backups / Versions', () => this._openBackupViewer(), false);
|
||||
}
|
||||
|
||||
this._menu.classList.add('active');
|
||||
@@ -1800,6 +1802,16 @@ export const ContextMenuManager = {
|
||||
DashboardBookmarkWidget.load();
|
||||
}
|
||||
} catch (err) { showToast("Erreur: " + err.message, "error"); }
|
||||
},
|
||||
|
||||
_openBackupViewer() {
|
||||
// Dynamically import the backup viewer module to avoid circular deps
|
||||
import('./backups.js').then(mod => {
|
||||
mod.openBackupViewer(this._targetVault, this._targetPath);
|
||||
}).catch(err => {
|
||||
showToast("Erreur lors du chargement du viewer: " + err.message, "error");
|
||||
});
|
||||
this.hide();
|
||||
}
|
||||
};
|
||||
export const TabManager = {
|
||||
|
||||
Reference in New Issue
Block a user