- 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
409 lines
14 KiB
PHP
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;
|
|
}
|
|
}
|