Files
ObsiGate/desktop/frontend-static/js/backups.js
T
bruno 301c325af7
CI / lint (push) Successful in 25s
CI / security (push) Successful in 19s
CI / test (push) Successful in 29s
CI / build (push) Successful in 8s
CI / e2e (push) Failing after 21s
Desktop Build / build-windows (push) Has been cancelled
Desktop Build / build-linux (push) Has been cancelled
Refactor frontend into ES modules with modern tooling
2026-07-26 10:29:19 -04:00

502 lines
18 KiB
JavaScript

/**
* 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 = '<i data-lucide="x" style="width:15px;height:15px"></i>';
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 '<div class="backup-header"><h3>Backups</h3></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></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></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 crees automatiquement avant chaque modification.</p>' +
'</div>';
}
function renderMainView() {
var b = state.backups;
var leftOpts = b.map(function(bk) {
var sel = bk.timestamp === state.leftVersion ? ' selected' : '';
return '<option value="' + bk.timestamp + '"' + sel + '>' + formatTimestamp(bk.timestamp) + ' (' + formatSize(bk.size) + ')</option>';
}).join('');
var rightOpts = ['<option value="current"' + (state.rightVersion === null ? ' selected' : '') + '>Version actuelle</option>']
.concat(b.map(function(bk) {
var sel = bk.timestamp === state.rightVersion ? ' selected' : '';
return '<option value="' + bk.timestamp + '"' + sel + '>' + formatTimestamp(bk.timestamp) + ' (' + formatSize(bk.size) + ')</option>';
})).join('');
var html =
'<div class="backup-header">' +
'<div class="backup-header-left">' +
'<h3>'+t('backups.title')+'</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>' +
'</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>' +
'<button class="backup-diff-mode-btn" id="backup-diff-mode-btn" title="Vue cote a cote">' +
'<i data-lucide="columns-2" class="icon-sm"></i> Cote-a-cote' +
'</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 crees lors de la sauvegarde manuelle (Ctrl+S)</span>' +
'<button class="backup-footer-manage-btn" id="backup-manage-btn">Gerer tous les backups</button>' +
'</div>';
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 =
'<div class="backup-diff-loading">' +
'<div class="loading-spinner" style="width:24px;height:24px;border-width:3px;margin:0 auto"></div>' +
'</div>';
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 = '<div class="backup-diff-error">Erreur: ' + escapeHtml((err && err.detail) || (err && err.message) || 'Unknown') + '</div>';
}
}
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(t('backup.file_restored'), 'success');
await refreshSidebarTreePreservingState();
close();
if (typeof TabManager !== 'undefined' && TabManager.reloadCurrent) {
TabManager.reloadCurrent();
}
} else {
showToast(t('backup.restore_failed'), 'error');
}
} catch (err) {
showToast('Erreur: ' + ((err && err.detail) || (err && 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 difference - les versions sont identiques.</div>';
}
var html = '<div class="backup-diff-output"><table class="backup-diff-table">';
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 += '<tr class="' + cssClass + '"><td class="diff-prefix">' + prefix + '</td><td class="diff-content"><pre>' + escapeHtml(line) + '</pre></td></tr>';
}
html += '</table></div>';
return html;
}
// ---------------------------------------------------------------------------
// Side-by-side diff rendering
// ---------------------------------------------------------------------------
function renderDiffSideBySide(leftText, rightText) {
if (!leftText && !rightText) {
return '<div class="backup-diff-empty">Aucune difference - les versions sont identiques.</div>';
}
var leftLines = (leftText || '').split('\n');
var rightLines = (rightText || '').split('\n');
var sm = new DifflibSeqMatcher(leftLines, rightLines);
var ops = sm.getOpcodes();
var html = '<div class="backup-diff-output backup-diff-sidebyside"><table class="backup-diff-table sidebyside"><colgroup><col style="width:50%"><col style="width:50%"></colgroup><tbody>';
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 += '<tr class="diff-context"><td class="diff-content sidebyside-left"><pre>' + escapeHtml(leftLines[i1 + k]) + '</pre></td><td class="diff-content sidebyside-right"><pre>' + escapeHtml(rightLines[j1 + k]) + '</pre></td></tr>';
}
} else if (tag === 'delete') {
for (var k2 = i1; k2 < i2; k2++) {
html += '<tr class="diff-del"><td class="diff-content sidebyside-left"><pre>' + escapeHtml(leftLines[k2]) + '</pre></td><td class="diff-content sidebyside-right diff-empty-cell"></td></tr>';
}
} else if (tag === 'insert') {
for (var k3 = j1; k3 < j2; k3++) {
html += '<tr class="diff-add"><td class="diff-content sidebyside-left diff-empty-cell"></td><td class="diff-content sidebyside-right"><pre>' + escapeHtml(rightLines[k3]) + '</pre></td></tr>';
}
} 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 += '<tr><td class="diff-content sidebyside-left ' + leftClass + '"><pre>' + leftLine + '</pre></td><td class="diff-content sidebyside-right ' + rightClass + '"><pre>' + rightLine + '</pre></td></tr>';
}
}
}
html += '</tbody></table></div>';
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;
}