Files
fuzip/core/FileTree.php
Charles bd6d321ed7 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
2026-01-12 03:29:01 +01:00

409 lines
14 KiB
PHP

<?php
/**
* FileTree - Construction d'arborescence et détection de conflits
*
* Cette classe gère :
* - La conversion d'une liste plate de fichiers en arborescence hiérarchique
* - La détection de conflits entre 2 ZIP (fichiers/dossiers identiques)
* - La génération de structures JSON pour le frontend
*/
require_once __DIR__ . '/Config.php';
class FileTree {
/**
* Construit une arborescence hiérarchique à partir d'une liste plate de fichiers
*
* @param array $filesList Liste plate de fichiers avec 'name', 'size', 'is_dir'
* @return array Structure hiérarchique
*/
public static function buildTree(array $filesList): array {
$tree = [
'type' => '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;
}
}