commit bd6d321ed7977afa9f72e7325e3c70354a2a87bc Author: Charles Date: Mon Jan 12 03:29:01 2026 +0100 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..395f8a5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Dossier uploads (contient les fichiers temporaires des sessions) +uploads/* +!uploads/.gitkeep + +# Fichier de log +fuzip_debug.log +*.log + +# Fichiers temporaires PHP +*.tmp +*.temp + +# IDE et éditeurs +.vscode/ +.idea/ +.claude/ +*.sublime-* +.DS_Store +Thumbs.db + +# Fichiers de configuration locaux +config.local.php + +# Fichiers de test temporaires +create_test_zips.php +test_*.zip +test_*.php +phpunit.xml +extracted_* +temp_test/ + +# Cache +cache/ +tmp/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..683c834 --- /dev/null +++ b/README.md @@ -0,0 +1,347 @@ +# FuZip + +Application web interactive pour fusionner deux fichiers ZIP en choisissant précisément les fichiers à conserver de chaque côté. + +![License](https://img.shields.io/badge/license-MIT-blue.svg) +![PHP](https://img.shields.io/badge/PHP-7.4%2B-blue.svg) +![JavaScript](https://img.shields.io/badge/JavaScript-ES6%2B-yellow.svg) + +## 🎯 Fonctionnalités + +### Gestion des fichiers +- **Upload de ZIP** jusqu'à 500 MB par glisser-déposer ou sélection +- **Arborescence interactive** avec navigation hiérarchique +- **Détection automatique des conflits** entre les deux ZIP +- **Sélection exclusive** : un fichier en conflit ne peut être choisi que d'un seul côté +- **Recherche en temps réel** avec expansion automatique des dossiers +- **Prévisualisation** des fichiers texte directement dans l'interface +- **Téléchargement individuel** de fichiers depuis les ZIP + +### Interface utilisateur +- **Interface responsive** adaptée mobile et desktop +- **Thèmes clair/sombre** avec sauvegarde de préférence +- **Multi-langue** (Français/Anglais) +- **Persistance d'état** : l'interface reste identique après fermeture/réouverture de l'onglet +- **Animations fluides** et retours visuels + +### Sécurité +- **Validation stricte** des fichiers uploadés (magic bytes, MIME type) +- **Isolation par session** : chaque utilisateur a son propre espace +- **Sanitization** des noms de fichiers et chemins +- **Protection contre les extensions dangereuses** +- **Nettoyage automatique** des anciennes sessions (>24h) +- **Limites configurable** : taille de fichier, nombre de fichiers, profondeur d'arborescence + +## 🛠️ Technologies utilisées + +### Backend +- **PHP 7.4+** avec extensions : + - `ZipArchive` - Manipulation des fichiers ZIP + - `fileinfo` - Détection MIME type + - `session` - Gestion des sessions utilisateur + - `json` - Sérialisation des données + +### Frontend +- **JavaScript ES6+** vanilla (aucun framework) +- **CSS3** avec variables CSS pour theming +- **SVG** pour les icônes +- **localStorage** pour persistance d'état + +### Architecture +- **Pattern MVC** côté backend +- **Composants modulaires** côté frontend +- **API REST** pour communication client-serveur +- **Streaming** pour téléchargement des ZIP fusionnés + +## 📋 Prérequis + +- **Serveur web** : Apache 2.4+ ou Nginx 1.18+ +- **PHP** : 7.4 ou supérieur +- **Extensions PHP requises** : + - `zip` + - `fileinfo` + - `session` + - `json` +- **Espace disque** : Au moins 2 GB pour les fichiers temporaires +- **Navigateur moderne** : Chrome 90+, Firefox 88+, Safari 14+, Edge 90+ + +## 🚀 Installation + +### 1. Cloner le dépôt + +```bash +git clone https://github.com/votre-username/fuzip.git +cd fuzip +``` + +### 2. Configuration du serveur web + +#### Apache + +Créer un VirtualHost : + +```apache + + ServerName fuzip.local + DocumentRoot /path/to/fuzip + + + Options -Indexes +FollowSymLinks + AllowOverride All + Require all granted + + + # Augmenter les limites pour gros fichiers + php_value upload_max_filesize 512M + php_value post_max_size 512M + php_value max_execution_time 300 + php_value memory_limit 512M + +``` + +#### Nginx + +Configuration pour Nginx : + +```nginx +server { + listen 80; + server_name fuzip.local; + root /path/to/fuzip; + index index.php; + + client_max_body_size 512M; + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location ~ \.php$ { + fastcgi_pass unix:/var/run/php/php7.4-fpm.sock; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + include fastcgi_params; + + fastcgi_read_timeout 300; + fastcgi_buffer_size 128k; + fastcgi_buffers 4 256k; + } + + location ~ /\. { + deny all; + } +} +``` + +### 3. Configuration PHP + +Modifier `php.ini` pour autoriser les gros fichiers : + +```ini +upload_max_filesize = 512M +post_max_size = 512M +max_execution_time = 300 +memory_limit = 512M +max_input_time = 300 +``` + +### 4. Permissions des dossiers + +```bash +# Créer le dossier temporaire +mkdir -p temp/uploads + +# Définir les permissions (Linux/Mac) +chmod 755 temp +chmod 755 temp/uploads + +# Propriétaire www-data (Apache/Nginx) +chown -R www-data:www-data temp + +# Ou utilisateur spécifique selon votre config +# chown -R nginx:nginx temp +``` + +Sur Windows avec WAMP/XAMPP, vérifier que l'utilisateur du serveur a les droits d'écriture sur `temp/`. + +### 5. Configuration de l'application + +Ouvrir `core/Config.php` et ajuster les paramètres si nécessaire : + +```php +// Taille maximale des fichiers (en octets) +const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB + +// Nombre maximum de fichiers par ZIP +const MAX_FILES_PER_ZIP = 10000; + +// Profondeur maximale de l'arborescence +const MAX_TREE_DEPTH = 20; + +// Durée de vie des sessions (en secondes) +const SESSION_LIFETIME = 86400; // 24 heures +``` + +### 6. Tester l'installation + +1. Accéder à `http://fuzip.local` (ou votre URL configurée) +2. Uploader deux fichiers ZIP de test +3. Vérifier que l'arborescence s'affiche correctement +4. Tester la fusion et le téléchargement + +## 📁 Structure du projet + +``` +fuzip/ +├── api/ # Endpoints API REST +│ ├── cleanup.php # Nettoyage des anciennes sessions +│ ├── merge.php # Fusion et téléchargement du ZIP +│ ├── preview.php # Prévisualisation de fichiers +│ ├── structure.php # Extraction structure + conflits +│ └── upload.php # Upload et validation des ZIP +│ +├── assets/ # Ressources frontend +│ ├── css/ +│ │ ├── file-tree.css # Styles arborescence +│ │ ├── main.css # Styles globaux +│ │ ├── themes.css # Thèmes clair/sombre +│ │ └── upload-panel.css # Styles zones d'upload +│ └── js/ +│ ├── app.js # Application principale +│ ├── FileTreeRenderer.js # Rendu arborescence +│ ├── LanguageManager.js # Gestion i18n +│ ├── PreviewManager.js # Prévisualisation fichiers +│ ├── ThemeManager.js # Gestion thèmes +│ └── UploadManager.js # Gestion uploads +│ +├── core/ # Logique métier backend +│ ├── Config.php # Configuration globale +│ ├── FileTree.php # Construction arborescence +│ ├── SessionManager.php # Gestion sessions utilisateur +│ └── ZipHandler.php # Manipulation des ZIP +│ +├── temp/ # Fichiers temporaires (gitignored) +│ └── uploads/ # ZIP uploadés par session +│ +├── .gitignore # Fichiers exclus de Git +├── index.php # Point d'entrée +└── README.md # Documentation +``` + +## 🎮 Utilisation + +### 1. Upload des ZIP + +- Glisser-déposer deux fichiers ZIP dans les zones gauche et droite +- Ou cliquer sur "Parcourir" pour sélectionner les fichiers +- L'arborescence s'affiche automatiquement après l'upload + +### 2. Navigation dans l'arborescence + +- **Cliquer sur un dossier** pour l'expandre/réduire +- **Utiliser la recherche** pour filtrer les fichiers (expansion automatique) +- **Bouton "Tout déplier"** pour expandre toute l'arborescence + +### 3. Sélection des fichiers + +- **Cocher une checkbox** pour sélectionner un fichier ou dossier +- **Sélection exclusive** : pour les fichiers en conflit (⚠), cocher d'un côté désélectionne automatiquement l'autre +- **Sélection de dossier** : sélectionne automatiquement tous les fichiers contenus +- Le compteur en bas affiche le nombre de fichiers sélectionnés + +### 4. Prévisualisation et téléchargement + +- **Icône œil** : prévisualiser un fichier texte +- **Icône téléchargement** : télécharger un fichier individuellement + +### 5. Fusion + +- Cliquer sur **"Fusionner et Télécharger"** pour créer le ZIP final +- Le fichier `fuzip_merged_YYYY-MM-DD_HHmmss.zip` est téléchargé automatiquement + +### 6. Réinitialisation + +- Cliquer sur **"Réinitialiser"** pour tout effacer et recommencer + +## 🔒 Sécurité + +### Validations implémentées + +- ✅ Vérification des **magic bytes** (signatures ZIP) +- ✅ Validation du **type MIME** +- ✅ Limitation de la **taille des fichiers** (configurable) +- ✅ Limitation du **nombre de fichiers** par ZIP +- ✅ Limitation de la **profondeur d'arborescence** +- ✅ **Sanitization** des noms de fichiers (../traversal, caractères spéciaux) +- ✅ Blocage des **extensions dangereuses** (.exe, .bat, .sh, etc.) +- ✅ **Isolation par session** : chaque utilisateur a son propre espace +- ✅ **Nettoyage automatique** des fichiers temporaires + +### Extensions bloquées + +```php +'.exe', '.bat', '.cmd', '.sh', '.com', '.pif', '.scr', +'.vbs', '.js', '.jar', '.app', '.deb', '.rpm' +``` + +### Recommandations + +- Déployer derrière un **reverse proxy** (Nginx) pour la production +- Activer **HTTPS** avec certificat SSL/TLS +- Configurer un **firewall** (fail2ban, CSF) +- Limiter l'accès avec **authentification** si nécessaire +- Monitorer les **logs** (`temp/fuzip.log`) + +## 🐛 Dépannage + +### Les fichiers ne s'uploadent pas + +- Vérifier les permissions du dossier `temp/uploads/` +- Vérifier les limites PHP (`upload_max_filesize`, `post_max_size`) +- Vérifier les logs : `temp/fuzip.log` et logs Apache/Nginx + +### Erreur 500 lors de la fusion + +- Augmenter `memory_limit` et `max_execution_time` dans `php.ini` +- Vérifier l'espace disque disponible +- Consulter les logs pour identifier l'erreur exacte + +### L'état ne persiste pas + +- Vérifier que localStorage est activé dans le navigateur +- Vérifier que les cookies de session PHP fonctionnent +- Nettoyer le cache du navigateur + +### Conflit de session après fermeture + +- Ceci est normal : si la session PHP expire, l'état est automatiquement nettoyé +- Augmenter `SESSION_LIFETIME` dans `Config.php` si nécessaire + +## 📝 Licence + +Ce projet est sous licence MIT. Voir le fichier `LICENSE` pour plus de détails. + +## 👨‍💻 Auteur + +Développé avec ❤️ pour faciliter la fusion de fichiers ZIP. + +## 🤝 Contribution + +Les contributions sont les bienvenues ! N'hésitez pas à : + +1. **Fork** le projet +2. Créer une **branche** pour votre fonctionnalité (`git checkout -b feature/amazing-feature`) +3. **Commit** vos changements (`git commit -m 'Add amazing feature'`) +4. **Push** vers la branche (`git push origin feature/amazing-feature`) +5. Ouvrir une **Pull Request** + +## 📮 Support + +Pour toute question ou problème : + +- Ouvrir une [issue](https://github.com/votre-username/fuzip/issues) +- Consulter la [documentation](https://github.com/votre-username/fuzip/wiki) + +--- + +⭐ Si ce projet vous a été utile, n'hésitez pas à lui donner une étoile sur GitHub ! diff --git a/api/cleanup.php b/api/cleanup.php new file mode 100644 index 0000000..eb978e9 --- /dev/null +++ b/api/cleanup.php @@ -0,0 +1,82 @@ + false, + 'error' => $message + ], $httpCode); +} + +// Vérifier la méthode HTTP +if ($_SERVER['REQUEST_METHOD'] !== 'GET') { + sendError('Méthode non autorisée. Utilisez GET.', 405); +} + +Config::log("Cleanup API - Nettoyage manuel demandé"); + +try { + // Lancer le nettoyage + $deletedCount = SessionManager::cleanOldSessions(); + + $message = $deletedCount > 0 + ? "{$deletedCount} session(s) expirée(s) supprimée(s)" + : "Aucune session expirée à supprimer"; + + Config::log("Cleanup terminé : {$message}"); + + sendResponse([ + 'success' => true, + 'deleted_count' => $deletedCount, + 'message' => $message, + 'session_lifetime_hours' => Config::SESSION_LIFETIME / 3600 + ]); + +} catch (Exception $e) { + sendError('Erreur lors du nettoyage : ' . $e->getMessage(), 500); +} diff --git a/api/extract.php b/api/extract.php new file mode 100644 index 0000000..1b26211 --- /dev/null +++ b/api/extract.php @@ -0,0 +1,153 @@ + false, + 'error' => $message + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + exit; +} + +// CORS +if (isset($_SERVER['HTTP_ORIGIN'])) { + header("Access-Control-Allow-Origin: {$_SERVER['HTTP_ORIGIN']}"); + header('Access-Control-Allow-Credentials: true'); + header('Access-Control-Max-Age: 86400'); +} + +if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { + if (isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_METHOD'])) { + header("Access-Control-Allow-Methods: GET, POST, OPTIONS"); + } + if (isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'])) { + header("Access-Control-Allow-Headers: {$_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS']}"); + } + exit(0); +} + +// Vérifier la méthode HTTP +if ($_SERVER['REQUEST_METHOD'] !== 'GET') { + sendError('Méthode non autorisée. Utilisez GET.', 405); +} + +// Initialiser la session +try { + $sessionId = SessionManager::init(); + Config::log("Extract API - Session : {$sessionId}"); +} catch (Exception $e) { + sendError('Erreur initialisation session : ' . $e->getMessage(), 500); +} + +// Vérifier les paramètres +if (!isset($_GET['side']) || !in_array($_GET['side'], ['left', 'right'])) { + sendError('Paramètre "side" manquant ou invalide. Valeurs acceptées : "left", "right".'); +} + +if (!isset($_GET['path']) || empty($_GET['path'])) { + sendError('Paramètre "path" manquant. Spécifiez le chemin du fichier dans le ZIP.'); +} + +$side = $_GET['side']; +$filePath = $_GET['path']; + +// Sanitize le chemin pour la sécurité +$filePath = Config::sanitizePath($filePath); + +Config::log("Extraction demandée : {$filePath} depuis {$side}"); + +$uploadDir = SessionManager::getUploadDir($sessionId); +$zipPath = $uploadDir . $side . '.zip'; + +// Vérifier que le ZIP existe +if (!file_exists($zipPath)) { + sendError("ZIP '{$side}' non uploadé.", 404); +} + +// Extraire le fichier +$zipHandler = new ZipHandler(); +$tempOutputPath = $uploadDir . 'temp_extract_' . basename($filePath); + +try { + $success = $zipHandler->extractFile($zipPath, $filePath, $tempOutputPath); + + if (!$success) { + throw new Exception("Fichier introuvable dans le ZIP"); + } + + // Vérifier que le fichier a été extrait + if (!file_exists($tempOutputPath)) { + throw new Exception("Échec extraction du fichier"); + } + + $fileSize = filesize($tempOutputPath); + Config::log("Fichier extrait : " . Config::formatBytes($fileSize)); + + // Mettre à jour le timestamp + SessionManager::updateAccess($sessionId); + + // Déterminer le type MIME + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mimeType = finfo_file($finfo, $tempOutputPath); + finfo_close($finfo); + + // Nom de fichier pour le téléchargement + $downloadName = basename($filePath); + + // Headers pour téléchargement + header('Content-Type: ' . $mimeType); + header('Content-Disposition: attachment; filename="' . $downloadName . '"'); + header('Content-Length: ' . $fileSize); + header('Cache-Control: no-cache, must-revalidate'); + header('Pragma: no-cache'); + header('Expires: 0'); + + // Stream le fichier + $handle = fopen($tempOutputPath, 'rb'); + if ($handle === false) { + throw new Exception("Impossible d'ouvrir le fichier extrait"); + } + + while (!feof($handle)) { + echo fread($handle, Config::STREAM_BUFFER_SIZE); + flush(); + } + + fclose($handle); + + // Nettoyer le fichier temporaire + unlink($tempOutputPath); + + Config::log("Fichier téléchargé : {$downloadName}"); + + exit; + +} catch (Exception $e) { + // Nettoyer le fichier temporaire en cas d'erreur + if (file_exists($tempOutputPath)) { + unlink($tempOutputPath); + } + + sendError('Erreur lors de l\'extraction : ' . $e->getMessage(), 500); +} diff --git a/api/merge.php b/api/merge.php new file mode 100644 index 0000000..f95f69d --- /dev/null +++ b/api/merge.php @@ -0,0 +1,166 @@ + false, + 'error' => $message + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + exit; +} + +// CORS +if (isset($_SERVER['HTTP_ORIGIN'])) { + header("Access-Control-Allow-Origin: {$_SERVER['HTTP_ORIGIN']}"); + header('Access-Control-Allow-Credentials: true'); + header('Access-Control-Max-Age: 86400'); +} + +if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { + if (isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_METHOD'])) { + header("Access-Control-Allow-Methods: GET, POST, OPTIONS"); + } + if (isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'])) { + header("Access-Control-Allow-Headers: {$_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS']}"); + } + exit(0); +} + +// Vérifier la méthode HTTP +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + sendError('Méthode non autorisée. Utilisez POST.', 405); +} + +// Initialiser la session +try { + $sessionId = SessionManager::init(); + Config::log("Merge API - Session : {$sessionId}"); +} catch (Exception $e) { + sendError('Erreur initialisation session : ' . $e->getMessage(), 500); +} + +// Récupérer le body JSON +$input = file_get_contents('php://input'); +if (empty($input)) { + sendError('Body JSON manquant. Envoyez la sélection au format JSON.'); +} + +$data = json_decode($input, true); +if (json_last_error() !== JSON_ERROR_NONE) { + sendError('JSON invalide : ' . json_last_error_msg()); +} + +// Vérifier la sélection +if (!isset($data['selection']) || !is_array($data['selection'])) { + sendError('Paramètre "selection" manquant ou invalide.'); +} + +$selection = $data['selection']; +Config::log("Sélection reçue : " . count($selection) . " entrées"); + +// Valider la sélection +$validation = FileTree::validateSelection($selection); +if (!$validation['valid']) { + $conflicts = implode(', ', $validation['conflicts']); + sendError("Sélection invalide. Conflits détectés : {$conflicts}"); +} + +$uploadDir = SessionManager::getUploadDir($sessionId); +$leftZipPath = $uploadDir . 'left.zip'; +$rightZipPath = $uploadDir . 'right.zip'; + +// Vérifier que les 2 ZIP existent +if (!file_exists($leftZipPath)) { + sendError('ZIP gauche non uploadé.', 404); +} + +if (!file_exists($rightZipPath)) { + sendError('ZIP droite non uploadé.', 404); +} + +// Préparer le chemin du ZIP fusionné +$mergedZipPath = $uploadDir . 'merged.zip'; + +// Fusionner +$zipHandler = new ZipHandler(); + +try { + Config::log("Début fusion : " . count(array_filter($selection)) . " fichiers sélectionnés"); + + $result = $zipHandler->merge($leftZipPath, $rightZipPath, $selection, $mergedZipPath); + + Config::log("Fusion réussie : {$result}"); + + // Vérifier que le fichier a été créé + if (!file_exists($mergedZipPath)) { + throw new Exception("Le fichier fusionné n'a pas été créé"); + } + + $fileSize = filesize($mergedZipPath); + Config::log("ZIP fusionné créé : " . Config::formatBytes($fileSize)); + + // Mettre à jour le timestamp + SessionManager::updateAccess($sessionId); + + // Envoyer le fichier en téléchargement + // Nom de fichier avec timestamp + $timestamp = date('Y-m-d_His'); + $filename = "fuzip_merged_{$timestamp}.zip"; + + // Headers pour téléchargement + header('Content-Type: application/zip'); + header('Content-Disposition: attachment; filename="' . $filename . '"'); + header('Content-Length: ' . $fileSize); + header('Cache-Control: no-cache, must-revalidate'); + header('Pragma: no-cache'); + header('Expires: 0'); + + // Stream le fichier + $handle = fopen($mergedZipPath, 'rb'); + if ($handle === false) { + throw new Exception("Impossible d'ouvrir le fichier fusionné"); + } + + // Envoyer par chunks pour économiser la mémoire + while (!feof($handle)) { + echo fread($handle, Config::STREAM_BUFFER_SIZE); + flush(); + } + + fclose($handle); + + Config::log("ZIP fusionné envoyé : {$filename} (" . Config::formatBytes($fileSize) . ")"); + + exit; + +} catch (Exception $e) { + sendError('Erreur lors de la fusion : ' . $e->getMessage(), 500); +} diff --git a/api/preview.php b/api/preview.php new file mode 100644 index 0000000..631df22 --- /dev/null +++ b/api/preview.php @@ -0,0 +1,135 @@ + false, + 'error' => $message + ], $httpCode); +} + +// Vérifier la méthode HTTP +if ($_SERVER['REQUEST_METHOD'] !== 'GET') { + sendError('Méthode non autorisée. Utilisez GET.', 405); +} + +// Initialiser la session +try { + $sessionId = SessionManager::init(); + Config::log("Preview API - Session : {$sessionId}"); +} catch (Exception $e) { + sendError('Erreur initialisation session : ' . $e->getMessage(), 500); +} + +// Vérifier les paramètres +if (!isset($_GET['side']) || !in_array($_GET['side'], ['left', 'right'])) { + sendError('Paramètre "side" manquant ou invalide. Valeurs acceptées : "left", "right".'); +} + +if (!isset($_GET['path']) || empty($_GET['path'])) { + sendError('Paramètre "path" manquant. Spécifiez le chemin du fichier dans le ZIP.'); +} + +$side = $_GET['side']; +$filePath = $_GET['path']; +$maxLength = isset($_GET['max_length']) ? (int)$_GET['max_length'] : 10000; + +// Limiter la longueur max pour éviter surcharge +$maxLength = min($maxLength, 50000); // Max 50 KB + +// Sanitize le chemin +$filePath = Config::sanitizePath($filePath); + +Config::log("Prévisualisation demandée : {$filePath} depuis {$side} (max: {$maxLength} octets)"); + +$uploadDir = SessionManager::getUploadDir($sessionId); +$zipPath = $uploadDir . $side . '.zip'; + +// Vérifier que le ZIP existe +if (!file_exists($zipPath)) { + sendError("ZIP '{$side}' non uploadé.", 404); +} + +// Prévisualiser le fichier +$zipHandler = new ZipHandler(); + +try { + $content = $zipHandler->previewFile($zipPath, $filePath, $maxLength); + + if ($content === null) { + throw new Exception("Fichier introuvable dans le ZIP"); + } + + // Déterminer si c'est un fichier binaire + $isBinary = strpos($content, '[Fichier binaire - prévisualisation impossible]') === 0; + + // Déterminer si le contenu a été tronqué + $truncated = strpos($content, '... (tronqué)') !== false; + + // Mettre à jour le timestamp + SessionManager::updateAccess($sessionId); + + Config::log("Prévisualisation envoyée : " . strlen($content) . " caractères"); + + sendResponse([ + 'success' => true, + 'file_path' => $filePath, + 'side' => $side, + 'content' => $content, + 'is_binary' => $isBinary, + 'truncated' => $truncated, + 'content_length' => strlen($content) + ]); + +} catch (Exception $e) { + sendError('Erreur lors de la prévisualisation : ' . $e->getMessage(), 500); +} diff --git a/api/structure.php b/api/structure.php new file mode 100644 index 0000000..089b446 --- /dev/null +++ b/api/structure.php @@ -0,0 +1,181 @@ + false, + 'error' => $message + ], $httpCode); +} + +// Vérifier la méthode HTTP +if ($_SERVER['REQUEST_METHOD'] !== 'GET') { + sendError('Méthode non autorisée. Utilisez GET.', 405); +} + +// Initialiser la session +try { + $sessionId = SessionManager::init(); + Config::log("Structure API - Session : {$sessionId}"); +} catch (Exception $e) { + sendError('Erreur initialisation session : ' . $e->getMessage(), 500); +} + +// Récupérer le paramètre action +$action = $_GET['action'] ?? 'get'; + +if (!in_array($action, ['get', 'conflicts'])) { + sendError('Paramètre "action" invalide. Valeurs acceptées : "get", "conflicts".'); +} + +Config::log("Action demandée : {$action}"); + +$uploadDir = SessionManager::getUploadDir($sessionId); +$zipHandler = new ZipHandler(); + +// ACTION : get - Récupérer la structure d'un ZIP +if ($action === 'get') { + // Vérifier le paramètre side + if (!isset($_GET['side']) || !in_array($_GET['side'], ['left', 'right'])) { + sendError('Paramètre "side" manquant ou invalide pour action=get.'); + } + + $side = $_GET['side']; + $zipPath = $uploadDir . $side . '.zip'; + + // Vérifier que le fichier existe + if (!file_exists($zipPath)) { + sendError("Aucun fichier ZIP uploadé pour le côté '{$side}'. Uploadez d'abord un fichier.", 404); + } + + Config::log("Récupération structure : {$side}"); + + try { + // Extraire la structure + $structure = $zipHandler->getStructure($zipPath); + + // Construire l'arborescence + $tree = FileTree::buildTree($structure['files']); + $stats = FileTree::getTreeStats($tree); + + // Mettre à jour le timestamp + SessionManager::updateAccess($sessionId); + + sendResponse([ + 'success' => true, + 'side' => $side, + 'structure' => [ + 'tree' => $tree, + 'files_list' => $structure['files'] + ], + 'stats' => [ + 'total_files' => $stats['total_files'], + 'total_folders' => $stats['total_folders'], + 'total_size' => $stats['total_size'], + 'total_size_formatted' => Config::formatBytes($stats['total_size']), + 'max_depth' => $stats['max_depth'] + ] + ]); + + } catch (Exception $e) { + sendError('Erreur lors de l\'extraction : ' . $e->getMessage(), 500); + } +} + +// ACTION : conflicts - Détecter les conflits entre les 2 ZIP +if ($action === 'conflicts') { + $leftZipPath = $uploadDir . 'left.zip'; + $rightZipPath = $uploadDir . 'right.zip'; + + // Vérifier que les 2 fichiers existent + if (!file_exists($leftZipPath)) { + sendError('ZIP gauche non uploadé. Uploadez les 2 fichiers avant de détecter les conflits.', 404); + } + + if (!file_exists($rightZipPath)) { + sendError('ZIP droite non uploadé. Uploadez les 2 fichiers avant de détecter les conflits.', 404); + } + + Config::log("Détection conflits entre left.zip et right.zip"); + + try { + // Extraire les structures + $leftStructure = $zipHandler->getStructure($leftZipPath); + $rightStructure = $zipHandler->getStructure($rightZipPath); + + // Détecter les conflits + $conflicts = FileTree::detectConflicts($leftStructure['files'], $rightStructure['files']); + + Config::log("Conflits détectés : " . count($conflicts)); + + // Mettre à jour le timestamp + SessionManager::updateAccess($sessionId); + + sendResponse([ + 'success' => true, + 'conflicts' => $conflicts, + 'total_conflicts' => count($conflicts), + 'summary' => [ + 'left_total_files' => $leftStructure['total_files'], + 'right_total_files' => $rightStructure['total_files'], + 'conflicts_count' => count($conflicts) + ] + ]); + + } catch (Exception $e) { + sendError('Erreur lors de la détection des conflits : ' . $e->getMessage(), 500); + } +} diff --git a/api/upload.php b/api/upload.php new file mode 100644 index 0000000..2ada944 --- /dev/null +++ b/api/upload.php @@ -0,0 +1,165 @@ + false, + 'error' => $message + ], $httpCode); +} + +// Vérifier la méthode HTTP +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + sendError('Méthode non autorisée. Utilisez POST.', 405); +} + +// Initialiser la session +try { + $sessionId = SessionManager::init(); + Config::log("Upload API - Session : {$sessionId}"); +} catch (Exception $e) { + sendError('Erreur initialisation session : ' . $e->getMessage(), 500); +} + +// Vérifier le paramètre 'side' +if (!isset($_POST['side']) || !in_array($_POST['side'], ['left', 'right'])) { + sendError('Paramètre "side" manquant ou invalide. Valeurs acceptées : "left", "right".'); +} + +$side = $_POST['side']; +Config::log("Upload côté : {$side}"); + +// Vérifier qu'un fichier a été uploadé +if (!isset($_FILES['file'])) { + sendError('Aucun fichier uploadé. Utilisez le champ "file".'); +} + +$uploadedFile = $_FILES['file']; +Config::log("Fichier reçu : {$uploadedFile['name']} ({$uploadedFile['size']} octets)"); + +// Valider le fichier avec ZipHandler +$zipHandler = new ZipHandler(); +$validation = $zipHandler->validateUpload($uploadedFile); + +if (!$validation['valid']) { + sendError($validation['error'], 400); +} + +// Déplacer le fichier vers le dossier de session +try { + $uploadDir = SessionManager::getUploadDir($sessionId); + $targetPath = $uploadDir . $side . '.zip'; + + // Supprimer l'ancien fichier s'il existe + if (file_exists($targetPath)) { + unlink($targetPath); + Config::log("Ancien fichier {$side}.zip supprimé"); + } + + if (!move_uploaded_file($uploadedFile['tmp_name'], $targetPath)) { + throw new Exception("Impossible de déplacer le fichier uploadé"); + } + + Config::log("Fichier sauvegardé : {$targetPath}"); + +} catch (Exception $e) { + sendError('Erreur lors de la sauvegarde : ' . $e->getMessage(), 500); +} + +// Extraire la structure du ZIP +try { + $structure = $zipHandler->getStructure($targetPath); + Config::log("Structure extraite : {$structure['total_files']} fichiers"); + + // Construire l'arborescence + $tree = FileTree::buildTree($structure['files']); + $stats = FileTree::getTreeStats($tree); + + Config::log("Arborescence construite : {$stats['total_files']} fichiers, {$stats['total_folders']} dossiers"); + +} catch (Exception $e) { + // Supprimer le fichier en cas d'erreur + if (file_exists($targetPath)) { + unlink($targetPath); + } + sendError('Erreur lors de l\'extraction : ' . $e->getMessage(), 500); +} + +// Mettre à jour le timestamp de session +SessionManager::updateAccess($sessionId); + +// Réponse de succès +sendResponse([ + 'success' => true, + 'message' => 'Fichier ZIP uploadé et analysé avec succès', + 'side' => $side, + 'session_id' => $sessionId, + 'file' => [ + 'name' => $uploadedFile['name'], + 'size' => $uploadedFile['size'], + 'size_formatted' => Config::formatBytes($uploadedFile['size']) + ], + 'structure' => [ + 'tree' => $tree, + 'files_list' => $structure['files'] + ], + 'stats' => [ + 'total_files' => $stats['total_files'], + 'total_folders' => $stats['total_folders'], + 'total_size' => $stats['total_size'], + 'total_size_formatted' => Config::formatBytes($stats['total_size']), + 'max_depth' => $stats['max_depth'] + ] +], 200); diff --git a/assets/css/file-tree.css b/assets/css/file-tree.css new file mode 100644 index 0000000..5a272ab --- /dev/null +++ b/assets/css/file-tree.css @@ -0,0 +1,428 @@ +/** + * FuZip - Styles arborescence de fichiers + * Tree view, checkboxes, sélection exclusive, animations + */ + +/* ===== Tree Panel ===== */ +.tree-panel { + background-color: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + display: flex; + flex-direction: column; + overflow: hidden; + min-height: 400px; +} + +.tree-header { + padding: var(--spacing-md) var(--spacing-lg); + border-bottom: 1px solid var(--color-border); + background-color: var(--color-bg-secondary); + display: flex; + gap: var(--spacing-md); + align-items: center; +} + +.search-input-wrapper { + flex: 1; + position: relative; + display: flex; + align-items: center; +} + +.search-input { + flex: 1; + width: 100%; + padding: var(--spacing-sm) 2.5rem var(--spacing-sm) var(--spacing-md); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + font-size: var(--font-size-sm); + background-color: var(--color-bg); + color: var(--color-text); + transition: all var(--transition-fast); +} + +.search-input:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-light); +} + +.search-input::placeholder { + color: var(--color-text-muted); +} + +.search-clear-btn { + position: absolute; + right: 0.5rem; + top: 50%; + transform: translateY(-50%); + padding: 0.25rem; + background: transparent; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + transition: all var(--transition-fast); +} + +.search-clear-btn:hover { + background-color: rgba(0, 0, 0, 0.1); +} + +/* Mode dark : hover plus visible */ +:root[data-theme="dark"] .search-clear-btn:hover { + background-color: rgba(255, 255, 255, 0.1); +} + +.search-clear-btn svg { + width: 1rem; + height: 1rem; + stroke: var(--color-text-muted); + fill: none; + stroke-width: 2; +} + +.search-clear-btn.hidden { + display: none; +} + +.tree-actions { + display: flex; + gap: var(--spacing-xs); +} + +.btn-tree-action { + padding: var(--spacing-sm); + background-color: transparent; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition-fast); +} + +.btn-tree-action:hover { + background-color: var(--color-bg-tertiary); + border-color: var(--color-border-hover); +} + +.btn-tree-action svg { + width: 1.25rem; + height: 1.25rem; + color: var(--color-text-secondary); +} + +/* ===== Tree Content ===== */ +.tree-content { + flex: 1; + overflow-y: auto; + padding: var(--spacing-sm); +} + +/* Scrollbar personnalisé */ +.tree-content::-webkit-scrollbar { + width: 8px; +} + +.tree-content::-webkit-scrollbar-track { + background: var(--color-bg-secondary); +} + +.tree-content::-webkit-scrollbar-thumb { + background: var(--color-border); + border-radius: var(--radius-md); +} + +.tree-content::-webkit-scrollbar-thumb:hover { + background: var(--color-border-hover); +} + +/* ===== Tree Items ===== */ +.tree-item { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-xs) var(--spacing-sm); + border-radius: var(--radius-sm); + cursor: pointer; + transition: background-color var(--transition-fast); + position: relative; + user-select: none; +} + +.tree-item:hover { + background-color: var(--color-bg-secondary); +} + +.tree-item.selected { + background-color: var(--color-primary-light); +} + +/* Indentation pour niveaux */ +.tree-item[data-level="1"] { padding-left: calc(var(--spacing-sm) + 0rem); } +.tree-item[data-level="2"] { padding-left: calc(var(--spacing-sm) + 1.5rem); } +.tree-item[data-level="3"] { padding-left: calc(var(--spacing-sm) + 3rem); } +.tree-item[data-level="4"] { padding-left: calc(var(--spacing-sm) + 4.5rem); } +.tree-item[data-level="5"] { padding-left: calc(var(--spacing-sm) + 6rem); } + +/* Icône expand/collapse pour dossiers */ +.tree-expand { + width: 1rem; + height: 1rem; + flex-shrink: 0; + transition: transform var(--transition-fast); + cursor: pointer; + fill: none; + stroke: var(--color-text-muted); + stroke-width: 2; +} + +.tree-item.expanded .tree-expand { + transform: rotate(90deg); +} + +.tree-item.collapsed .tree-expand { + transform: rotate(0deg); +} + +/* Pas d'icône pour fichiers */ +.tree-item.file .tree-expand { + visibility: hidden; +} + +/* Checkbox */ +.tree-checkbox { + width: 1.125rem; + height: 1.125rem; + border: 2px solid var(--color-border); + border-radius: var(--radius-sm); + cursor: pointer; + flex-shrink: 0; + transition: all var(--transition-fast); + position: relative; + background-color: var(--color-bg); +} + +.tree-checkbox:hover { + border-color: var(--color-primary); +} + +/* Checkbox cochée */ +input[type="checkbox"]:checked + .tree-checkbox, +.tree-checkbox.checked { + background-color: var(--color-primary); + border-color: var(--color-primary); +} + +/* Icône checkmark */ +input[type="checkbox"]:checked + .tree-checkbox::after, +.tree-checkbox.checked::after { + content: ''; + position: absolute; + left: 0.25rem; + top: 0.125rem; + width: 0.375rem; + height: 0.625rem; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} + +/* État indeterminate pour dossiers partiellement sélectionnés */ +.tree-checkbox.indeterminate { + background-color: var(--color-primary-light); + border-color: var(--color-primary); +} + +.tree-checkbox.indeterminate::after { + content: ''; + position: absolute; + left: 0.1875rem; + top: 50%; + width: 0.5rem; + height: 2px; + background-color: var(--color-primary); + transform: translateY(-50%); +} + +/* Masquer input natif */ +.tree-item input[type="checkbox"] { + position: absolute; + opacity: 0; + pointer-events: none; +} + +/* Icône fichier/dossier */ +.tree-icon { + width: 1.25rem; + height: 1.25rem; + flex-shrink: 0; + fill: none; + stroke-width: 2; +} + +.tree-icon.folder { + stroke: #f59e0b; +} + +.tree-icon.file { + stroke: var(--color-text-muted); +} + +/* Nom du fichier/dossier */ +.tree-name { + flex: 1; + font-size: var(--font-size-sm); + color: var(--color-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Taille fichier */ +.tree-size { + font-size: var(--font-size-xs); + color: var(--color-text-muted); + flex-shrink: 0; +} + +/* ===== États spéciaux ===== */ + +/* Fichier en conflit */ +.tree-item.conflict { + position: relative; +} + +.tree-item.conflict::before { + content: '⚠'; + position: absolute; + left: 0.25rem; + font-size: 0.875rem; + color: var(--color-warning); +} + +.tree-item.conflict .tree-name { + font-weight: 500; +} + +/* Animation déselection automatique (sélection exclusive) */ +@keyframes deselect-pulse { + 0%, 100% { + background-color: transparent; + transform: scale(1); + } + 25% { + background-color: rgba(255, 193, 7, 0.4); + transform: scale(1.02); + } + 75% { + background-color: rgba(255, 193, 7, 0.2); + transform: scale(1.01); + } +} + +.tree-item.auto-deselected { + animation: deselect-pulse 0.6s ease-in-out; +} + +/* Highlight pour recherche */ +.tree-item.search-match { + background-color: #fef3c7; +} + +.tree-item.search-match .tree-name { + font-weight: 600; +} + +/* Élément caché (filtré par recherche) */ +.tree-item.filtered-out { + display: none; +} + +/* Dossier vide */ +.tree-item.empty { + opacity: 0.6; + font-style: italic; +} + +/* Actions sur l'item (preview, extract) */ +.tree-actions { + display: none; + gap: var(--spacing-xs); + margin-left: auto; +} + +.tree-item:hover .tree-actions { + display: flex; +} + +.btn-item-action { + padding: var(--spacing-xs); + background-color: transparent; + border: 1px solid transparent; + border-radius: var(--radius-sm); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition-fast); +} + +.btn-item-action:hover { + background-color: var(--color-bg-tertiary); + border-color: var(--color-border); +} + +.btn-item-action svg { + width: 1rem; + height: 1rem; + stroke: var(--color-text-secondary); + fill: none; + stroke-width: 2; +} + +/* ===== Empty State ===== */ +.tree-empty { + padding: var(--spacing-2xl); + text-align: center; + color: var(--color-text-muted); +} + +.tree-empty-icon { + width: 3rem; + height: 3rem; + margin: 0 auto var(--spacing-md); + color: var(--color-text-muted); +} + +.tree-empty-text { + font-size: var(--font-size-base); +} + +/* ===== Responsive ===== */ +@media (max-width: 768px) { + .tree-panel { + min-height: 300px; + } + + .tree-header { + flex-direction: column; + align-items: stretch; + } + + .tree-actions { + justify-content: flex-end; + } + + /* Réduire l'indentation sur mobile */ + .tree-item[data-level="2"] { padding-left: calc(var(--spacing-sm) + 1rem); } + .tree-item[data-level="3"] { padding-left: calc(var(--spacing-sm) + 2rem); } + .tree-item[data-level="4"] { padding-left: calc(--spacing-sm) + 3rem); } + .tree-item[data-level="5"] { padding-left: calc(var(--spacing-sm) + 4rem); } +} diff --git a/assets/css/main.css b/assets/css/main.css new file mode 100644 index 0000000..c336c58 --- /dev/null +++ b/assets/css/main.css @@ -0,0 +1,506 @@ +/** + * FuZip - Styles globaux + * Variables CSS, reset, layout principal + */ + +/* ===== Variables CSS ===== */ +:root { + /* Couleurs principales */ + --color-primary: #3b82f6; + --color-primary-hover: #2563eb; + --color-primary-light: #dbeafe; + + --color-secondary: #64748b; + --color-secondary-hover: #475569; + + --color-success: #10b981; + --color-warning: #f59e0b; + --color-error: #ef4444; + + /* Couleurs neutres (thème clair) */ + --color-bg: #ffffff; + --color-bg-secondary: #f8fafc; + --color-bg-tertiary: #f1f5f9; + + --color-text: #0f172a; + --color-text-secondary: #64748b; + --color-text-muted: #94a3b8; + + --color-border: #e2e8f0; + --color-border-hover: #cbd5e1; + + /* Ombres */ + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + + /* Bordures arrondies */ + --radius-sm: 0.25rem; + --radius-md: 0.5rem; + --radius-lg: 0.75rem; + --radius-xl: 1rem; + + /* Espacements */ + --spacing-xs: 0.25rem; + --spacing-sm: 0.5rem; + --spacing-md: 1rem; + --spacing-lg: 1.5rem; + --spacing-xl: 2rem; + --spacing-2xl: 3rem; + + /* Typographie */ + --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + --font-mono: 'Courier New', Courier, monospace; + + --font-size-xs: 0.75rem; + --font-size-sm: 0.875rem; + --font-size-base: 1rem; + --font-size-lg: 1.125rem; + --font-size-xl: 1.25rem; + --font-size-2xl: 1.5rem; + --font-size-3xl: 1.875rem; + + /* Transitions */ + --transition-fast: 150ms ease; + --transition-base: 200ms ease; + --transition-slow: 300ms ease; + + /* Z-index */ + --z-dropdown: 1000; + --z-sticky: 1020; + --z-fixed: 1030; + --z-modal-backdrop: 1040; + --z-modal: 1050; + --z-popover: 1060; + --z-tooltip: 1070; +} + +/* ===== Reset et base ===== */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 16px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + font-family: var(--font-family); + font-size: var(--font-size-base); + line-height: 1.6; + color: var(--color-text); + background-color: var(--color-bg); + min-height: 100vh; + display: flex; + flex-direction: column; + transition: background-color var(--transition-base), color var(--transition-base); +} + +/* ===== Header ===== */ +.header { + background-color: var(--color-bg); + border-bottom: 1px solid var(--color-border); + padding: var(--spacing-lg) var(--spacing-xl); + box-shadow: var(--shadow-sm); + flex-shrink: 0; +} + +.header-content { + max-width: 1400px; + margin: 0 auto; + display: flex; + align-items: center; + gap: var(--spacing-xl); + flex-wrap: wrap; +} + +.logo { + display: flex; + align-items: center; + gap: var(--spacing-sm); +} + +.logo-icon { + width: 2rem; + height: 2rem; + color: var(--color-primary); +} + +.logo-text { + font-size: var(--font-size-2xl); + font-weight: 700; + color: var(--color-text); +} + +.subtitle { + flex: 1; + color: var(--color-text-secondary); + font-size: var(--font-size-sm); +} + +.header-actions { + display: flex; + gap: var(--spacing-sm); +} + +.btn-icon { + display: flex; + align-items: center; + gap: var(--spacing-xs); + padding: var(--spacing-sm) var(--spacing-md); + background-color: var(--color-bg-tertiary); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + cursor: pointer; + transition: all var(--transition-fast); +} + +.btn-icon:hover { + background-color: var(--color-bg-secondary); + border-color: var(--color-border-hover); +} + +.btn-icon svg { + width: 1.25rem; + height: 1.25rem; + color: var(--color-text-secondary); +} + +.lang-text { + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--color-text-secondary); +} + +/* Icônes thème (affichage conditionnel) */ +[data-theme="light"] .icon-moon { + display: none; +} + +[data-theme="dark"] .icon-sun { + display: none; +} + +/* ===== Main Container ===== */ +.main-container { + flex: 1; + display: flex; + flex-direction: column; + max-width: 1400px; + width: 100%; + margin: 0 auto; + padding: var(--spacing-xl); + gap: var(--spacing-lg); +} + +/* ===== Upload Section ===== */ +.upload-section { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-lg); + flex-shrink: 0; +} + +/* ===== Conflicts Banner ===== */ +.conflicts-banner { + display: flex; + align-items: center; + gap: var(--spacing-md); + padding: var(--spacing-md) var(--spacing-lg); + background-color: #fef3c7; + border: 1px solid #fbbf24; + border-radius: var(--radius-md); + color: #92400e; + flex-shrink: 0; +} + +.banner-icon { + width: 1.5rem; + height: 1.5rem; + flex-shrink: 0; +} + +.banner-text { + font-size: var(--font-size-sm); +} + +/* ===== Trees Section ===== */ +.trees-section { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-lg); + flex: 1; + min-height: 0; /* Important pour scroll */ +} + +/* ===== Footer ===== */ +.footer { + background-color: var(--color-bg); + border-top: 1px solid var(--color-border); + padding: var(--spacing-lg) var(--spacing-xl); + box-shadow: 0 -1px 3px rgba(0, 0, 0, 0.05); + flex-shrink: 0; +} + +.footer-content { + max-width: 1400px; + margin: 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-lg); + flex-wrap: wrap; +} + +.selection-info { + display: flex; + align-items: center; + gap: var(--spacing-sm); + font-size: var(--font-size-sm); + color: var(--color-text-secondary); +} + +.info-icon { + width: 1.25rem; + height: 1.25rem; +} + +.footer-actions { + display: flex; + gap: var(--spacing-md); +} + +/* ===== Boutons ===== */ +.btn { + display: inline-flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-lg); + font-size: var(--font-size-base); + font-weight: 500; + border-radius: var(--radius-md); + border: none; + cursor: pointer; + transition: all var(--transition-fast); + text-decoration: none; +} + +.btn svg { + width: 1.25rem; + height: 1.25rem; +} + +.btn-primary { + background-color: var(--color-primary); + color: white; + box-shadow: var(--shadow-sm); +} + +.btn-primary:hover:not(:disabled) { + background-color: var(--color-primary-hover); + box-shadow: var(--shadow-md); +} + +.btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-secondary { + background-color: var(--color-bg-tertiary); + color: var(--color-text); + border: 1px solid var(--color-border); +} + +.btn-secondary:hover { + background-color: var(--color-bg-secondary); + border-color: var(--color-border-hover); +} + +/* ===== Loading Overlay ===== */ +.loading-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--spacing-lg); + z-index: var(--z-modal); + backdrop-filter: blur(4px); +} + +.loading-spinner { + width: 3rem; + height: 3rem; + border: 4px solid rgba(255, 255, 255, 0.3); + border-top-color: white; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.loading-text { + color: white; + font-size: var(--font-size-lg); + font-weight: 500; +} + +/* ===== Modal ===== */ +.modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-xl); + z-index: var(--z-modal); + backdrop-filter: blur(4px); +} + +.modal-content { + background-color: var(--color-bg); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-xl); + max-width: 800px; + width: 100%; + max-height: 80vh; + display: flex; + flex-direction: column; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-lg); + border-bottom: 1px solid var(--color-border); +} + +.modal-title { + font-size: var(--font-size-xl); + font-weight: 600; + color: var(--color-text); +} + +.modal-close { + background: none; + border: none; + cursor: pointer; + padding: var(--spacing-sm); + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-md); + transition: background-color var(--transition-fast); +} + +.modal-close:hover { + background-color: var(--color-bg-tertiary); +} + +.modal-close svg { + width: 1.5rem; + height: 1.5rem; + color: var(--color-text-secondary); +} + +.modal-body { + padding: var(--spacing-lg); + overflow-y: auto; +} + +.preview-content { + font-family: var(--font-mono); + font-size: var(--font-size-sm); + line-height: 1.5; + background-color: var(--color-bg-tertiary); + padding: var(--spacing-md); + border-radius: var(--radius-md); + overflow-x: auto; + white-space: pre-wrap; + word-wrap: break-word; +} + +/* ===== Responsive ===== */ +@media (max-width: 1024px) { + .upload-section { + grid-template-columns: 1fr; + } + + .trees-section { + grid-template-columns: 1fr; + } + + .header-content { + flex-direction: column; + align-items: flex-start; + } + + .subtitle { + order: 2; + } + + .header-actions { + order: 1; + margin-left: auto; + } +} + +@media (max-width: 768px) { + .main-container { + padding: var(--spacing-md); + } + + .header { + padding: var(--spacing-md); + } + + .footer { + padding: var(--spacing-md); + } + + .footer-content { + flex-direction: column; + align-items: stretch; + } + + .footer-actions { + flex-direction: column; + } +} + +/* ===== Utilitaires ===== */ +.hidden { + display: none !important; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} diff --git a/assets/css/themes.css b/assets/css/themes.css new file mode 100644 index 0000000..621c2b7 --- /dev/null +++ b/assets/css/themes.css @@ -0,0 +1,226 @@ +/** + * FuZip - Thèmes (Dark Mode) + * Option E du plan : Toggle dark/light mode + */ + +/* ===== Thème sombre ===== */ +[data-theme="dark"] { + /* Couleurs principales (inchangées) */ + --color-primary: #3b82f6; + --color-primary-hover: #60a5fa; + --color-primary-light: #1e3a8a; + + --color-secondary: #94a3b8; + --color-secondary-hover: #cbd5e1; + + --color-success: #10b981; + --color-warning: #f59e0b; + --color-error: #ef4444; + + /* Couleurs neutres (inversées pour dark) */ + --color-bg: #0f172a; + --color-bg-secondary: #1e293b; + --color-bg-tertiary: #334155; + + --color-text: #f1f5f9; + --color-text-secondary: #cbd5e1; + --color-text-muted: #94a3b8; + + --color-border: #334155; + --color-border-hover: #475569; + + /* Ombres adaptées pour dark */ + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -2px rgba(0, 0, 0, 0.4); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.6), 0 10px 10px -5px rgba(0, 0, 0, 0.5); +} + +/* ===== Ajustements spécifiques dark mode ===== */ + +/* Drop zone dark */ +[data-theme="dark"] .drop-zone { + background-color: var(--color-bg-secondary); +} + +[data-theme="dark"] .drop-zone:hover, +[data-theme="dark"] .drop-zone.drag-over { + background-color: var(--color-bg-tertiary); + border-color: var(--color-primary); +} + +/* Bouton browse dark */ +[data-theme="dark"] .btn-browse { + background-color: var(--color-bg-tertiary); + color: var(--color-primary-hover); + border-color: var(--color-primary); +} + +[data-theme="dark"] .btn-browse:hover { + background-color: var(--color-primary); + color: white; +} + +/* Input search dark */ +[data-theme="dark"] .search-input { + background-color: var(--color-bg-tertiary); + border-color: var(--color-border); + color: var(--color-text); +} + +[data-theme="dark"] .search-input:focus { + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3); +} + +/* Tree items dark */ +[data-theme="dark"] .tree-item:hover { + background-color: var(--color-bg-tertiary); +} + +[data-theme="dark"] .tree-item.selected { + background-color: rgba(59, 130, 246, 0.2); +} + +[data-theme="dark"] .tree-item.search-match { + background-color: rgba(245, 158, 11, 0.2); +} + +/* Checkbox dark */ +[data-theme="dark"] .tree-checkbox { + background-color: var(--color-bg-tertiary); + border-color: var(--color-border-hover); +} + +[data-theme="dark"] .tree-checkbox:hover { + border-color: var(--color-primary); +} + +[data-theme="dark"] input[type="checkbox"]:checked + .tree-checkbox, +[data-theme="dark"] .tree-checkbox.checked { + background-color: var(--color-primary); + border-color: var(--color-primary); +} + +[data-theme="dark"] .tree-checkbox.indeterminate { + background-color: rgba(59, 130, 246, 0.3); + border-color: var(--color-primary); +} + +/* Conflicts banner dark */ +[data-theme="dark"] .conflicts-banner { + background-color: rgba(245, 158, 11, 0.1); + border-color: var(--color-warning); + color: #fbbf24; +} + +/* Modal dark */ +[data-theme="dark"] .modal { + background-color: rgba(0, 0, 0, 0.7); +} + +[data-theme="dark"] .modal-content { + background-color: var(--color-bg-secondary); +} + +[data-theme="dark"] .preview-content { + background-color: var(--color-bg-tertiary); + color: var(--color-text); +} + +/* Loading overlay dark */ +[data-theme="dark"] .loading-overlay { + background-color: rgba(0, 0, 0, 0.7); +} + +/* Messages d'erreur/succès dark */ +[data-theme="dark"] .error-message { + background-color: rgba(239, 68, 68, 0.2); + border-color: var(--color-error); + color: #fca5a5; +} + +[data-theme="dark"] .success-message { + background-color: rgba(16, 185, 129, 0.2); + border-color: var(--color-success); + color: #6ee7b7; +} + +/* Upload panel states dark */ +[data-theme="dark"] .upload-panel.success { + border-color: var(--color-success); + background-color: rgba(16, 185, 129, 0.1); +} + +[data-theme="dark"] .upload-panel.error { + border-color: var(--color-error); + background-color: rgba(239, 68, 68, 0.1); +} + +/* Progress bar dark */ +[data-theme="dark"] .progress-bar { + background-color: var(--color-bg-tertiary); +} + +/* Scrollbar dark */ +[data-theme="dark"] .tree-content::-webkit-scrollbar-track { + background: var(--color-bg); +} + +[data-theme="dark"] .tree-content::-webkit-scrollbar-thumb { + background: var(--color-border-hover); +} + +[data-theme="dark"] .tree-content::-webkit-scrollbar-thumb:hover { + background: var(--color-border); +} + +/* ===== Transition smooth entre thèmes ===== */ +body, +.header, +.upload-panel, +.tree-panel, +.footer, +.drop-zone, +.btn-icon, +.search-input, +.tree-item, +.tree-checkbox, +.modal-content, +.preview-content { + transition: + background-color var(--transition-base), + color var(--transition-base), + border-color var(--transition-base); +} + +/* ===== Preference système ===== */ +/* Détecter automatiquement la préférence système */ +@media (prefers-color-scheme: dark) { + /* Par défaut, respecter la préférence système + Mais l'utilisateur peut override avec le toggle */ + body:not([data-theme]) { + /* Les variables dark seront appliquées via JavaScript si aucun thème n'est explicitement défini */ + } +} + +/* ===== Accessibilité ===== */ +/* Assurer un contraste suffisant en dark mode */ +[data-theme="dark"] { + /* Force le contraste minimum pour accessibilité */ + color-scheme: dark; +} + +[data-theme="light"] { + color-scheme: light; +} + +/* ===== Print styles ===== */ +/* Forcer le thème clair pour l'impression */ +@media print { + body[data-theme] { + --color-bg: white; + --color-text: black; + --color-border: #e5e7eb; + } +} diff --git a/assets/css/upload-panel.css b/assets/css/upload-panel.css new file mode 100644 index 0000000..3f023f3 --- /dev/null +++ b/assets/css/upload-panel.css @@ -0,0 +1,268 @@ +/** + * FuZip - Styles zones d'upload + * Drop zones, boutons browse, progress bars + */ + +/* ===== Panel d'upload ===== */ +.upload-panel { + background-color: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--spacing-lg); + display: flex; + flex-direction: column; + gap: var(--spacing-md); + box-shadow: var(--shadow-sm); + transition: all var(--transition-base); +} + +.panel-title { + font-size: var(--font-size-lg); + font-weight: 600; + color: var(--color-text); + margin: 0; +} + +/* ===== Drop Zone ===== */ +.drop-zone { + border: 2px dashed var(--color-border); + border-radius: var(--radius-md); + padding: var(--spacing-2xl) var(--spacing-lg); + text-align: center; + background-color: var(--color-bg-secondary); + transition: all var(--transition-base); + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-md); +} + +.drop-zone:hover { + border-color: var(--color-primary); + background-color: var(--color-primary-light); +} + +.drop-zone.drag-over { + border-color: var(--color-primary); + background-color: var(--color-primary-light); + border-style: solid; + transform: scale(1.02); +} + +.drop-icon { + width: 3rem; + height: 3rem; + color: var(--color-text-secondary); + transition: color var(--transition-base); +} + +.drop-zone:hover .drop-icon, +.drop-zone.drag-over .drop-icon { + color: var(--color-primary); +} + +.drop-text { + font-size: var(--font-size-lg); + font-weight: 500; + color: var(--color-text); +} + +.drop-or { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + text-transform: lowercase; +} + +.btn-browse { + padding: var(--spacing-sm) var(--spacing-xl); + background-color: white; + color: var(--color-primary); + border: 1px solid var(--color-primary); + border-radius: var(--radius-md); + font-size: var(--font-size-base); + font-weight: 500; + cursor: pointer; + transition: all var(--transition-fast); +} + +.btn-browse:hover { + background-color: var(--color-primary); + color: white; +} + +.file-input { + display: none; +} + +/* ===== Upload Info ===== */ +.upload-info { + display: flex; + flex-direction: column; + gap: var(--spacing-md); + padding: var(--spacing-md); + background-color: var(--color-bg-secondary); + border-radius: var(--radius-md); + border: 1px solid var(--color-border); +} + +.file-name { + font-weight: 600; + color: var(--color-text); + font-size: var(--font-size-base); + word-break: break-all; +} + +.file-stats { + display: flex; + gap: var(--spacing-lg); + flex-wrap: wrap; +} + +.stat-item { + display: flex; + align-items: center; + gap: var(--spacing-xs); + font-size: var(--font-size-sm); + color: var(--color-text-secondary); +} + +.stat-icon { + width: 1rem; + height: 1rem; + color: var(--color-text-muted); +} + +.stat-value { + font-weight: 600; + color: var(--color-text); +} + +/* ===== Progress Bar ===== */ +.upload-progress { + display: flex; + align-items: center; + gap: var(--spacing-md); +} + +.progress-bar { + flex: 1; + height: 0.5rem; + background-color: var(--color-bg-tertiary); + border-radius: var(--radius-xl); + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--color-primary) 0%, var(--color-primary-hover) 100%); + border-radius: var(--radius-xl); + transition: width var(--transition-base); + position: relative; + overflow: hidden; +} + +/* Animation de progression */ +.progress-fill::after { + content: ''; + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + background: linear-gradient( + 90deg, + transparent, + rgba(255, 255, 255, 0.3), + transparent + ); + animation: shimmer 1.5s infinite; +} + +@keyframes shimmer { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } +} + +.progress-text { + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--color-text-secondary); + min-width: 3rem; + text-align: right; +} + +/* ===== États spéciaux ===== */ +.upload-panel.uploading { + pointer-events: none; + opacity: 0.7; +} + +.upload-panel.success { + border-color: var(--color-success); + background-color: rgba(16, 185, 129, 0.05); +} + +.upload-panel.error { + border-color: var(--color-error); + background-color: rgba(239, 68, 68, 0.05); +} + +/* Message d'erreur */ +.error-message { + padding: var(--spacing-sm) var(--spacing-md); + background-color: #fee2e2; + border: 1px solid var(--color-error); + border-radius: var(--radius-md); + color: #991b1b; + font-size: var(--font-size-sm); + display: flex; + align-items: center; + gap: var(--spacing-sm); +} + +.error-icon { + width: 1.25rem; + height: 1.25rem; + flex-shrink: 0; +} + +/* Message de succès */ +.success-message { + padding: var(--spacing-sm) var(--spacing-md); + background-color: #d1fae5; + border: 1px solid var(--color-success); + border-radius: var(--radius-md); + color: #065f46; + font-size: var(--font-size-sm); + display: flex; + align-items: center; + gap: var(--spacing-sm); +} + +.success-icon { + width: 1.25rem; + height: 1.25rem; + flex-shrink: 0; +} + +/* ===== Responsive ===== */ +@media (max-width: 768px) { + .drop-zone { + padding: var(--spacing-xl) var(--spacing-md); + } + + .drop-icon { + width: 2.5rem; + height: 2.5rem; + } + + .drop-text { + font-size: var(--font-size-base); + } + + .file-stats { + flex-direction: column; + gap: var(--spacing-sm); + } +} diff --git a/assets/js/FileTreeRenderer.js b/assets/js/FileTreeRenderer.js new file mode 100644 index 0000000..efa2d21 --- /dev/null +++ b/assets/js/FileTreeRenderer.js @@ -0,0 +1,754 @@ +/** + * FuZip - Rendu de l'arborescence de fichiers + * Gère l'affichage hiérarchique, la sélection exclusive, les conflits + */ + +class FileTreeRenderer { + constructor(side, container, app) { + this.side = side; // 'left' ou 'right' + this.container = container; // DOM element + this.app = app; // Référence à FuZipApp pour accès à la sélection globale + this.tree = null; + this.filesList = []; + this.conflicts = []; + this.expandedFolders = new Set(); // Dossiers expand/collapsed + this.searchQuery = ''; + + // Initialiser la barre de recherche + this.initSearchBar(); + + console.log(`[FileTreeRenderer] Initialized for side: ${this.side}`); + } + + /** + * Initialise la barre de recherche + */ + initSearchBar() { + // Trouver le champ de recherche dans le même panel + const panel = this.container.closest('.tree-panel'); + if (!panel) return; + + const searchInput = panel.querySelector('.search-input'); + if (!searchInput) return; + + // Créer un wrapper pour le champ de recherche + const wrapper = document.createElement('div'); + wrapper.className = 'search-input-wrapper'; + + // Remplacer le searchInput par le wrapper dans le DOM + searchInput.parentElement.insertBefore(wrapper, searchInput); + wrapper.appendChild(searchInput); + + // Créer le bouton de réinitialisation + const clearBtn = document.createElement('button'); + clearBtn.className = 'search-clear-btn hidden'; + clearBtn.type = 'button'; + clearBtn.title = 'Effacer la recherche'; + clearBtn.innerHTML = ` + + + + + `; + + // Ajouter le bouton dans le wrapper + wrapper.appendChild(clearBtn); + + // Événement de recherche en temps réel + searchInput.addEventListener('input', (e) => { + const query = e.target.value.trim(); + + // Afficher/masquer le bouton X + if (query) { + clearBtn.classList.remove('hidden'); + } else { + clearBtn.classList.add('hidden'); + } + + this.search(query); + }); + + // Événement clic sur le bouton X + clearBtn.addEventListener('click', () => { + searchInput.value = ''; + clearBtn.classList.add('hidden'); + this.search(''); + }); + + this.searchInput = searchInput; + this.searchClearBtn = clearBtn; + } + + /** + * Charge et rend l'arborescence + * @param {Object} structure - Structure de l'arbre depuis l'API + * @param {Array} conflicts - Liste des chemins en conflit + */ + render(structure, conflicts = []) { + this.tree = structure.tree || {}; + this.filesList = structure.files_list || []; + this.conflicts = conflicts; + + // Vide le container + this.container.innerHTML = ''; + + // Si arbre vide + if (this.filesList.length === 0) { + this.renderEmpty(); + return; + } + + // Rend récursivement l'arbre (commence par les enfants de la racine) + if (this.tree.children && Array.isArray(this.tree.children)) { + this.renderChildren(this.tree.children, this.container, 1); + } + + // Restaurer l'état des checkboxes après le render + this.updateAllCheckboxStates(); + + console.log(`[FileTreeRenderer] Rendered ${this.filesList.length} items for ${this.side}`); + } + + /** + * Affiche un message d'arbre vide + */ + renderEmpty() { + const emptyDiv = document.createElement('div'); + emptyDiv.className = 'tree-empty'; + emptyDiv.innerHTML = ` + + + +

Aucun fichier

+ `; + this.container.appendChild(emptyDiv); + } + + /** + * Rend un tableau d'enfants + * @param {Array} children - Tableau d'enfants + * @param {HTMLElement} parentElement - Élément parent DOM + * @param {number} level - Niveau de profondeur (pour indentation) + */ + renderChildren(children, parentElement, level) { + // Trie : dossiers d'abord, puis fichiers, alphabétique + const sortedChildren = [...children].sort((a, b) => { + const isFileA = a.type === 'file'; + const isFileB = b.type === 'file'; + + if (isFileA !== isFileB) { + return isFileA ? 1 : -1; // Dossiers avant fichiers + } + + return a.name.localeCompare(b.name); + }); + + sortedChildren.forEach((childNode) => { + this.renderNode(childNode, parentElement, level); + }); + } + + /** + * Rend un noeud de l'arbre + * @param {Object} childNode - Noeud à rendre + * @param {HTMLElement} parentElement - Élément parent DOM + * @param {number} level - Niveau de profondeur (pour indentation) + */ + renderNode(childNode, parentElement, level) { + const isFile = childNode.type === 'file'; + const name = childNode.name; + const path = childNode.path; + const isExpanded = this.expandedFolders.has(path); + const isConflict = this.conflicts.includes(path); + + // Filtre recherche + const hasMatch = this.searchQuery ? this.matchesSearch(name, path) : true; + const hasChildMatch = !isFile && this.searchQuery ? this.hasMatchingChildren(childNode) : false; + + // Ne pas afficher si ni le noeud ni ses enfants ne matchent + if (this.searchQuery && !hasMatch && !hasChildMatch) { + return; + } + + // Auto-expand les dossiers qui contiennent des résultats + const shouldAutoExpand = this.searchQuery && !isFile && hasChildMatch; + if (shouldAutoExpand && !this.expandedFolders.has(path)) { + this.expandedFolders.add(path); + } + + // Créer l'élément tree-item + const itemDiv = document.createElement('div'); + itemDiv.className = 'tree-item'; + itemDiv.setAttribute('data-level', level); + itemDiv.setAttribute('data-path', path); + itemDiv.setAttribute('data-type', isFile ? 'file' : 'folder'); + + if (isFile) { + itemDiv.classList.add('file'); + } else { + itemDiv.classList.add('folder'); + // Vérifier l'état après auto-expand + const isFinallyExpanded = this.expandedFolders.has(path); + itemDiv.classList.add(isFinallyExpanded ? 'expanded' : 'collapsed'); + } + + if (isConflict) { + itemDiv.classList.add('conflict'); + } + + // Highlight si correspondance de recherche + if (this.searchQuery && hasMatch) { + itemDiv.classList.add('search-match'); + } + + // Icône expand/collapse (seulement pour dossiers) + const expandIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + expandIcon.setAttribute('class', 'tree-expand'); + expandIcon.setAttribute('viewBox', '0 0 24 24'); + expandIcon.innerHTML = ''; + + // Rendre l'item entier cliquable pour expand/collapse si c'est un dossier + if (!isFile) { + itemDiv.addEventListener('click', (e) => { + // Ne déclencher que si on ne clique pas sur la checkbox + if (e.target.closest('.tree-checkbox') || e.target.closest('input[type="checkbox"]')) { + return; + } + console.log(`[FileTreeRenderer] Folder item clicked: ${path}`); + this.toggleFolder(path, itemDiv); + }); + } + + // Checkbox (input caché + style custom) + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.setAttribute('data-path', path); + + const checkboxSpan = document.createElement('span'); + checkboxSpan.className = 'tree-checkbox'; + + // Événement sur le checkbox caché + checkbox.addEventListener('change', (e) => { + e.stopPropagation(); + console.log(`[FileTreeRenderer] Checkbox clicked: ${path}, checked: ${checkbox.checked}`); + this.handleCheckboxClick(path, checkbox.checked); + }); + + // Événement sur le span visible pour propager le clic + checkboxSpan.addEventListener('click', (e) => { + e.stopPropagation(); + checkbox.checked = !checkbox.checked; + checkbox.dispatchEvent(new Event('change')); + }); + + // Icône fichier/dossier + const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + icon.setAttribute('class', isFile ? 'tree-icon file' : 'tree-icon folder'); + icon.setAttribute('viewBox', '0 0 24 24'); + + if (isFile) { + icon.innerHTML = ` + + + `; + } else { + icon.innerHTML = ` + + `; + } + + // Nom + const nameSpan = document.createElement('span'); + nameSpan.className = 'tree-name'; + nameSpan.textContent = name; + + // Taille (seulement pour fichiers) + let sizeSpan = null; + if (isFile && childNode.size !== undefined) { + sizeSpan = document.createElement('span'); + sizeSpan.className = 'tree-size'; + sizeSpan.textContent = this.formatBytes(childNode.size); + } + + // Actions (preview et download) - seulement pour fichiers + let actionsDiv = null; + if (isFile) { + actionsDiv = document.createElement('div'); + actionsDiv.className = 'tree-actions'; + + // Bouton preview + const btnPreview = document.createElement('button'); + btnPreview.className = 'btn-item-action'; + btnPreview.title = 'Prévisualiser'; + + const svgPreview = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svgPreview.setAttribute('viewBox', '0 0 24 24'); + svgPreview.innerHTML = ` + + + `; + btnPreview.appendChild(svgPreview); + + btnPreview.addEventListener('click', (e) => { + e.stopPropagation(); + this.app.previewManager.preview(this.side, path, name); + }); + + // Bouton download + const btnDownload = document.createElement('button'); + btnDownload.className = 'btn-item-action'; + btnDownload.title = 'Télécharger'; + + const svgDownload = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svgDownload.setAttribute('viewBox', '0 0 24 24'); + svgDownload.innerHTML = ` + + + + `; + btnDownload.appendChild(svgDownload); + + btnDownload.addEventListener('click', (e) => { + e.stopPropagation(); + this.app.previewManager.extract(this.side, path, name); + }); + + actionsDiv.appendChild(btnPreview); + actionsDiv.appendChild(btnDownload); + } + + // Assemblage + itemDiv.appendChild(expandIcon); + itemDiv.appendChild(checkbox); + itemDiv.appendChild(checkboxSpan); + itemDiv.appendChild(icon); + itemDiv.appendChild(nameSpan); + if (sizeSpan) { + itemDiv.appendChild(sizeSpan); + } + if (actionsDiv) { + itemDiv.appendChild(actionsDiv); + } + + parentElement.appendChild(itemDiv); + + // Si dossier et expanded, rend les enfants + // Vérifier directement dans expandedFolders (car isExpanded peut être obsolète après auto-expand) + if (!isFile && this.expandedFolders.has(path) && childNode.children && Array.isArray(childNode.children)) { + this.renderChildren(childNode.children, parentElement, level + 1); + } + } + + /** + * Toggle expand/collapse d'un dossier + * @param {string} path - Chemin du dossier + * @param {HTMLElement} itemDiv - Élément DOM du dossier + */ + toggleFolder(path, itemDiv) { + console.log(`[FileTreeRenderer] toggleFolder called for: ${path}`); + + if (this.expandedFolders.has(path)) { + console.log(`[FileTreeRenderer] Collapsing folder: ${path}`); + this.expandedFolders.delete(path); + itemDiv.classList.remove('expanded'); + itemDiv.classList.add('collapsed'); + + // Supprime les enfants affichés + let nextSibling = itemDiv.nextElementSibling; + while (nextSibling && parseInt(nextSibling.getAttribute('data-level')) > parseInt(itemDiv.getAttribute('data-level'))) { + const toRemove = nextSibling; + nextSibling = nextSibling.nextElementSibling; + toRemove.remove(); + } + } else { + console.log(`[FileTreeRenderer] Expanding folder: ${path}`); + this.expandedFolders.add(path); + itemDiv.classList.remove('collapsed'); + itemDiv.classList.add('expanded'); + + // Re-render pour afficher les enfants + const level = parseInt(itemDiv.getAttribute('data-level')); + const node = this.findNodeByPath(this.tree, path); + + console.log(`[FileTreeRenderer] Found node:`, node); + + if (node && node.children && Array.isArray(node.children)) { + console.log(`[FileTreeRenderer] Rendering ${node.children.length} children`); + // Créer un fragment temporaire + const tempDiv = document.createElement('div'); + this.renderChildren(node.children, tempDiv, level + 1); + + // Insérer après l'item + let nextSibling = itemDiv.nextElementSibling; + while (tempDiv.firstChild) { + if (nextSibling) { + this.container.insertBefore(tempDiv.firstChild, nextSibling); + } else { + this.container.appendChild(tempDiv.firstChild); + } + } + + // Restaurer l'état des checkboxes après l'insertion + this.updateAllCheckboxStates(); + } + } + + // Sauvegarder l'état après toggle + this.app.saveState(); + } + + /** + * Trouve un noeud par son chemin dans l'arbre + * @param {Object} tree - Arbre ou noeud + * @param {string} targetPath - Chemin recherché + * @returns {Object|null} + */ + findNodeByPath(tree, targetPath) { + // Fonction récursive pour chercher dans un noeud + const searchInNode = (node) => { + // Si ce noeud correspond + if (node.path === targetPath) { + return node; + } + + // Si ce noeud a des enfants, chercher récursivement + if (node.children && Array.isArray(node.children)) { + for (const child of node.children) { + const found = searchInNode(child); + if (found) return found; + } + } + + return null; + }; + + // Si tree est la racine avec un tableau children, commencer par là + if (tree.children && Array.isArray(tree.children)) { + for (const child of tree.children) { + const found = searchInNode(child); + if (found) return found; + } + } else { + // Sinon chercher dans le noeud directement + return searchInNode(tree); + } + + return null; + } + + /** + * Gère le clic sur une checkbox (SÉLECTION EXCLUSIVE) + * @param {string} path - Chemin du fichier/dossier + * @param {boolean} checked - Nouvel état + */ + handleCheckboxClick(path, checked) { + const newSelection = checked ? this.side : null; + + // Sélection exclusive : si on sélectionne ici, on désélectionne de l'autre côté + const currentSelection = this.app.state.selection.get(path); + + if (checked && currentSelection && currentSelection !== this.side) { + // Désélectionner de l'autre côté avec animation + const otherSide = this.side === 'left' ? 'right' : 'left'; + const otherRenderer = this.side === 'left' ? this.app.treeRendererRight : this.app.treeRendererLeft; + + if (otherRenderer) { + otherRenderer.animateAutoDeselect(path); + } + } + + // Met à jour la sélection globale + this.app.state.selection.set(path, newSelection); + + // Propage aux enfants si c'est un dossier + this.propagateSelectionToChildren(path, newSelection); + + // Remonte pour mettre à jour les parents + this.updateParentCheckboxes(path); + + // Met à jour les états des checkboxes + this.updateAllCheckboxStates(); + + // Notifie l'app + this.app.onSelectionChanged(); + + console.log(`[FileTreeRenderer] ${this.side} - Selection: ${path} = ${newSelection}`); + } + + /** + * Propage la sélection aux enfants + * @param {string} parentPath - Chemin du parent + * @param {string|null} selection - 'left', 'right', ou null + */ + propagateSelectionToChildren(parentPath, selection) { + const node = this.findNodeByPath(this.tree, parentPath); + + if (node && node.children && Array.isArray(node.children)) { + const collectPaths = (children) => { + const paths = []; + children.forEach(child => { + paths.push(child.path); + if (child.children && Array.isArray(child.children)) { + paths.push(...collectPaths(child.children)); + } + }); + return paths; + }; + + const childPaths = collectPaths(node.children); + childPaths.forEach(childPath => { + this.app.state.selection.set(childPath, selection); + }); + } + } + + /** + * Met à jour les checkboxes des parents en remontant la hiérarchie + * @param {string} childPath - Chemin de l'enfant modifié + */ + updateParentCheckboxes(childPath) { + // Trouve le parent en remontant le chemin + const pathParts = childPath.split('/').filter(p => p); + + // Remonte chaque niveau parent du plus profond au plus haut + for (let i = pathParts.length - 1; i > 0; i--) { + const parentPath = pathParts.slice(0, i).join('/') + '/'; + + const parentNode = this.findNodeByPath(this.tree, parentPath); + + if (!parentNode || !parentNode.children || !Array.isArray(parentNode.children)) { + continue; + } + + // Vérifie l'état des enfants DIRECTS uniquement + let allSelected = true; + let noneSelected = true; + + for (const child of parentNode.children) { + const childSelection = this.app.state.selection.get(child.path); + + if (childSelection === this.side) { + noneSelected = false; + } else { + allSelected = false; + } + } + + // Décide de l'état du parent + if (allSelected) { + // Tous les enfants directs sélectionnés : parent coché + this.app.state.selection.set(parentPath, this.side); + console.log(`[FileTreeRenderer] Parent ${parentPath} checked (all children selected)`); + } else if (noneSelected) { + // Aucun enfant direct sélectionné : parent décoché + this.app.state.selection.set(parentPath, null); + console.log(`[FileTreeRenderer] Parent ${parentPath} unchecked (no children selected)`); + } else { + // Sélection partielle : parent décoché mais sera en état indeterminate visuellement + this.app.state.selection.set(parentPath, null); + console.log(`[FileTreeRenderer] Parent ${parentPath} indeterminate (partial selection)`); + } + } + } + + /** + * Met à jour tous les états des checkboxes (checked, unchecked, indeterminate) + */ + updateAllCheckboxStates() { + const checkboxes = this.container.querySelectorAll('input[type="checkbox"]'); + + checkboxes.forEach(checkbox => { + const path = checkbox.getAttribute('data-path'); + const selection = this.app.state.selection.get(path); + const itemDiv = checkbox.closest('.tree-item'); + const checkboxSpan = itemDiv.querySelector('.tree-checkbox'); + + // Réinitialise les classes + checkboxSpan.classList.remove('checked', 'indeterminate'); + + if (selection === this.side) { + checkbox.checked = true; + checkboxSpan.classList.add('checked'); + } else { + checkbox.checked = false; + + // Si c'est un dossier, vérifie l'état indeterminate + if (itemDiv.classList.contains('folder')) { + const hasSelectedChildren = this.hasSelectedChildren(path); + const hasUnselectedChildren = this.hasUnselectedChildren(path); + + if (hasSelectedChildren && hasUnselectedChildren) { + checkboxSpan.classList.add('indeterminate'); + } + } + } + }); + } + + /** + * Vérifie si un dossier a des enfants sélectionnés de ce côté + * @param {string} folderPath + * @returns {boolean} + */ + hasSelectedChildren(folderPath) { + const node = this.findNodeByPath(this.tree, folderPath); + if (!node || !node.children || !Array.isArray(node.children)) return false; + + const collectPaths = (children) => { + const paths = []; + children.forEach(child => { + paths.push(child.path); + if (child.children && Array.isArray(child.children)) { + paths.push(...collectPaths(child.children)); + } + }); + return paths; + }; + + const childPaths = collectPaths(node.children); + return childPaths.some(path => this.app.state.selection.get(path) === this.side); + } + + /** + * Vérifie si un dossier a des enfants non sélectionnés + * @param {string} folderPath + * @returns {boolean} + */ + hasUnselectedChildren(folderPath) { + const node = this.findNodeByPath(this.tree, folderPath); + if (!node || !node.children || !Array.isArray(node.children)) return false; + + const collectPaths = (children) => { + const paths = []; + children.forEach(child => { + paths.push(child.path); + if (child.children && Array.isArray(child.children)) { + paths.push(...collectPaths(child.children)); + } + }); + return paths; + }; + + const childPaths = collectPaths(node.children); + return childPaths.some(path => this.app.state.selection.get(path) !== this.side); + } + + /** + * Anime la désélection automatique (pulse jaune) - SÉLECTION EXCLUSIVE + * @param {string} path - Chemin du fichier à animer + */ + animateAutoDeselect(path) { + const itemDiv = this.container.querySelector(`[data-path="${path}"]`); + + if (itemDiv) { + itemDiv.classList.add('auto-deselected'); + + // Retire la classe après l'animation + setTimeout(() => { + itemDiv.classList.remove('auto-deselected'); + }, 600); + } + + // Met à jour la checkbox + const checkbox = this.container.querySelector(`input[data-path="${path}"]`); + if (checkbox) { + checkbox.checked = false; + const checkboxSpan = checkbox.nextElementSibling; + if (checkboxSpan) { + checkboxSpan.classList.remove('checked'); + } + } + } + + /** + * Filtre l'arbre par recherche + * @param {string} query + */ + search(query) { + this.searchQuery = query.toLowerCase(); + + // Réinitialiser l'auto-expansion à chaque changement de recherche + // pour éviter l'accumulation de dossiers expanded + this.expandedFolders.clear(); + + this.render({ tree: this.tree, files_list: this.filesList }, this.conflicts); + + // Sauvegarder l'état après recherche + this.app.saveState(); + } + + /** + * Vérifie si un nom/chemin correspond à la recherche + * @param {string} name + * @param {string} path + * @returns {boolean} + */ + matchesSearch(name, path) { + if (!this.searchQuery) return true; + return name.toLowerCase().includes(this.searchQuery) || + path.toLowerCase().includes(this.searchQuery); + } + + /** + * Vérifie récursivement si un noeud a des enfants qui matchent la recherche + * @param {Object} node - Noeud à vérifier + * @returns {boolean} + */ + hasMatchingChildren(node) { + if (!node.children || !Array.isArray(node.children)) { + return false; + } + + for (const child of node.children) { + // Si l'enfant matche directement + if (this.matchesSearch(child.name, child.path)) { + return true; + } + + // Si l'enfant est un dossier, vérifier récursivement + if (child.type === 'folder' && this.hasMatchingChildren(child)) { + return true; + } + } + + return false; + } + + /** + * Formate les octets + * @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]; + } + + /** + * Réinitialise le renderer + */ + clear() { + this.tree = null; + this.filesList = []; + this.conflicts = []; + this.expandedFolders.clear(); + this.searchQuery = ''; + this.container.innerHTML = ''; + + // Vider le champ de recherche + if (this.searchInput) { + this.searchInput.value = ''; + } + + // Masquer le bouton X + if (this.searchClearBtn) { + this.searchClearBtn.classList.add('hidden'); + } + } +} + +// Export pour utilisation dans app.js +window.FileTreeRenderer = FileTreeRenderer; diff --git a/assets/js/LanguageManager.js b/assets/js/LanguageManager.js new file mode 100644 index 0000000..222b984 --- /dev/null +++ b/assets/js/LanguageManager.js @@ -0,0 +1,105 @@ +/** + * FuZip - Gestionnaire de langue (Bilingual FR/EN) + * Gère le toggle entre français et anglais avec persistance localStorage + */ + +class LanguageManager { + constructor() { + this.storageKey = 'fuzip_lang'; + this.currentLang = this.loadLang(); + this.btnToggle = document.getElementById('btn-lang-toggle'); + this.i18n = window.FUZIP_CONFIG?.i18n || {}; + + this.init(); + } + + /** + * Initialise le gestionnaire de langue + */ + init() { + // Écoute le clic sur le bouton toggle + if (this.btnToggle) { + this.btnToggle.addEventListener('click', () => this.toggle()); + } + + // Synchronise le localStorage avec la langue actuelle de la page + this.saveLang(this.currentLang); + + console.log(`[LanguageManager] Initialized with lang: ${this.currentLang}`); + } + + /** + * Charge la langue depuis localStorage ou depuis la config globale + * @returns {string} 'fr' ou 'en' + */ + loadLang() { + // Vérifie localStorage en premier + const saved = localStorage.getItem(this.storageKey); + if (saved === 'fr' || saved === 'en') { + return saved; + } + + // Sinon utilise la langue de la config (depuis PHP) + return window.FUZIP_CONFIG?.lang || 'fr'; + } + + /** + * Sauvegarde la langue dans localStorage + * @param {string} lang - 'fr' ou 'en' + */ + saveLang(lang) { + if (lang === 'fr' || lang === 'en') { + localStorage.setItem(this.storageKey, lang); + this.currentLang = lang; + } + } + + /** + * Toggle entre français et anglais + * Recharge la page avec le paramètre ?lang=XX + */ + toggle() { + const newLang = this.currentLang === 'fr' ? 'en' : 'fr'; + this.saveLang(newLang); + + // Recharge la page avec le nouveau paramètre lang + const url = new URL(window.location.href); + url.searchParams.set('lang', newLang); + window.location.href = url.toString(); + } + + /** + * Récupère la langue actuelle + * @returns {string} + */ + getLang() { + return this.currentLang; + } + + /** + * Définit une langue spécifique + * @param {string} lang - 'fr' ou 'en' + */ + setLang(lang) { + if (lang === 'fr' || lang === 'en') { + this.saveLang(lang); + + // Recharge la page + const url = new URL(window.location.href); + url.searchParams.set('lang', lang); + window.location.href = url.toString(); + } + } + + /** + * Récupère une traduction depuis l'objet i18n + * @param {string} key - Clé de traduction + * @returns {string} + */ + t(key) { + return this.i18n[key] || key; + } +} + +// Export pour utilisation dans app.js +window.LanguageManager = LanguageManager; diff --git a/assets/js/PreviewManager.js b/assets/js/PreviewManager.js new file mode 100644 index 0000000..3762d8d --- /dev/null +++ b/assets/js/PreviewManager.js @@ -0,0 +1,139 @@ +/** + * FuZip - Gestionnaire de preview et extraction + * Options A et D : Preview de fichiers et download individuel + */ + +class PreviewManager { + constructor() { + this.apiBase = window.FUZIP_CONFIG?.apiBase || '/api/'; + + // Éléments DOM de la modal + this.modal = document.getElementById('preview-modal'); + this.modalTitle = document.getElementById('preview-title'); + this.modalContent = document.getElementById('preview-content'); + this.modalClose = document.getElementById('preview-close'); + + this.init(); + } + + /** + * Initialise les événements + */ + init() { + // Fermeture de la modal + if (this.modalClose) { + this.modalClose.addEventListener('click', () => this.closeModal()); + } + + // Fermeture en cliquant à l'extérieur + if (this.modal) { + this.modal.addEventListener('click', (e) => { + if (e.target === this.modal) { + this.closeModal(); + } + }); + } + + // Fermeture avec Escape + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && this.modal && !this.modal.classList.contains('hidden')) { + this.closeModal(); + } + }); + + console.log('[PreviewManager] Initialized'); + } + + /** + * Prévisualise un fichier (Option A) + * @param {string} side - 'left' ou 'right' + * @param {string} path - Chemin du fichier + * @param {string} fileName - Nom du fichier pour affichage + */ + async preview(side, path, fileName) { + try { + this.openModal(fileName, 'Chargement...'); + + const url = `${this.apiBase}preview.php?side=${side}&path=${encodeURIComponent(path)}&max_length=50000`; + const response = await fetch(url); + const data = await response.json(); + + if (data.success) { + let content = data.content || ''; + + // Si tronqué + if (data.truncated) { + content += `\n\n... (fichier tronqué à ${data.size_shown} octets sur ${data.total_size})`; + } + + // Si fichier binaire + if (data.is_binary) { + content = `[Fichier binaire - ${data.total_size} octets]\n\nAperçu non disponible pour ce type de fichier.`; + } + + this.modalContent.textContent = content; + } else { + this.modalContent.textContent = `Erreur : ${data.error || 'Impossible de charger le fichier'}`; + } + } catch (error) { + console.error('[PreviewManager] Preview error:', error); + this.modalContent.textContent = `Erreur réseau : ${error.message}`; + } + } + + /** + * Télécharge un fichier individuel (Option D) + * @param {string} side - 'left' ou 'right' + * @param {string} path - Chemin du fichier + * @param {string} fileName - Nom du fichier + */ + async extract(side, path, fileName) { + try { + const url = `${this.apiBase}extract.php?side=${side}&path=${encodeURIComponent(path)}`; + + // Créer un lien de téléchargement temporaire + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + + console.log(`[PreviewManager] Extracted: ${fileName} from ${side}`); + } catch (error) { + console.error('[PreviewManager] Extract error:', error); + alert(`Erreur lors du téléchargement : ${error.message}`); + } + } + + /** + * Ouvre la modal de preview + * @param {string} title - Titre (nom du fichier) + * @param {string} content - Contenu initial + */ + openModal(title, content = '') { + if (this.modalTitle) { + this.modalTitle.textContent = title; + } + + if (this.modalContent) { + this.modalContent.textContent = content; + } + + if (this.modal) { + this.modal.classList.remove('hidden'); + } + } + + /** + * Ferme la modal de preview + */ + closeModal() { + if (this.modal) { + this.modal.classList.add('hidden'); + } + } +} + +// Export pour utilisation dans app.js +window.PreviewManager = PreviewManager; diff --git a/assets/js/ThemeManager.js b/assets/js/ThemeManager.js new file mode 100644 index 0000000..569bf84 --- /dev/null +++ b/assets/js/ThemeManager.js @@ -0,0 +1,112 @@ +/** + * FuZip - Gestionnaire de thème (Option E: Dark Mode) + * Gère le toggle entre thème clair et sombre avec persistance localStorage + */ + +class ThemeManager { + constructor() { + this.storageKey = 'fuzip_theme'; + this.currentTheme = this.loadTheme(); + this.btnToggle = document.getElementById('btn-theme-toggle'); + + this.init(); + } + + /** + * Initialise le gestionnaire de thème + */ + init() { + // Applique le thème initial + this.applyTheme(this.currentTheme); + + // Écoute le clic sur le bouton toggle + if (this.btnToggle) { + this.btnToggle.addEventListener('click', () => this.toggle()); + } + + // Écoute les changements de préférence système + this.watchSystemPreference(); + + console.log(`[ThemeManager] Initialized with theme: ${this.currentTheme}`); + } + + /** + * Charge le thème depuis localStorage ou détecte la préférence système + * @returns {string} 'light' ou 'dark' + */ + loadTheme() { + // Vérifie localStorage en premier + const saved = localStorage.getItem(this.storageKey); + if (saved === 'light' || saved === 'dark') { + return saved; + } + + // Sinon détecte la préférence système + if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { + return 'dark'; + } + + return 'light'; // Par défaut + } + + /** + * Applique un thème + * @param {string} theme - 'light' ou 'dark' + */ + applyTheme(theme) { + document.documentElement.setAttribute('data-theme', theme); + this.currentTheme = theme; + localStorage.setItem(this.storageKey, theme); + + // Log pour debug + console.log(`[ThemeManager] Theme applied: ${theme}`); + } + + /** + * Toggle entre light et dark + */ + toggle() { + const newTheme = this.currentTheme === 'light' ? 'dark' : 'light'; + this.applyTheme(newTheme); + } + + /** + * Écoute les changements de préférence système + * (seulement si l'utilisateur n'a pas explicitement choisi un thème) + */ + watchSystemPreference() { + if (!window.matchMedia) return; + + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + + mediaQuery.addEventListener('change', (e) => { + // Ne change automatiquement que si pas de préférence explicite + const hasExplicitPreference = localStorage.getItem(this.storageKey) !== null; + if (!hasExplicitPreference) { + const newTheme = e.matches ? 'dark' : 'light'; + this.applyTheme(newTheme); + } + }); + } + + /** + * Récupère le thème actuel + * @returns {string} + */ + getTheme() { + return this.currentTheme; + } + + /** + * Définit un thème spécifique (utilisé par LanguageManager si besoin) + * @param {string} theme - 'light' ou 'dark' + */ + setTheme(theme) { + if (theme === 'light' || theme === 'dark') { + this.applyTheme(theme); + } + } +} + +// Export pour utilisation dans app.js +window.ThemeManager = ThemeManager; diff --git a/assets/js/UploadManager.js b/assets/js/UploadManager.js new file mode 100644 index 0000000..190767d --- /dev/null +++ b/assets/js/UploadManager.js @@ -0,0 +1,304 @@ +/** + * FuZip - Gestionnaire d'upload + * Gère le drag & drop, browse, upload vers API, progression + */ + +class UploadManager { + constructor(side, onStructureLoaded) { + this.side = side; // 'left' ou 'right' + this.onStructureLoaded = onStructureLoaded; // Callback quand structure reçue + this.apiBase = window.FUZIP_CONFIG?.apiBase || '/api/'; + this.i18n = window.FUZIP_CONFIG?.i18n || {}; + + // Éléments DOM + this.panel = document.getElementById(`upload-panel-${side}`); + this.dropZone = document.getElementById(`drop-zone-${side}`); + this.fileInput = document.getElementById(`file-input-${side}`); + this.btnBrowse = document.getElementById(`btn-browse-${side}`); + this.uploadInfo = document.getElementById(`upload-info-${side}`); + this.fileName = document.getElementById(`file-name-${side}`); + this.fileSize = document.getElementById(`file-size-${side}`); + this.fileCount = document.getElementById(`file-count-${side}`); + this.progressBar = document.getElementById(`progress-bar-${side}`); + this.progressFill = document.getElementById(`progress-fill-${side}`); + this.progressText = document.getElementById(`progress-text-${side}`); + + // État + this.currentFile = null; + this.structure = null; + this.isUploading = false; + + this.init(); + } + + /** + * Initialise les événements + */ + init() { + // Drag & Drop + if (this.dropZone) { + this.dropZone.addEventListener('dragover', (e) => this.onDragOver(e)); + this.dropZone.addEventListener('dragleave', (e) => this.onDragLeave(e)); + this.dropZone.addEventListener('drop', (e) => this.onDrop(e)); + this.dropZone.addEventListener('click', () => this.fileInput?.click()); + } + + // Browse button + if (this.btnBrowse) { + this.btnBrowse.addEventListener('click', (e) => { + e.stopPropagation(); + this.fileInput?.click(); + }); + } + + // File input change + if (this.fileInput) { + this.fileInput.addEventListener('change', (e) => this.onFileSelected(e)); + } + + console.log(`[UploadManager] Initialized for side: ${this.side}`); + } + + /** + * Événement dragover + */ + onDragOver(e) { + e.preventDefault(); + e.stopPropagation(); + this.dropZone?.classList.add('drag-over'); + } + + /** + * Événement dragleave + */ + onDragLeave(e) { + e.preventDefault(); + e.stopPropagation(); + this.dropZone?.classList.remove('drag-over'); + } + + /** + * Événement drop + */ + onDrop(e) { + e.preventDefault(); + e.stopPropagation(); + this.dropZone?.classList.remove('drag-over'); + + const files = e.dataTransfer?.files; + if (files && files.length > 0) { + this.handleFile(files[0]); + } + } + + /** + * Événement file input change + */ + onFileSelected(e) { + const files = e.target?.files; + if (files && files.length > 0) { + this.handleFile(files[0]); + } + } + + /** + * Gère un fichier sélectionné + * @param {File} file + */ + async handleFile(file) { + // Validation basique + if (!file.name.endsWith('.zip')) { + this.showError(this.i18n.error_not_zip || 'Le fichier doit être un ZIP'); + return; + } + + if (file.size > 500 * 1024 * 1024) { // 500 MB + this.showError(this.i18n.error_file_too_large || 'Fichier trop volumineux (max 500 MB)'); + return; + } + + this.currentFile = file; + + // Upload + await this.upload(); + } + + /** + * Upload le fichier vers l'API + */ + async upload() { + if (!this.currentFile || this.isUploading) return; + + this.isUploading = true; + this.panel?.classList.add('uploading'); + this.showProgress(0); + + const formData = new FormData(); + formData.append('file', this.currentFile); + formData.append('side', this.side); + + try { + const xhr = new XMLHttpRequest(); + + // Progression + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable) { + const percent = Math.round((e.loaded / e.total) * 100); + this.showProgress(percent); + } + }); + + // Promesse pour gérer le résultat + const response = await new Promise((resolve, reject) => { + xhr.addEventListener('load', () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + const data = JSON.parse(xhr.responseText); + resolve(data); + } catch (err) { + reject(new Error('Réponse JSON invalide')); + } + } else { + reject(new Error(`Erreur HTTP ${xhr.status}`)); + } + }); + + xhr.addEventListener('error', () => reject(new Error('Erreur réseau'))); + xhr.addEventListener('abort', () => reject(new Error('Upload annulé'))); + + xhr.open('POST', `${this.apiBase}upload.php`); + xhr.send(formData); + }); + + // Succès + if (response.success) { + this.structure = response.structure; + this.showSuccess(response.stats); + + // Callback vers app.js + if (this.onStructureLoaded) { + this.onStructureLoaded(this.side, response.structure, response.stats); + } + } else { + throw new Error(response.error || 'Erreur inconnue'); + } + + } catch (error) { + console.error(`[UploadManager] Upload error for ${this.side}:`, error); + this.showError(error.message); + } finally { + this.isUploading = false; + this.panel?.classList.remove('uploading'); + } + } + + /** + * Affiche la progression + * @param {number} percent - 0-100 + */ + showProgress(percent) { + if (this.uploadInfo) { + this.uploadInfo.classList.remove('hidden'); + } + + if (this.progressBar) { + this.progressBar.classList.remove('hidden'); + } + + if (this.progressFill) { + this.progressFill.style.width = `${percent}%`; + } + + if (this.progressText) { + this.progressText.textContent = `${percent}%`; + } + } + + /** + * Affiche le succès avec stats + * @param {Object} stats + */ + showSuccess(stats) { + this.panel?.classList.remove('error'); + this.panel?.classList.add('success'); + + if (this.fileName) { + this.fileName.textContent = this.currentFile?.name || ''; + } + + if (this.fileSize) { + this.fileSize.textContent = this.formatBytes(stats.total_size || 0); + } + + if (this.fileCount) { + this.fileCount.textContent = (stats.total_files || 0).toString(); + } + + // Cache la progress bar après 1s + setTimeout(() => { + this.progressBar?.classList.add('hidden'); + }, 1000); + } + + /** + * Affiche une erreur + * @param {string} message + */ + showError(message) { + this.panel?.classList.remove('success'); + this.panel?.classList.add('error'); + + alert(message); // TODO: Afficher dans l'UI plutôt qu'une alerte + + // Réinitialise + this.currentFile = null; + this.structure = null; + if (this.uploadInfo) { + this.uploadInfo.classList.add('hidden'); + } + if (this.progressBar) { + this.progressBar.classList.add('hidden'); + } + } + + /** + * 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]; + } + + /** + * Récupère la structure actuelle + * @returns {Object|null} + */ + getStructure() { + return this.structure; + } + + /** + * Réinitialise l'upload + */ + reset() { + this.currentFile = null; + this.structure = null; + this.isUploading = false; + + this.panel?.classList.remove('success', 'error', 'uploading'); + this.uploadInfo?.classList.add('hidden'); + this.progressBar?.classList.add('hidden'); + + if (this.fileInput) { + this.fileInput.value = ''; + } + } +} + +// Export pour utilisation dans app.js +window.UploadManager = UploadManager; diff --git a/assets/js/app.js b/assets/js/app.js new file mode 100644 index 0000000..c66e7b6 --- /dev/null +++ b/assets/js/app.js @@ -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(); +}); diff --git a/core/Config.php b/core/Config.php new file mode 100644 index 0000000..a41e9d7 --- /dev/null +++ b/core/Config.php @@ -0,0 +1,190 @@ + 'folder', + 'name' => 'root', + 'path' => '', + 'children' => [] + ]; + + // Trier les fichiers par chemin pour un traitement cohérent + usort($filesList, function($a, $b) { + return strcmp($a['name'], $b['name']); + }); + + foreach ($filesList as $file) { + $path = $file['name']; + $size = $file['size']; + $isDir = $file['is_dir']; + + // Découper le chemin en segments + $segments = explode('/', trim($path, '/')); + + // Naviguer/créer l'arborescence + self::addToTree($tree, $segments, $size, $isDir, $path); + } + + Config::log("Arborescence construite : " . self::countNodes($tree) . " nœuds"); + + return $tree; + } + + /** + * Ajoute un fichier/dossier à l'arborescence + * + * @param array &$node Nœud actuel (passé par référence) + * @param array $segments Segments du chemin restants + * @param int $size Taille du fichier + * @param bool $isDir Si c'est un dossier + * @param string $fullPath Chemin complet + * @param array $processedSegments Segments déjà traités (pour construire le path des dossiers intermédiaires) + */ + private static function addToTree(array &$node, array $segments, int $size, bool $isDir, string $fullPath, array $processedSegments = []): void { + if (empty($segments)) { + return; + } + + $currentSegment = array_shift($segments); + + // Si c'est vide (cas des chemins se terminant par /), ignorer + if ($currentSegment === '') { + return; + } + + // Ajouter le segment actuel aux segments traités + $processedSegments[] = $currentSegment; + + // Chercher si ce segment existe déjà dans les enfants + $existingChild = null; + $existingIndex = null; + + foreach ($node['children'] as $index => $child) { + if ($child['name'] === $currentSegment) { + $existingChild = &$node['children'][$index]; + $existingIndex = $index; + break; + } + } + + // Si c'est le dernier segment + if (empty($segments)) { + // C'est le fichier/dossier final + if ($existingChild === null) { + $node['children'][] = [ + 'type' => $isDir ? 'folder' : 'file', + 'name' => $currentSegment, + 'path' => $fullPath, + 'size' => $size, + 'children' => $isDir ? [] : null + ]; + } else { + // Le nœud existe déjà (cas des dossiers déclarés implicitement) + // Mettre à jour avec les vraies infos + $node['children'][$existingIndex]['type'] = $isDir ? 'folder' : 'file'; + $node['children'][$existingIndex]['path'] = $fullPath; + $node['children'][$existingIndex]['size'] = $size; + if (!$isDir) { + $node['children'][$existingIndex]['children'] = null; + } + } + } else { + // Il reste des segments, c'est un dossier intermédiaire + if ($existingChild === null) { + // Construire le chemin du dossier intermédiaire + $intermediatePath = implode('/', $processedSegments) . '/'; + + // Créer un dossier intermédiaire + $node['children'][] = [ + 'type' => 'folder', + 'name' => $currentSegment, + 'path' => $intermediatePath, + 'size' => 0, + 'children' => [] + ]; + // Récursion sur le nouveau nœud + self::addToTree($node['children'][count($node['children']) - 1], $segments, $size, $isDir, $fullPath, $processedSegments); + } else { + // Le dossier existe, continuer la récursion + // Mettre à jour le path s'il était vide + if ($node['children'][$existingIndex]['path'] === '') { + $node['children'][$existingIndex]['path'] = implode('/', $processedSegments) . '/'; + } + self::addToTree($node['children'][$existingIndex], $segments, $size, $isDir, $fullPath, $processedSegments); + } + } + } + + /** + * Compte le nombre total de nœuds dans l'arborescence + * + * @param array $node Nœud racine + * @return int Nombre de nœuds + */ + private static function countNodes(array $node): int { + $count = 1; // Le nœud lui-même + + if (isset($node['children']) && is_array($node['children'])) { + foreach ($node['children'] as $child) { + $count += self::countNodes($child); + } + } + + return $count; + } + + /** + * Détecte les conflits entre 2 listes de fichiers + * Un conflit = même chemin existe dans les 2 listes + * + * @param array $leftFiles Liste fichiers ZIP gauche + * @param array $rightFiles Liste fichiers ZIP droite + * @return array Liste des conflits avec détails + */ + public static function detectConflicts(array $leftFiles, array $rightFiles): array { + $conflicts = []; + + // Créer un index des fichiers de droite pour recherche rapide + $rightIndex = []; + foreach ($rightFiles as $file) { + $rightIndex[$file['name']] = $file; + } + + // Parcourir les fichiers de gauche + foreach ($leftFiles as $leftFile) { + $path = $leftFile['name']; + + // Vérifier si ce chemin existe à droite + if (isset($rightIndex[$path])) { + $rightFile = $rightIndex[$path]; + + $conflicts[] = [ + 'path' => $path, + 'type' => $leftFile['is_dir'] ? 'folder' : 'file', + 'left_size' => $leftFile['size'], + 'right_size' => $rightFile['size'], + 'size_diff' => abs($leftFile['size'] - $rightFile['size']), + 'same_size' => $leftFile['size'] === $rightFile['size'] + ]; + } + } + + Config::log("Conflits détectés : " . count($conflicts) . " chemins en doublon"); + + return $conflicts; + } + + /** + * Obtient tous les chemins d'une arborescence (liste plate) + * + * @param array $tree Arborescence + * @return array Liste de chemins + */ + public static function getAllPaths(array $tree): array { + $paths = []; + + if (isset($tree['path']) && $tree['path'] !== '') { + $paths[] = $tree['path']; + } + + if (isset($tree['children']) && is_array($tree['children'])) { + foreach ($tree['children'] as $child) { + $paths = array_merge($paths, self::getAllPaths($child)); + } + } + + return $paths; + } + + /** + * Filtre l'arborescence selon un pattern de recherche + * + * @param array $tree Arborescence + * @param string $searchTerm Terme de recherche (regex compatible) + * @param bool $caseSensitive Sensible à la casse + * @return array Arborescence filtrée + */ + public static function filterTree(array $tree, string $searchTerm, bool $caseSensitive = false): array { + // Copier l'arbre + $filtered = $tree; + + if (!isset($filtered['children']) || !is_array($filtered['children'])) { + return $filtered; + } + + $filteredChildren = []; + + foreach ($filtered['children'] as $child) { + // Vérifier si le nom matche + $name = $child['name']; + $matches = $caseSensitive + ? (stripos($name, $searchTerm) !== false) + : (strpos(strtolower($name), strtolower($searchTerm)) !== false); + + if ($matches) { + // Ce nœud matche, l'inclure + $filteredChildren[] = $child; + } elseif (isset($child['children']) && is_array($child['children'])) { + // Chercher récursivement dans les enfants + $filteredChild = self::filterTree($child, $searchTerm, $caseSensitive); + if (!empty($filteredChild['children'])) { + // Des enfants matchent, inclure ce dossier + $filteredChildren[] = $filteredChild; + } + } + } + + $filtered['children'] = $filteredChildren; + return $filtered; + } + + /** + * Calcule les statistiques d'une arborescence + * + * @param array $tree Arborescence + * @return array Statistiques (total_files, total_folders, total_size, max_depth) + */ + public static function getTreeStats(array $tree, int $currentDepth = 0): array { + $stats = [ + 'total_files' => 0, + 'total_folders' => 0, + 'total_size' => 0, + 'max_depth' => $currentDepth + ]; + + if ($tree['type'] === 'folder') { + $stats['total_folders']++; + } else { + $stats['total_files']++; + $stats['total_size'] += $tree['size'] ?? 0; + } + + if (isset($tree['children']) && is_array($tree['children'])) { + foreach ($tree['children'] as $child) { + $childStats = self::getTreeStats($child, $currentDepth + 1); + $stats['total_files'] += $childStats['total_files']; + $stats['total_folders'] += $childStats['total_folders']; + $stats['total_size'] += $childStats['total_size']; + $stats['max_depth'] = max($stats['max_depth'], $childStats['max_depth']); + } + } + + return $stats; + } + + /** + * Convertit une arborescence en liste plate de chemins + * + * @param array $tree Arborescence + * @param array $selection Sélection utilisateur (path => 'left'|'right'|null) + * @return array Liste de chemins sélectionnés + */ + public static function treeToPathList(array $tree, array $selection = []): array { + $paths = []; + + if (isset($tree['path']) && $tree['path'] !== '') { + // Vérifier si ce chemin est sélectionné + if (empty($selection) || isset($selection[$tree['path']])) { + $paths[] = $tree['path']; + } + } + + if (isset($tree['children']) && is_array($tree['children'])) { + foreach ($tree['children'] as $child) { + $childPaths = self::treeToPathList($child, $selection); + $paths = array_merge($paths, $childPaths); + } + } + + return $paths; + } + + /** + * Trouve un nœud dans l'arborescence par son chemin + * + * @param array $tree Arborescence + * @param string $path Chemin à rechercher + * @return array|null Nœud trouvé ou null + */ + public static function findNodeByPath(array $tree, string $path): ?array { + if (isset($tree['path']) && $tree['path'] === $path) { + return $tree; + } + + if (isset($tree['children']) && is_array($tree['children'])) { + foreach ($tree['children'] as $child) { + $found = self::findNodeByPath($child, $path); + if ($found !== null) { + return $found; + } + } + } + + return null; + } + + /** + * Valide une sélection utilisateur + * Vérifie qu'il n'y a pas de conflits (même fichier sélectionné des 2 côtés) + * + * @param array $selection Map path => 'left'|'right'|null + * @return array ['valid' => bool, 'conflicts' => array] + */ + public static function validateSelection(array $selection): array { + $pathsBySide = [ + 'left' => [], + 'right' => [] + ]; + + // Grouper par côté + foreach ($selection as $path => $side) { + if ($side === 'left' || $side === 'right') { + $pathsBySide[$side][] = $path; + } + } + + // Chercher les doublons (même path des 2 côtés) + $conflicts = array_intersect($pathsBySide['left'], $pathsBySide['right']); + + return [ + 'valid' => empty($conflicts), + 'conflicts' => array_values($conflicts) + ]; + } + + /** + * Optimise une sélection en supprimant les enfants si le parent est sélectionné + * + * @param array $selection Map path => 'left'|'right'|null + * @param array $tree Arborescence (pour connaître la hiérarchie) + * @return array Sélection optimisée + */ + public static function optimizeSelection(array $selection, array $tree): array { + $optimized = []; + + foreach ($selection as $path => $side) { + if ($side === null) { + continue; + } + + // Vérifier si un parent de ce chemin est déjà sélectionné du même côté + $hasParentSelected = false; + + foreach ($selection as $otherPath => $otherSide) { + if ($otherSide === $side && $otherPath !== $path) { + // Vérifier si otherPath est un parent de path + if (strpos($path, $otherPath . '/') === 0) { + $hasParentSelected = true; + break; + } + } + } + + // Si aucun parent n'est sélectionné, garder ce chemin + if (!$hasParentSelected) { + $optimized[$path] = $side; + } + } + + return $optimized; + } +} diff --git a/core/SessionManager.php b/core/SessionManager.php new file mode 100644 index 0000000..aa4f03a --- /dev/null +++ b/core/SessionManager.php @@ -0,0 +1,360 @@ + 24h) + */ + +require_once __DIR__ . '/Config.php'; + +class SessionManager { + /** + * Nom du fichier pour tracker la dernière activité + */ + const LAST_ACCESS_FILE = 'last_access.txt'; + + /** + * Initialise une session PHP et crée le dossier d'upload associé + * + * @return string L'ID de session + * @throws Exception Si la création du dossier échoue + */ + public static function init(): string { + // Démarrer la session si pas déjà démarrée + if (session_status() === PHP_SESSION_NONE) { + // Vérifier si on est en CLI (pour les tests) + if (php_sapi_name() === 'cli') { + // En CLI, générer un ID de session unique pour le test + $sessionId = 'cli_test_' . bin2hex(random_bytes(16)); + Config::log("Mode CLI détecté, session ID générée : {$sessionId}"); + } else { + // Mode web normal + session_start(); + $sessionId = session_id(); + Config::log("Session initialisée : {$sessionId}"); + } + } else { + $sessionId = session_id(); + Config::log("Session existante : {$sessionId}"); + } + + // Vérifier que le session_id n'est pas vide + if (empty($sessionId)) { + Config::log("Session ID vide, génération d'un ID unique", 'WARNING'); + $sessionId = 'fallback_' . bin2hex(random_bytes(16)); + } + + // Créer le dossier d'upload pour cette session + $uploadDir = self::getUploadDir($sessionId); + + if (!file_exists($uploadDir)) { + if (!mkdir($uploadDir, 0755, true)) { + Config::log("Échec création dossier : {$uploadDir}", 'ERROR'); + throw new Exception("Impossible de créer le dossier de session"); + } + Config::log("Dossier de session créé : {$uploadDir}"); + } + + // Mettre à jour le timestamp d'accès + self::updateAccess($sessionId); + + // Nettoyer les anciennes sessions (appelé à chaque initialisation) + self::cleanOldSessions(); + + return $sessionId; + } + + /** + * Obtient le chemin du dossier d'upload pour une session donnée + * + * @param string $sessionId ID de la session + * @return string Chemin absolu vers uploads/{sessionId}/ + */ + public static function getUploadDir(string $sessionId): string { + // Sécurité : valider que le session_id ne contient pas de caractères dangereux + // Autorise lettres, chiffres et underscores (pour cli_test_ et fallback_) + if (!preg_match('/^[a-zA-Z0-9_]{10,150}$/', $sessionId)) { + Config::log("Session ID invalide : {$sessionId}", 'WARNING'); + throw new InvalidArgumentException("Session ID invalide"); + } + + // Vérifier qu'il n'y a pas de .. ou de chemins relatifs + if (strpos($sessionId, '..') !== false || strpos($sessionId, '/') !== false || strpos($sessionId, '\\') !== false) { + Config::log("Session ID contient des caractères dangereux : {$sessionId}", 'WARNING'); + throw new InvalidArgumentException("Session ID invalide"); + } + + $uploadDir = Config::getUploadDir() . $sessionId . DIRECTORY_SEPARATOR; + return $uploadDir; + } + + /** + * Met à jour le timestamp de dernière activité pour une session + * + * @param string $sessionId ID de la session + * @return bool True si succès, false sinon + */ + public static function updateAccess(string $sessionId): bool { + try { + $uploadDir = self::getUploadDir($sessionId); + $lastAccessFile = $uploadDir . self::LAST_ACCESS_FILE; + + $timestamp = time(); + $result = file_put_contents($lastAccessFile, $timestamp); + + if ($result !== false) { + Config::log("Timestamp mis à jour pour session {$sessionId} : {$timestamp}"); + return true; + } + + Config::log("Échec mise à jour timestamp pour session {$sessionId}", 'WARNING'); + return false; + + } catch (Exception $e) { + Config::log("Erreur updateAccess : " . $e->getMessage(), 'ERROR'); + return false; + } + } + + /** + * Obtient le timestamp de dernière activité d'une session + * + * @param string $sessionId ID de la session + * @return int|null Timestamp ou null si non trouvé + */ + public static function getLastAccess(string $sessionId): ?int { + try { + $uploadDir = self::getUploadDir($sessionId); + $lastAccessFile = $uploadDir . self::LAST_ACCESS_FILE; + + if (!file_exists($lastAccessFile)) { + return null; + } + + $content = file_get_contents($lastAccessFile); + return $content !== false ? (int)$content : null; + + } catch (Exception $e) { + Config::log("Erreur getLastAccess : " . $e->getMessage(), 'ERROR'); + return null; + } + } + + /** + * Nettoie les sessions expirées (> SESSION_LIFETIME) + * Supprime récursivement les dossiers et leur contenu + * + * @return int Nombre de sessions supprimées + */ + public static function cleanOldSessions(): int { + $deletedCount = 0; + $uploadBaseDir = Config::getUploadDir(); + + Config::log("Début nettoyage sessions expirées"); + + try { + // Parcourir tous les dossiers dans uploads/ + if (!is_dir($uploadBaseDir)) { + Config::log("Dossier uploads inexistant : {$uploadBaseDir}", 'WARNING'); + return 0; + } + + $sessionDirs = scandir($uploadBaseDir); + + foreach ($sessionDirs as $sessionId) { + // Ignorer . et .. + if ($sessionId === '.' || $sessionId === '..') { + continue; + } + + $sessionPath = $uploadBaseDir . $sessionId; + + // Vérifier que c'est un dossier + if (!is_dir($sessionPath)) { + continue; + } + + // Récupérer le timestamp de dernière activité + $lastAccess = self::getLastAccess($sessionId); + + if ($lastAccess === null) { + // Pas de fichier last_access.txt, utiliser mtime du dossier + $lastAccess = filemtime($sessionPath); + } + + $age = time() - $lastAccess; + + // Si la session a expiré + if ($age > Config::SESSION_LIFETIME) { + Config::log("Session expirée : {$sessionId} (âge: {$age}s)"); + + if (self::deleteDirectory($sessionPath)) { + $deletedCount++; + Config::log("Session supprimée : {$sessionId}"); + } else { + Config::log("Échec suppression session : {$sessionId}", 'ERROR'); + } + } + } + + if ($deletedCount > 0) { + Config::log("Nettoyage terminé : {$deletedCount} session(s) supprimée(s)"); + } + + } catch (Exception $e) { + Config::log("Erreur lors du nettoyage : " . $e->getMessage(), 'ERROR'); + } + + return $deletedCount; + } + + /** + * Supprime récursivement un dossier et son contenu + * + * @param string $dir Chemin du dossier à supprimer + * @return bool True si succès, false sinon + */ + private static function deleteDirectory(string $dir): bool { + if (!file_exists($dir)) { + return true; + } + + if (!is_dir($dir)) { + return unlink($dir); + } + + // Parcourir le contenu du dossier + $items = scandir($dir); + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $itemPath = $dir . DIRECTORY_SEPARATOR . $item; + + if (is_dir($itemPath)) { + // Récursion pour sous-dossiers + if (!self::deleteDirectory($itemPath)) { + return false; + } + } else { + // Supprimer fichier + if (!unlink($itemPath)) { + Config::log("Échec suppression fichier : {$itemPath}", 'WARNING'); + return false; + } + } + } + + // Supprimer le dossier vide + return rmdir($dir); + } + + /** + * Supprime manuellement une session (ex: bouton "Nouveau") + * + * @param string $sessionId ID de la session à supprimer + * @return bool True si succès + */ + public static function deleteSession(string $sessionId): bool { + try { + $uploadDir = self::getUploadDir($sessionId); + + if (!file_exists($uploadDir)) { + return true; // Déjà supprimé + } + + $result = self::deleteDirectory($uploadDir); + + if ($result) { + Config::log("Session supprimée manuellement : {$sessionId}"); + } else { + Config::log("Échec suppression manuelle session : {$sessionId}", 'ERROR'); + } + + return $result; + + } catch (Exception $e) { + Config::log("Erreur deleteSession : " . $e->getMessage(), 'ERROR'); + return false; + } + } + + /** + * Vérifie si une session existe et est valide + * + * @param string $sessionId ID de la session + * @return bool True si la session existe et n'est pas expirée + */ + public static function isSessionValid(string $sessionId): bool { + try { + $uploadDir = self::getUploadDir($sessionId); + + if (!file_exists($uploadDir)) { + return false; + } + + $lastAccess = self::getLastAccess($sessionId); + if ($lastAccess === null) { + return false; + } + + $age = time() - $lastAccess; + return $age <= Config::SESSION_LIFETIME; + + } catch (Exception $e) { + Config::log("Erreur isSessionValid : " . $e->getMessage(), 'ERROR'); + return false; + } + } + + /** + * Obtient les statistiques d'une session + * + * @param string $sessionId ID de la session + * @return array|null Tableau avec infos ou null si erreur + */ + public static function getSessionStats(string $sessionId): ?array { + try { + $uploadDir = self::getUploadDir($sessionId); + + if (!file_exists($uploadDir)) { + return null; + } + + $lastAccess = self::getLastAccess($sessionId); + $age = $lastAccess ? time() - $lastAccess : null; + + // Compter les fichiers dans le dossier + $files = glob($uploadDir . '*'); + $fileCount = count($files); + + // Calculer la taille totale + $totalSize = 0; + foreach ($files as $file) { + if (is_file($file)) { + $totalSize += filesize($file); + } + } + + return [ + 'session_id' => $sessionId, + 'upload_dir' => $uploadDir, + 'last_access' => $lastAccess, + 'age_seconds' => $age, + 'file_count' => $fileCount, + 'total_size' => $totalSize, + 'total_size_formatted' => Config::formatBytes($totalSize), + 'is_expired' => $age ? $age > Config::SESSION_LIFETIME : false + ]; + + } catch (Exception $e) { + Config::log("Erreur getSessionStats : " . $e->getMessage(), 'ERROR'); + return null; + } + } +} diff --git a/core/ZipHandler.php b/core/ZipHandler.php new file mode 100644 index 0000000..fc2b193 --- /dev/null +++ b/core/ZipHandler.php @@ -0,0 +1,454 @@ + Config::MAX_FILE_SIZE) { + $sizeFormatted = Config::formatBytes($fileSize); + $maxFormatted = Config::formatBytes(Config::MAX_FILE_SIZE); + Config::log("Fichier ZIP trop volumineux : {$sizeFormatted} > {$maxFormatted}", 'ERROR'); + throw new Exception("Fichier ZIP trop volumineux ({$sizeFormatted}). Maximum : {$maxFormatted}"); + } + + // Valider que c'est bien un fichier ZIP (magic bytes) + if (!$this->isValidZip($zipPath)) { + Config::log("Fichier non-ZIP ou corrompu : {$zipPath}", 'ERROR'); + throw new Exception("Le fichier n'est pas un ZIP valide"); + } + + // Ouvrir le ZIP + $zip = new ZipArchive(); + $result = $zip->open($zipPath, ZipArchive::RDONLY); + + if ($result !== true) { + Config::log("Échec ouverture ZIP : {$zipPath} (code: {$result})", 'ERROR'); + throw new Exception("Impossible d'ouvrir le fichier ZIP (code erreur: {$result})"); + } + + Config::log("Extraction structure ZIP : {$zipPath} ({$zip->numFiles} fichiers)"); + + // Vérifier le nombre de fichiers + if ($zip->numFiles > Config::MAX_FILES_PER_ZIP) { + $zip->close(); + Config::log("Trop de fichiers dans le ZIP : {$zip->numFiles} > " . Config::MAX_FILES_PER_ZIP, 'ERROR'); + throw new Exception("Le ZIP contient trop de fichiers (" . $zip->numFiles . "). Maximum : " . Config::MAX_FILES_PER_ZIP); + } + + // Extraire la liste des fichiers + $filesList = []; + for ($i = 0; $i < $zip->numFiles; $i++) { + $stat = $zip->statIndex($i); + + if ($stat === false) { + Config::log("Impossible de lire l'entrée {$i} du ZIP", 'WARNING'); + continue; + } + + $name = $stat['name']; + $size = $stat['size']; + $isDir = substr($name, -1) === '/'; + + // Sanitize le nom + $nameSanitized = Config::sanitizePath($name); + + // Vérifier la profondeur + $depth = substr_count($nameSanitized, '/'); + if ($depth > Config::MAX_TREE_DEPTH) { + Config::log("Profondeur excessive ignorée : {$nameSanitized} (depth: {$depth})", 'WARNING'); + continue; + } + + // Vérifier les extensions interdites + if (!$isDir && Config::isForbiddenExtension($nameSanitized)) { + Config::log("Extension interdite ignorée : {$nameSanitized}", 'WARNING'); + continue; + } + + $filesList[] = [ + 'name' => $nameSanitized, + 'size' => $size, + 'is_dir' => $isDir, + 'index' => $i + ]; + } + + $zip->close(); + + Config::log("Structure extraite : " . count($filesList) . " éléments valides"); + + return [ + 'files' => $filesList, + 'total_files' => count($filesList), + 'total_size' => array_sum(array_column($filesList, 'size')), + 'zip_path' => $zipPath + ]; + } + + /** + * Vérifie si un fichier est un ZIP valide (magic bytes) + * + * @param string $filePath Chemin vers le fichier + * @return bool True si c'est un ZIP valide + */ + private function isValidZip(string $filePath): bool { + $handle = fopen($filePath, 'rb'); + if (!$handle) { + return false; + } + + // Lire les 4 premiers octets + $bytes = fread($handle, 4); + fclose($handle); + + if ($bytes === false || strlen($bytes) < 4) { + return false; + } + + // Convertir en hex + $hex = bin2hex($bytes); + + // Vérifier contre les signatures ZIP connues + foreach (Config::ZIP_MAGIC_BYTES as $magicByte) { + if (strcasecmp($hex, $magicByte) === 0) { + return true; + } + } + + return false; + } + + /** + * Fusionne des fichiers de 2 ZIP selon la sélection utilisateur + * + * @param string $leftZipPath Chemin vers le ZIP de gauche + * @param string $rightZipPath Chemin vers le ZIP de droite + * @param array $selection Map path => 'left'|'right'|null + * @param string $outputPath Chemin de sortie pour le ZIP fusionné + * @return string Chemin du ZIP créé + * @throws Exception Si erreur lors de la fusion + */ + public function merge(string $leftZipPath, string $rightZipPath, array $selection, string $outputPath): string { + Config::log("Début fusion : left={$leftZipPath}, right={$rightZipPath}, output={$outputPath}"); + + // Ouvrir les ZIP sources + $leftZip = new ZipArchive(); + $rightZip = new ZipArchive(); + $outputZip = new ZipArchive(); + + if ($leftZip->open($leftZipPath, ZipArchive::RDONLY) !== true) { + throw new Exception("Impossible d'ouvrir le ZIP de gauche"); + } + + if ($rightZip->open($rightZipPath, ZipArchive::RDONLY) !== true) { + $leftZip->close(); + throw new Exception("Impossible d'ouvrir le ZIP de droite"); + } + + // Créer le ZIP de sortie + if ($outputZip->open($outputPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + $leftZip->close(); + $rightZip->close(); + throw new Exception("Impossible de créer le ZIP de sortie"); + } + + $addedCount = 0; + $skippedCount = 0; + $addedPaths = []; // Pour éviter les doublons + + // Parcourir la sélection + foreach ($selection as $path => $side) { + if ($side === null) { + // Aucun côté sélectionné, ignorer + $skippedCount++; + continue; + } + + // Déterminer le ZIP source + $sourceZip = ($side === 'left') ? $leftZip : $rightZip; + + // Vérifier si c'est un dossier (se termine par /) + $isFolder = substr($path, -1) === '/'; + + if ($isFolder) { + // C'est un dossier : ajouter tous les fichiers qui commencent par ce chemin + $folderFileCount = 0; + + for ($i = 0; $i < $sourceZip->numFiles; $i++) { + $filename = $sourceZip->getNameIndex($i); + + // Vérifier si ce fichier est dans le dossier + if (strpos($filename, $path) === 0) { + // Éviter les doublons + if (isset($addedPaths[$filename])) { + continue; + } + + // Vérifier si c'est un dossier (entrée ZIP vide) + $stat = $sourceZip->statIndex($i); + if ($stat !== false && substr($stat['name'], -1) === '/') { + // C'est un dossier vide, on peut l'ignorer ou l'ajouter + // Pour l'instant on l'ignore car on ajoute déjà les fichiers + continue; + } + + // Récupérer le contenu + $content = $sourceZip->getFromIndex($i); + + if ($content === false) { + Config::log("Impossible de lire : {$filename}", 'WARNING'); + $skippedCount++; + continue; + } + + // Ajouter au ZIP de sortie + if ($outputZip->addFromString($filename, $content)) { + $addedCount++; + $folderFileCount++; + $addedPaths[$filename] = true; + + // Flush tous les 50 fichiers pour éviter les timeouts + if ($folderFileCount % 50 === 0) { + @ob_flush(); + @flush(); + } + } else { + Config::log("Échec ajout : {$filename}", 'WARNING'); + $skippedCount++; + } + } + } + + Config::log("Dossier {$path} : {$folderFileCount} fichiers ajoutés"); + } else { + // C'est un fichier individuel + // Éviter les doublons + if (isset($addedPaths[$path])) { + continue; + } + + // Rechercher l'index du fichier dans le ZIP source + $index = $sourceZip->locateName($path); + + if ($index === false) { + Config::log("Fichier introuvable : {$path} (côté: {$side})", 'WARNING'); + $skippedCount++; + continue; + } + + // Récupérer le contenu + $content = $sourceZip->getFromIndex($index); + + if ($content === false) { + Config::log("Impossible de lire : {$path}", 'WARNING'); + $skippedCount++; + continue; + } + + // Ajouter au ZIP de sortie + if ($outputZip->addFromString($path, $content)) { + $addedCount++; + $addedPaths[$path] = true; + } else { + Config::log("Échec ajout : {$path}", 'WARNING'); + $skippedCount++; + } + } + } + + // Fermer les ZIP + $leftZip->close(); + $rightZip->close(); + $outputZip->close(); + + Config::log("Fusion terminée : {$addedCount} fichiers ajoutés, {$skippedCount} ignorés"); + + return $outputPath; + } + + /** + * Extrait un fichier spécifique d'un ZIP + * + * @param string $zipPath Chemin vers le ZIP + * @param string $filePath Chemin du fichier dans le ZIP + * @param string $outputPath Chemin de sortie + * @return bool True si succès + */ + public function extractFile(string $zipPath, string $filePath, string $outputPath): bool { + $zip = new ZipArchive(); + + if ($zip->open($zipPath, ZipArchive::RDONLY) !== true) { + Config::log("Impossible d'ouvrir le ZIP : {$zipPath}", 'ERROR'); + return false; + } + + $content = $zip->getFromName($filePath); + + if ($content === false) { + Config::log("Fichier introuvable dans le ZIP : {$filePath}", 'ERROR'); + $zip->close(); + return false; + } + + $result = file_put_contents($outputPath, $content); + $zip->close(); + + if ($result === false) { + Config::log("Échec écriture fichier : {$outputPath}", 'ERROR'); + return false; + } + + Config::log("Fichier extrait : {$filePath} -> {$outputPath}"); + return true; + } + + /** + * Prévisualise le contenu d'un fichier texte dans un ZIP + * + * @param string $zipPath Chemin vers le ZIP + * @param string $filePath Chemin du fichier dans le ZIP + * @param int $maxLength Longueur maximale à retourner + * @return string|null Contenu du fichier ou null si erreur + */ + public function previewFile(string $zipPath, string $filePath, int $maxLength = 10000): ?string { + $zip = new ZipArchive(); + + if ($zip->open($zipPath, ZipArchive::RDONLY) !== true) { + Config::log("Impossible d'ouvrir le ZIP pour prévisualisation : {$zipPath}", 'ERROR'); + return null; + } + + $content = $zip->getFromName($filePath); + $zip->close(); + + if ($content === false) { + Config::log("Fichier introuvable pour prévisualisation : {$filePath}", 'ERROR'); + return null; + } + + // Limiter la taille + if (strlen($content) > $maxLength) { + $content = substr($content, 0, $maxLength) . "\n\n... (tronqué)"; + } + + // Vérifier que c'est du texte (pas du binaire) + if (!mb_check_encoding($content, 'UTF-8') && !mb_check_encoding($content, 'ASCII')) { + return "[Fichier binaire - prévisualisation impossible]"; + } + + return $content; + } + + /** + * Obtient des informations détaillées sur un ZIP + * + * @param string $zipPath Chemin vers le ZIP + * @return array|null Informations ou null si erreur + */ + public function getZipInfo(string $zipPath): ?array { + if (!file_exists($zipPath)) { + return null; + } + + $zip = new ZipArchive(); + if ($zip->open($zipPath, ZipArchive::RDONLY) !== true) { + return null; + } + + $info = [ + 'file_path' => $zipPath, + 'file_size' => filesize($zipPath), + 'file_size_formatted' => Config::formatBytes(filesize($zipPath)), + 'num_files' => $zip->numFiles, + 'comment' => $zip->comment, + 'is_valid' => $this->isValidZip($zipPath) + ]; + + $zip->close(); + return $info; + } + + /** + * Valide un fichier uploadé ZIP + * + * @param array $uploadedFile Tableau $_FILES['file'] + * @return array ['valid' => bool, 'error' => string|null] + */ + public function validateUpload(array $uploadedFile): array { + // Vérifier les erreurs d'upload + if ($uploadedFile['error'] !== UPLOAD_ERR_OK) { + $errorMessages = [ + UPLOAD_ERR_INI_SIZE => 'Fichier trop volumineux (php.ini)', + UPLOAD_ERR_FORM_SIZE => 'Fichier trop volumineux (formulaire)', + UPLOAD_ERR_PARTIAL => 'Upload partiel', + UPLOAD_ERR_NO_FILE => 'Aucun fichier uploadé', + UPLOAD_ERR_NO_TMP_DIR => 'Dossier temporaire manquant', + UPLOAD_ERR_CANT_WRITE => 'Échec écriture disque', + UPLOAD_ERR_EXTENSION => 'Extension PHP a stoppé l\'upload' + ]; + + $error = $errorMessages[$uploadedFile['error']] ?? 'Erreur inconnue'; + Config::log("Erreur upload : {$error} (code: {$uploadedFile['error']})", 'ERROR'); + return ['valid' => false, 'error' => $error]; + } + + // Vérifier la taille + if ($uploadedFile['size'] > Config::MAX_FILE_SIZE) { + $sizeFormatted = Config::formatBytes($uploadedFile['size']); + $maxFormatted = Config::formatBytes(Config::MAX_FILE_SIZE); + return [ + 'valid' => false, + 'error' => "Fichier trop volumineux ({$sizeFormatted}). Maximum : {$maxFormatted}" + ]; + } + + // Vérifier le type MIME + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mimeType = finfo_file($finfo, $uploadedFile['tmp_name']); + finfo_close($finfo); + + if (!Config::isAllowedMimeType($mimeType)) { + Config::log("Type MIME non autorisé : {$mimeType}", 'WARNING'); + return [ + 'valid' => false, + 'error' => "Type de fichier non autorisé ({$mimeType}). ZIP uniquement." + ]; + } + + // Vérifier les magic bytes + if (!$this->isValidZip($uploadedFile['tmp_name'])) { + Config::log("Magic bytes invalides pour fichier uploadé", 'WARNING'); + return [ + 'valid' => false, + 'error' => "Le fichier n'est pas un ZIP valide" + ]; + } + + Config::log("Fichier ZIP validé : {$uploadedFile['name']} ({$uploadedFile['size']} octets)"); + return ['valid' => true, 'error' => null]; + } +} diff --git a/index.php b/index.php new file mode 100644 index 0000000..28d6622 --- /dev/null +++ b/index.php @@ -0,0 +1,368 @@ +getMessage())); +} + +// Langue par défaut (peut être changée par l'utilisateur) +$lang = $_GET['lang'] ?? 'fr'; +if (!in_array($lang, Config::SUPPORTED_LANGUAGES)) { + $lang = Config::DEFAULT_LANGUAGE; +} + +// Textes multilingues +$i18n = [ + 'fr' => [ + 'title' => 'FuZip - Fusion de Fichiers ZIP', + 'subtitle' => 'Fusionnez deux fichiers ZIP en choisissant les fichiers à conserver', + 'upload_left' => 'ZIP Gauche', + 'upload_right' => 'ZIP Droite', + 'drag_drop' => 'Glissez un fichier ZIP ici', + 'or' => 'ou', + 'browse' => 'Parcourir', + 'no_file' => 'Aucun fichier', + 'files_count' => 'fichiers', + 'total_size' => 'Taille totale', + 'search_placeholder' => 'Rechercher un fichier...', + 'select_all' => 'Tout sélectionner', + 'deselect_all' => 'Tout désélectionner', + 'expand_all' => 'Tout déplier', + 'collapse_all' => 'Tout replier', + 'conflicts' => 'Conflits détectés', + 'merge_button' => 'Fusionner et Télécharger', + 'reset_button' => 'Réinitialiser', + 'selected_files' => 'fichiers sélectionnés', + 'loading' => 'Chargement...', + 'theme_toggle' => 'Changer de thème', + 'lang_toggle' => 'Language' + ], + 'en' => [ + 'title' => 'FuZip - ZIP Files Merger', + 'subtitle' => 'Merge two ZIP files by choosing which files to keep', + 'upload_left' => 'Left ZIP', + 'upload_right' => 'Right ZIP', + 'drag_drop' => 'Drop a ZIP file here', + 'or' => 'or', + 'browse' => 'Browse', + 'no_file' => 'No file', + 'files_count' => 'files', + 'total_size' => 'Total size', + 'search_placeholder' => 'Search for a file...', + 'select_all' => 'Select all', + 'deselect_all' => 'Deselect all', + 'expand_all' => 'Expand all', + 'collapse_all' => 'Collapse all', + 'conflicts' => 'Conflicts detected', + 'merge_button' => 'Merge and Download', + 'reset_button' => 'Reset', + 'selected_files' => 'files selected', + 'loading' => 'Loading...', + 'theme_toggle' => 'Toggle theme', + 'lang_toggle' => 'Langue' + ] +]; + +$t = $i18n[$lang]; +?> + + + + + + <?= htmlspecialchars($t['title']) ?> + + + + + + + + + +
+
+ + +

+ +
+ + + + + +
+
+
+ + +
+ +
+ +
+

+ +
+ + + + + +

+

+ + +
+ + +
+ + +
+

+ +
+ + + + + +

+

+ + +
+ + +
+
+ + + + + +
+ +
+
+ +
+ + +
+
+
+ +
+
+ + +
+
+ +
+ + +
+
+
+ +
+
+
+ + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + diff --git a/uploads/.gitkeep b/uploads/.gitkeep new file mode 100644 index 0000000..e69de29