- Backend PHP: architecture MVC avec API REST (upload, merge, preview, extract) - Frontend JavaScript: composants modulaires (arborescence, upload, themes, i18n) - Fonctionnalités: drag&drop, sélection exclusive, détection conflits, persistance état - Sécurité: validation stricte, isolation sessions, sanitization chemins - UI/UX: responsive, thèmes clair/sombre, multi-langue (FR/EN) - Documentation: README complet avec installation et utilisation
579 lines
20 KiB
JavaScript
579 lines
20 KiB
JavaScript
/**
|
|
* FuZip - Application principale
|
|
* Point d'entrée et orchestrateur de l'application
|
|
*/
|
|
|
|
class FuZipApp {
|
|
constructor() {
|
|
// Vérification config
|
|
if (!window.FUZIP_CONFIG) {
|
|
console.error('[FuZipApp] FUZIP_CONFIG not found!');
|
|
return;
|
|
}
|
|
|
|
// État de l'application
|
|
this.state = {
|
|
leftStructure: null,
|
|
rightStructure: null,
|
|
leftStats: null,
|
|
rightStats: null,
|
|
conflicts: [],
|
|
selection: new Map() // path -> 'left'|'right'|null
|
|
};
|
|
|
|
// Managers
|
|
this.themeManager = null;
|
|
this.langManager = null;
|
|
this.uploadLeft = null;
|
|
this.uploadRight = null;
|
|
this.treeRendererLeft = null;
|
|
this.treeRendererRight = null;
|
|
this.previewManager = null;
|
|
|
|
// Éléments DOM
|
|
this.conflictsBanner = document.getElementById('conflicts-banner');
|
|
this.conflictsCount = document.getElementById('conflicts-count');
|
|
this.btnMerge = document.getElementById('btn-merge');
|
|
this.btnReset = document.getElementById('btn-reset');
|
|
this.selectionCount = document.getElementById('selection-count');
|
|
this.loadingOverlay = document.getElementById('loading-overlay');
|
|
this.loadingText = document.getElementById('loading-text');
|
|
this.treeContainerLeft = document.getElementById('tree-left');
|
|
this.treeContainerRight = document.getElementById('tree-right');
|
|
|
|
this.init();
|
|
}
|
|
|
|
/**
|
|
* Initialise l'application
|
|
*/
|
|
async init() {
|
|
console.log('[FuZipApp] Initializing...');
|
|
|
|
// Initialise les managers de base
|
|
this.themeManager = new ThemeManager();
|
|
this.langManager = new LanguageManager();
|
|
|
|
// Initialise les gestionnaires d'upload
|
|
this.uploadLeft = new UploadManager('left', (side, structure, stats) => {
|
|
this.onStructureLoaded(side, structure, stats);
|
|
});
|
|
|
|
this.uploadRight = new UploadManager('right', (side, structure, stats) => {
|
|
this.onStructureLoaded(side, structure, stats);
|
|
});
|
|
|
|
// Initialise les renderers d'arbres (Phase 6)
|
|
if (this.treeContainerLeft) {
|
|
this.treeRendererLeft = new FileTreeRenderer('left', this.treeContainerLeft, this);
|
|
}
|
|
|
|
if (this.treeContainerRight) {
|
|
this.treeRendererRight = new FileTreeRenderer('right', this.treeContainerRight, this);
|
|
}
|
|
|
|
// Initialise le gestionnaire de preview (Phase 6)
|
|
this.previewManager = new PreviewManager();
|
|
|
|
// Événements boutons
|
|
this.setupEventListeners();
|
|
|
|
// Nettoyage automatique des anciennes sessions
|
|
this.cleanupOldSessions();
|
|
|
|
// Restaurer l'état sauvegardé si disponible
|
|
this.restoreState();
|
|
|
|
console.log('[FuZipApp] Initialized successfully');
|
|
}
|
|
|
|
/**
|
|
* Configure les événements globaux
|
|
*/
|
|
setupEventListeners() {
|
|
// Bouton merge
|
|
if (this.btnMerge) {
|
|
this.btnMerge.addEventListener('click', () => this.onMergeClick());
|
|
this.updateMergeButton(); // Désactive par défaut
|
|
}
|
|
|
|
// Bouton reset
|
|
if (this.btnReset) {
|
|
this.btnReset.addEventListener('click', () => this.reset());
|
|
}
|
|
|
|
// Cleanup au unload
|
|
window.addEventListener('beforeunload', () => {
|
|
// Placeholder pour cleanup si nécessaire
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Affiche les informations d'upload dans l'UI
|
|
* @param {string} side - 'left' ou 'right'
|
|
* @param {Object} stats - {total_files, total_size}
|
|
* @param {string|null} fileNameText - Nom du fichier à afficher (optionnel)
|
|
*/
|
|
displayUploadInfo(side, stats, fileNameText = null) {
|
|
const uploadInfo = document.getElementById(`upload-info-${side}`);
|
|
const fileName = document.getElementById(`file-name-${side}`);
|
|
const fileSize = document.getElementById(`file-size-${side}`);
|
|
const fileCount = document.getElementById(`file-count-${side}`);
|
|
const panel = document.getElementById(`upload-panel-${side}`);
|
|
|
|
if (panel) {
|
|
panel.classList.add('success');
|
|
}
|
|
|
|
if (uploadInfo) {
|
|
uploadInfo.classList.remove('hidden');
|
|
}
|
|
|
|
if (fileName && fileNameText) {
|
|
fileName.textContent = fileNameText;
|
|
}
|
|
|
|
if (fileCount && stats.total_files !== undefined) {
|
|
fileCount.textContent = stats.total_files.toString();
|
|
}
|
|
|
|
if (fileSize && stats.total_size !== undefined) {
|
|
fileSize.textContent = this.formatBytes(stats.total_size);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Formate les octets en format lisible
|
|
* @param {number} bytes
|
|
* @returns {string}
|
|
*/
|
|
formatBytes(bytes) {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
|
}
|
|
|
|
/**
|
|
* Callback quand une structure est chargée
|
|
* @param {string} side - 'left' ou 'right'
|
|
* @param {Object} structure
|
|
* @param {Object} stats
|
|
*/
|
|
async onStructureLoaded(side, structure, stats) {
|
|
console.log(`[FuZipApp] Structure loaded for ${side}:`, stats);
|
|
|
|
// Mise à jour de l'état
|
|
if (side === 'left') {
|
|
this.state.leftStructure = structure;
|
|
this.state.leftStats = stats;
|
|
} else {
|
|
this.state.rightStructure = structure;
|
|
this.state.rightStats = stats;
|
|
}
|
|
|
|
// Rend l'arborescence (Phase 6)
|
|
const renderer = side === 'left' ? this.treeRendererLeft : this.treeRendererRight;
|
|
if (renderer) {
|
|
// Attendre les conflits si les deux sont chargés
|
|
if (this.state.leftStructure && this.state.rightStructure) {
|
|
await this.detectConflicts();
|
|
}
|
|
|
|
// Rendre avec les conflits
|
|
const conflictPaths = this.state.conflicts.map(c => c.path);
|
|
renderer.render(structure, conflictPaths);
|
|
}
|
|
|
|
// Met à jour le bouton merge
|
|
this.updateMergeButton();
|
|
|
|
// Sauvegarder l'état
|
|
this.saveState();
|
|
}
|
|
|
|
/**
|
|
* Détecte les conflits entre les deux structures
|
|
*/
|
|
async detectConflicts() {
|
|
try {
|
|
const response = await fetch(`${window.FUZIP_CONFIG.apiBase}structure.php?action=conflicts`);
|
|
const data = await response.json();
|
|
|
|
if (data.success && data.conflicts) {
|
|
this.state.conflicts = data.conflicts;
|
|
this.showConflictsBanner(data.conflicts.length);
|
|
}
|
|
} catch (error) {
|
|
console.error('[FuZipApp] Error detecting conflicts:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Affiche la bannière de conflits
|
|
* @param {number} count
|
|
*/
|
|
showConflictsBanner(count) {
|
|
if (count > 0 && this.conflictsBanner) {
|
|
this.conflictsBanner.classList.remove('hidden');
|
|
|
|
if (this.conflictsCount) {
|
|
this.conflictsCount.textContent = count.toString();
|
|
}
|
|
} else if (this.conflictsBanner) {
|
|
this.conflictsBanner.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Met à jour l'état du bouton merge
|
|
*/
|
|
updateMergeButton() {
|
|
if (!this.btnMerge) return;
|
|
|
|
// Active seulement si les deux structures sont chargées
|
|
const canMerge = this.state.leftStructure && this.state.rightStructure;
|
|
|
|
this.btnMerge.disabled = !canMerge;
|
|
}
|
|
|
|
/**
|
|
* Optimise la sélection en supprimant les enfants si le parent est sélectionné
|
|
* @param {Object} selection - Sélection {path: 'left'|'right'|null}
|
|
* @returns {Object} Sélection optimisée
|
|
*/
|
|
optimizeSelection(selection) {
|
|
const optimized = {};
|
|
|
|
for (const [path, side] of Object.entries(selection)) {
|
|
if (side === null) {
|
|
continue;
|
|
}
|
|
|
|
// Vérifier si un parent de ce chemin est déjà sélectionné du même côté
|
|
let hasParentSelected = false;
|
|
|
|
for (const [otherPath, otherSide] of Object.entries(selection)) {
|
|
if (otherSide === side && otherPath !== path) {
|
|
// Vérifier si otherPath est un parent de path
|
|
// Un parent se termine par / et path commence par ce parent
|
|
if (otherPath.endsWith('/') && path.startsWith(otherPath)) {
|
|
hasParentSelected = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Si aucun parent n'est sélectionné, garder ce chemin
|
|
if (!hasParentSelected) {
|
|
optimized[path] = side;
|
|
}
|
|
}
|
|
|
|
return optimized;
|
|
}
|
|
|
|
/**
|
|
* Callback quand la sélection change
|
|
*/
|
|
onSelectionChanged() {
|
|
// Compte les fichiers sélectionnés
|
|
let count = 0;
|
|
this.state.selection.forEach((side, path) => {
|
|
if (side !== null) {
|
|
count++;
|
|
}
|
|
});
|
|
|
|
// Met à jour le footer
|
|
if (this.selectionCount) {
|
|
this.selectionCount.textContent = count.toString();
|
|
}
|
|
|
|
// Met à jour les deux renderers
|
|
if (this.treeRendererLeft) {
|
|
this.treeRendererLeft.updateAllCheckboxStates();
|
|
}
|
|
|
|
if (this.treeRendererRight) {
|
|
this.treeRendererRight.updateAllCheckboxStates();
|
|
}
|
|
|
|
// Sauvegarder l'état
|
|
this.saveState();
|
|
}
|
|
|
|
/**
|
|
* Événement clic sur le bouton merge
|
|
*/
|
|
async onMergeClick() {
|
|
if (!this.state.leftStructure || !this.state.rightStructure) {
|
|
return;
|
|
}
|
|
|
|
this.showLoading('Fusion en cours...');
|
|
|
|
try {
|
|
// Convertit la sélection Map en objet pour JSON
|
|
const selection = {};
|
|
this.state.selection.forEach((side, path) => {
|
|
selection[path] = side;
|
|
});
|
|
|
|
// Optimiser la sélection (supprimer les enfants si parent déjà sélectionné)
|
|
const optimizedSelection = this.optimizeSelection(selection);
|
|
|
|
console.log(`[FuZipApp] Selection: ${Object.keys(selection).length} paths -> ${Object.keys(optimizedSelection).length} paths (optimized)`);
|
|
|
|
const response = await fetch(`${window.FUZIP_CONFIG.apiBase}merge.php`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ selection: optimizedSelection })
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
|
|
// Le serveur envoie le fichier ZIP en stream
|
|
const blob = await response.blob();
|
|
const url = window.URL.createObjectURL(blob);
|
|
|
|
// Déclenche le téléchargement
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'merged.zip';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
window.URL.revokeObjectURL(url);
|
|
|
|
console.log('[FuZipApp] Merge completed successfully');
|
|
} catch (error) {
|
|
console.error('[FuZipApp] Merge error:', error);
|
|
alert(`Erreur lors de la fusion : ${error.message}`);
|
|
} finally {
|
|
this.hideLoading();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Affiche l'overlay de loading
|
|
* @param {string} text
|
|
*/
|
|
showLoading(text = 'Chargement...') {
|
|
if (this.loadingOverlay) {
|
|
this.loadingOverlay.classList.remove('hidden');
|
|
}
|
|
|
|
if (this.loadingText) {
|
|
this.loadingText.textContent = text;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cache l'overlay de loading
|
|
*/
|
|
hideLoading() {
|
|
if (this.loadingOverlay) {
|
|
this.loadingOverlay.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Nettoyage automatique des anciennes sessions
|
|
*/
|
|
async cleanupOldSessions() {
|
|
try {
|
|
await fetch(`${window.FUZIP_CONFIG.apiBase}cleanup.php`);
|
|
console.log('[FuZipApp] Old sessions cleaned');
|
|
} catch (error) {
|
|
console.warn('[FuZipApp] Cleanup failed:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sauvegarde l'état de l'application dans localStorage
|
|
*/
|
|
saveState() {
|
|
try {
|
|
// Récupérer les noms de fichiers affichés
|
|
const fileNameLeft = document.getElementById('file-name-left');
|
|
const fileNameRight = document.getElementById('file-name-right');
|
|
|
|
const state = {
|
|
sessionId: window.FUZIP_CONFIG.sessionId,
|
|
leftStructure: this.state.leftStructure,
|
|
rightStructure: this.state.rightStructure,
|
|
leftStats: this.state.leftStats,
|
|
rightStats: this.state.rightStats,
|
|
leftFileName: fileNameLeft ? fileNameLeft.textContent : null,
|
|
rightFileName: fileNameRight ? fileNameRight.textContent : null,
|
|
conflicts: this.state.conflicts,
|
|
selection: Array.from(this.state.selection.entries()),
|
|
expandedFoldersLeft: this.treeRendererLeft ? Array.from(this.treeRendererLeft.expandedFolders) : [],
|
|
expandedFoldersRight: this.treeRendererRight ? Array.from(this.treeRendererRight.expandedFolders) : [],
|
|
searchQueryLeft: this.treeRendererLeft ? this.treeRendererLeft.searchQuery : '',
|
|
searchQueryRight: this.treeRendererRight ? this.treeRendererRight.searchQuery : ''
|
|
};
|
|
|
|
localStorage.setItem('fuzip_state', JSON.stringify(state));
|
|
console.log('[FuZipApp] State saved');
|
|
} catch (error) {
|
|
console.warn('[FuZipApp] Failed to save state:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Restaure l'état de l'application depuis localStorage
|
|
*/
|
|
async restoreState() {
|
|
try {
|
|
const saved = localStorage.getItem('fuzip_state');
|
|
if (!saved) {
|
|
console.log('[FuZipApp] No saved state found');
|
|
return;
|
|
}
|
|
|
|
const state = JSON.parse(saved);
|
|
|
|
// Vérifier que la session PHP est toujours la même
|
|
if (state.sessionId !== window.FUZIP_CONFIG.sessionId) {
|
|
console.log('[FuZipApp] Session ID mismatch, clearing old state');
|
|
localStorage.removeItem('fuzip_state');
|
|
return;
|
|
}
|
|
|
|
// Vérifier que les structures existent
|
|
if (!state.leftStructure && !state.rightStructure) {
|
|
console.log('[FuZipApp] No structures to restore');
|
|
return;
|
|
}
|
|
|
|
console.log('[FuZipApp] Restoring state...');
|
|
|
|
// Restaurer conflicts EN PREMIER (utilisés par render)
|
|
if (state.conflicts) {
|
|
this.state.conflicts = state.conflicts;
|
|
this.showConflictsBanner(state.conflicts.length);
|
|
}
|
|
|
|
// Restaurer selection EN SECOND (utilisée par updateAllCheckboxStates)
|
|
if (state.selection) {
|
|
this.state.selection = new Map(state.selection);
|
|
// Ne pas appeler onSelectionChanged() maintenant, on le fera après les renders
|
|
}
|
|
|
|
// Restaurer left
|
|
if (state.leftStructure) {
|
|
this.state.leftStructure = state.leftStructure;
|
|
this.state.leftStats = state.leftStats;
|
|
|
|
// Afficher les stats dans l'UI
|
|
this.displayUploadInfo('left', state.leftStats, state.leftFileName);
|
|
|
|
// Restaurer expanded folders AVANT le rendu
|
|
if (this.treeRendererLeft && state.expandedFoldersLeft) {
|
|
this.treeRendererLeft.expandedFolders = new Set(state.expandedFoldersLeft);
|
|
}
|
|
|
|
// Restaurer recherche AVANT le rendu
|
|
if (this.treeRendererLeft && state.searchQueryLeft) {
|
|
this.treeRendererLeft.searchQuery = state.searchQueryLeft;
|
|
if (this.treeRendererLeft.searchInput) {
|
|
this.treeRendererLeft.searchInput.value = state.searchQueryLeft;
|
|
}
|
|
if (state.searchQueryLeft && this.treeRendererLeft.searchClearBtn) {
|
|
this.treeRendererLeft.searchClearBtn.classList.remove('hidden');
|
|
}
|
|
}
|
|
|
|
// Charger la structure (ne pas appeler onStructureLoaded car ça va déclencher saveState)
|
|
const conflictPaths = this.state.conflicts.map(c => c.path);
|
|
this.treeRendererLeft.render(state.leftStructure, conflictPaths);
|
|
}
|
|
|
|
// Restaurer right
|
|
if (state.rightStructure) {
|
|
this.state.rightStructure = state.rightStructure;
|
|
this.state.rightStats = state.rightStats;
|
|
|
|
// Afficher les stats dans l'UI
|
|
this.displayUploadInfo('right', state.rightStats, state.rightFileName);
|
|
|
|
// Restaurer expanded folders AVANT le rendu
|
|
if (this.treeRendererRight && state.expandedFoldersRight) {
|
|
this.treeRendererRight.expandedFolders = new Set(state.expandedFoldersRight);
|
|
}
|
|
|
|
// Restaurer recherche AVANT le rendu
|
|
if (this.treeRendererRight && state.searchQueryRight) {
|
|
this.treeRendererRight.searchQuery = state.searchQueryRight;
|
|
if (this.treeRendererRight.searchInput) {
|
|
this.treeRendererRight.searchInput.value = state.searchQueryRight;
|
|
}
|
|
if (state.searchQueryRight && this.treeRendererRight.searchClearBtn) {
|
|
this.treeRendererRight.searchClearBtn.classList.remove('hidden');
|
|
}
|
|
}
|
|
|
|
// Charger la structure (ne pas appeler onStructureLoaded car ça va déclencher saveState)
|
|
const conflictPaths = this.state.conflicts.map(c => c.path);
|
|
this.treeRendererRight.render(state.rightStructure, conflictPaths);
|
|
}
|
|
|
|
// Mettre à jour les checkboxes et le compteur APRÈS les renders
|
|
if (state.selection) {
|
|
this.onSelectionChanged();
|
|
}
|
|
|
|
// Mettre à jour le bouton merge
|
|
this.updateMergeButton();
|
|
|
|
console.log('[FuZipApp] State restored');
|
|
} catch (error) {
|
|
console.warn('[FuZipApp] Failed to restore state:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Réinitialise l'application
|
|
*/
|
|
reset() {
|
|
this.state = {
|
|
leftStructure: null,
|
|
rightStructure: null,
|
|
leftStats: null,
|
|
rightStats: null,
|
|
conflicts: [],
|
|
selection: new Map()
|
|
};
|
|
|
|
this.uploadLeft?.reset();
|
|
this.uploadRight?.reset();
|
|
this.showConflictsBanner(0);
|
|
this.updateMergeButton();
|
|
|
|
// Clear trees (Phase 6)
|
|
this.treeRendererLeft?.clear();
|
|
this.treeRendererRight?.clear();
|
|
|
|
// Reset selection count
|
|
if (this.selectionCount) {
|
|
this.selectionCount.textContent = '0';
|
|
}
|
|
|
|
// Effacer l'état sauvegardé
|
|
localStorage.removeItem('fuzip_state');
|
|
console.log('[FuZipApp] State cleared');
|
|
}
|
|
}
|
|
|
|
// Initialisation au chargement du DOM
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
window.fuZipApp = new FuZipApp();
|
|
});
|