Initial commit: FuZip - Application de fusion interactive de fichiers ZIP

- 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
This commit is contained in:
2026-01-12 03:29:01 +01:00
commit bd6d321ed7
24 changed files with 6463 additions and 0 deletions

578
assets/js/app.js Normal file
View File

@@ -0,0 +1,578 @@
/**
* 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();
});