diff --git a/TODO.md b/TODO.md index 0102870..b24801b 100644 --- a/TODO.md +++ b/TODO.md @@ -104,6 +104,11 @@ # Current tasks +- [x] Cleanup modal: list files that will be removed (not just counts) +- [x] Storage restructure: documents/ → {objet}/ (tfe/theses/frart) +- [x] Migration script: move files + update DB paths +- [x] Fix: hardDeleteThesis doesn't prepend STORAGE_ROOT to file_path + - [x] Sticky thead: fix with border-collapse:separate, CSS class, --sticky-top var, +min-height:50vh on wrappers, +bulk delete for mots-clés - [x] Edit submit redirects to recapitulatif instead of staying on edit.php - [x] Mandatory auto-generated passwords on share links (no custom passwords, regenerate-only in edit, rate limit on password gate) diff --git a/app/public/admin/actions/cleanup-stats.php b/app/public/admin/actions/cleanup-stats.php index bb0bd3a..b4d4588 100644 --- a/app/public/admin/actions/cleanup-stats.php +++ b/app/public/admin/actions/cleanup-stats.php @@ -34,6 +34,7 @@ $now = time(); // ── FilePond stats ─────────────────────────────────────────────────────── $fpStaleCount = 0; $fpStaleSize = 0; +$fpStaleFiles = []; $fpActiveCount = 0; $fpActiveSize = 0; @@ -75,6 +76,12 @@ if (is_dir($filepondDir)) { if ($stale) { $fpStaleCount++; $fpStaleSize += $size; + $fpStaleFiles[] = [ + 'name' => $item, + 'size' => $size, + 'human' => humanBytes($size), + 'age_minutes' => $ageMinutes, + ]; } else { $fpActiveCount++; $fpActiveSize += $size; @@ -96,6 +103,7 @@ while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { $trStaleCount = 0; $trStaleSize = 0; +$trStaleFiles = []; $trActiveCount = 0; $trActiveSize = 0; @@ -131,6 +139,12 @@ if (is_dir($trashDir)) { if ($stale) { $trStaleCount++; $trStaleSize += $size; + $trStaleFiles[] = [ + 'name' => $item, + 'size' => $size, + 'human' => humanBytes($size), + 'age_days' => $ageDays, + ]; } else { $trActiveCount++; $trActiveSize += $size; @@ -144,12 +158,14 @@ echo json_encode([ 'filepond_stale_count' => $fpStaleCount, 'filepond_stale_size' => $fpStaleSize, 'filepond_stale_human' => humanBytes($fpStaleSize), + 'filepond_stale_files' => $fpStaleFiles, 'filepond_active_count' => $fpActiveCount, 'filepond_active_size' => $fpActiveSize, 'filepond_active_human' => humanBytes($fpActiveSize), 'trash_stale_count' => $trStaleCount, 'trash_stale_size' => $trStaleSize, 'trash_stale_human' => humanBytes($trStaleSize), + 'trash_stale_files' => $trStaleFiles, 'trash_active_count' => $trActiveCount, 'trash_active_size' => $trActiveSize, 'trash_active_human' => humanBytes($trActiveSize), diff --git a/app/public/admin/actions/filepond/relink.php b/app/public/admin/actions/filepond/relink.php index b7bd67f..b000943 100644 --- a/app/public/admin/actions/filepond/relink.php +++ b/app/public/admin/actions/filepond/relink.php @@ -51,8 +51,8 @@ if (!$thesisId || $filePath === '') { relinkError(400, 'Paramètres invalides (thesis_id + file_path requis).'); } -// Security: only allow paths under documents/ or theses/ -if (!preg_match('#^(documents|theses)/#', $filePath)) { +// Security: only allow paths under tfe/ these/ frart/ documents/ or theses/ +if (!preg_match('#^(tfe|these|frart|documents|theses)/#', $filePath)) { relinkError(403, 'Chemin de fichier non autorisé.'); } diff --git a/app/public/admin/fragments/file-browser.php b/app/public/admin/fragments/file-browser.php index 2570795..4311d7e 100644 --- a/app/public/admin/fragments/file-browser.php +++ b/app/public/admin/fragments/file-browser.php @@ -1,6 +1,6 @@ buildThesisFolder($data['annee'], $allAuthorsStr, $data['titre']); + $objet = $data['objet'] ?? 'tfe'; + $tf = $this->buildThesisFolder($data['annee'], $allAuthorsStr, $data['titre'], $objet); $folderName = $this->ensureUniqueFolder($tf['folderPath']); // Rebuild path with potentially modified folder name - $folderPath = 'documents/' . $data['annee'] . '/' . $folderName . '/'; + $folderPath = $objet . '/' . $data['annee'] . '/' . $folderName . '/'; $filePrefix = $folderName; if (!empty($post['filepond_mode'])) { diff --git a/app/src/Controllers/ThesisEditController.php b/app/src/Controllers/ThesisEditController.php index 610b4ae..72aadce 100644 --- a/app/src/Controllers/ThesisEditController.php +++ b/app/src/Controllers/ThesisEditController.php @@ -313,8 +313,10 @@ class ThesisEditController $year = intval($post['année'] ?? date('Y')); $title = trim($post['titre'] ?? ''); $authors = trim($post['auteurice'] ?? ''); + $thesis = $this->db->getThesis($thesisId); + $objet = $thesis['objet'] ?? 'tfe'; - $tf = $this->buildThesisFolder($year, $authors, $title); + $tf = $this->buildThesisFolder($year, $authors, $title, $objet); $folderPath = $tf['folderPath']; $filePrefix = $tf['filePrefix']; @@ -322,11 +324,12 @@ class ThesisEditController $existingFiles = $this->db->getThesisFiles($thesisId); foreach ($existingFiles as $f) { $fp = $f['file_path'] ?? ''; - if (str_starts_with($fp, 'documents/') || str_starts_with($fp, 'theses/')) { + if (preg_match('#^(theses|documents|tfe|these|frart)/#', $fp)) { $parts = explode('/', $fp); if (count($parts) >= 3) { + $parentDir = $parts[0]; $folderName = $parts[2]; - $folderPath = 'documents/' . $year . '/' . $folderName . '/'; + $folderPath = $parentDir . '/' . $year . '/' . $folderName . '/'; $filePrefix = $folderName; break; } diff --git a/app/src/Controllers/ThesisFileHandler.php b/app/src/Controllers/ThesisFileHandler.php index dc0e13e..963443f 100644 --- a/app/src/Controllers/ThesisFileHandler.php +++ b/app/src/Controllers/ThesisFileHandler.php @@ -6,7 +6,7 @@ * Shared file-upload logic used by ThesisCreateController and * ThesisEditController. All on-disk files are stored under: * - * documents/{YYYY}/{YYYY}_{AUTHORS}_{TITLE_SLUG}/ + * {objet}/{YYYY}/{YYYY}_{AUTHORS}_{TITLE_SLUG}/ * * Filenames follow: * @@ -88,7 +88,7 @@ trait ThesisFileHandler * * @param int $thesisId * @param array|null $upload Single-file $_FILES entry. - * @param string $folderPath Relative path from STORAGE_ROOT to the thesis folder (e.g. "documents/2025/2025_SMITH_Mon_Titre/"). + * @param string $folderPath Relative path from STORAGE_ROOT to the thesis folder (e.g. "tfe/2025/2025_SMITH_Mon_Titre/"). * @param string $filePrefix The prefix shared by all files in this folder (e.g. "2025_SMITH_Mon_Titre"). */ protected function handleCoverUpload(int $thesisId, ?array $upload, string $folderPath, string $filePrefix): void @@ -802,19 +802,19 @@ trait ThesisFileHandler /** * Build the folder path and file prefix for a thesis. * - * Folder: documents/{YYYY}/{YYYY}_{AUTHORS}_{TITLE_SLUG}/ + * Folder: {objet}/{YYYY}/{YYYY}_{AUTHORS}_{TITLE_SLUG}/ * Prefix: {YYYY}_{AUTHORS}_{TITLE_SLUG} * * @return array{folderPath: string, filePrefix: string, folderName: string} */ - protected function buildThesisFolder(int $year, string $authorsStr, string $title): array + protected function buildThesisFolder(int $year, string $authorsStr, string $title, string $objet = 'tfe'): array { $authorSlug = $this->generateAuthorSlug($authorsStr); $titleSlug = $this->generateTitleSlug($title); $folderName = $year . '_' . $authorSlug . '_' . $titleSlug; $filePrefix = $folderName; - $folderPath = 'documents/' . $year . '/' . $folderName . '/'; + $folderPath = $objet . '/' . $year . '/' . $folderName . '/'; return [ 'folderPath' => $folderPath, diff --git a/app/src/Database.php b/app/src/Database.php index 924edb3..8bbc6c9 100644 --- a/app/src/Database.php +++ b/app/src/Database.php @@ -2335,9 +2335,15 @@ class Database // Clean up thesis files from disk $files = $this->getThesisFiles($thesisId); + $storageRoot = defined('STORAGE_ROOT') ? STORAGE_ROOT : '/var/www/xamxam/storage'; foreach ($files as $file) { - if (!empty($file['file_path']) && file_exists($file['file_path'])) { - @unlink($file['file_path']); + $fp = $file['file_path'] ?? ''; + if ($fp === '' || str_starts_with($fp, 'http://') || str_starts_with($fp, 'https://') || str_starts_with($fp, 'peertube_ids:')) { + continue; + } + $abs = $storageRoot . '/' . $fp; + if (file_exists($abs)) { + @unlink($abs); } } diff --git a/app/storage/theses/2025/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU_ANNEXE_01.zip b/app/storage/tfe/2025/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU_ANNEXE_01.zip similarity index 100% rename from app/storage/theses/2025/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU_ANNEXE_01.zip rename to app/storage/tfe/2025/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU_ANNEXE_01.zip diff --git a/app/storage/theses/2025/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU_COUVERTURE.png b/app/storage/tfe/2025/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU_COUVERTURE.png similarity index 100% rename from app/storage/theses/2025/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU_COUVERTURE.png rename to app/storage/tfe/2025/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU_COUVERTURE.png diff --git a/app/storage/theses/2025/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU_NOTE_INTENTION.pdf b/app/storage/tfe/2025/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU_NOTE_INTENTION.pdf similarity index 100% rename from app/storage/theses/2025/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU_NOTE_INTENTION.pdf rename to app/storage/tfe/2025/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU_NOTE_INTENTION.pdf diff --git a/app/storage/theses/2025/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU_TFE_01.pdf b/app/storage/tfe/2025/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU_TFE_01.pdf similarity index 100% rename from app/storage/theses/2025/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU_TFE_01.pdf rename to app/storage/tfe/2025/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU_TFE_01.pdf diff --git a/app/storage/theses/2025/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU_TFE_02.pdf b/app/storage/tfe/2025/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU_TFE_02.pdf similarity index 100% rename from app/storage/theses/2025/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU_TFE_02.pdf rename to app/storage/tfe/2025/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU/2025_HUGO_BONNARD_POURQUOI_LES_ARTISTES_SONT_ILS_ENCORE_SUR_INSTAGRAM_ALORS_QU_TFE_02.pdf diff --git a/app/storage/theses/2025/2025_KARIM_NASSAR_LILA_DUBOIS_JOUER_LESPACE_DISPOSITIFS_SCENOGRAPHIQUES_POUR_LEXPERIENCE_P/2025_KARIM_NASSAR_LILA_DUBOIS_JOUER_LESPACE_DISPOSITIFS_SCENOGRAPHIQUES_POUR_LEXPERIENCE_P_NOTE_INTENTION.pdf b/app/storage/tfe/2025/2025_KARIM_NASSAR_LILA_DUBOIS_JOUER_LESPACE_DISPOSITIFS_SCENOGRAPHIQUES_POUR_LEXPERIENCE_P/2025_KARIM_NASSAR_LILA_DUBOIS_JOUER_LESPACE_DISPOSITIFS_SCENOGRAPHIQUES_POUR_LEXPERIENCE_P_NOTE_INTENTION.pdf similarity index 100% rename from app/storage/theses/2025/2025_KARIM_NASSAR_LILA_DUBOIS_JOUER_LESPACE_DISPOSITIFS_SCENOGRAPHIQUES_POUR_LEXPERIENCE_P/2025_KARIM_NASSAR_LILA_DUBOIS_JOUER_LESPACE_DISPOSITIFS_SCENOGRAPHIQUES_POUR_LEXPERIENCE_P_NOTE_INTENTION.pdf rename to app/storage/tfe/2025/2025_KARIM_NASSAR_LILA_DUBOIS_JOUER_LESPACE_DISPOSITIFS_SCENOGRAPHIQUES_POUR_LEXPERIENCE_P/2025_KARIM_NASSAR_LILA_DUBOIS_JOUER_LESPACE_DISPOSITIFS_SCENOGRAPHIQUES_POUR_LEXPERIENCE_P_NOTE_INTENTION.pdf diff --git a/app/templates/admin/index.php b/app/templates/admin/index.php index da88ada..6c93311 100644 --- a/app/templates/admin/index.php +++ b/app/templates/admin/index.php @@ -292,8 +292,24 @@ async function fetchTmpStats() { } } else { html = `

⚠️ ${totalStale} élément(s) obsolète(s) à nettoyer :

`; - if (data.filepond_stale_count) html += `

📁 Téléversements abandonnés : ${data.filepond_stale_count} dossier(s) — ${data.filepond_stale_human} (session expirée ou >2h)

`; - if (data.trash_stale_count) html += `

🗑️ Fichiers supprimés orphelins : ${data.trash_stale_count} fichier(s) — ${data.trash_stale_human} (référence DB disparue ou >30j)

`; + if (data.filepond_stale_count) { + html += `
📁 Téléversements abandonnés : ${data.filepond_stale_count} dossier(s) — ${data.filepond_stale_human} (session expirée ou >2h)`; + if (data.filepond_stale_files) { + html += ''; + } + html += '
'; + } + if (data.trash_stale_count) { + html += `
🗑️ Fichiers supprimés orphelins : ${data.trash_stale_count} fichier(s) — ${data.trash_stale_human} (référence DB disparue ou >30j)`; + if (data.trash_stale_files) { + html += ''; + } + html += '
'; + } if (data.filepond_active_count || data.trash_active_count) { html += '

Conservés : '; if (data.filepond_active_count) html += `${data.filepond_active_count} téléversement(s) actif(s), `; diff --git a/app/templates/admin/recapitulatif.php b/app/templates/admin/recapitulatif.php index 521ea48..dd9e650 100644 --- a/app/templates/admin/recapitulatif.php +++ b/app/templates/admin/recapitulatif.php @@ -266,7 +266,6 @@

Modifier - Ajouter un autre TFE Retour à la liste
diff --git a/justfile b/justfile index 761edad..059bcae 100644 --- a/justfile +++ b/justfile @@ -51,6 +51,9 @@ deploy: --exclude '.DS_Store' \ --exclude '.env' \ --exclude 'storage/xamxam.db' \ + --exclude 'storage/tfe/' \ + --exclude 'storage/these/' \ + --exclude 'storage/frart/' \ --exclude 'storage/theses' \ --exclude 'storage/covers' \ --exclude 'storage/backup_*' \ @@ -316,6 +319,16 @@ trigger-backup: # Manually trigger the backup script on the server now (doesn't wait for cron). ssh -t xamxam "sudo -u www-data /usr/local/bin/backup-sqlite.sh" +[group('deploy')] +deploy-migrate-storage dry_run='' target_host='xamxam': + # Run the storage path migration on the remote server. + # Usage: + # just deploy-migrate-storage # apply migration + # just deploy-migrate-storage --dry-run # dry-run only + rsync -v scripts/migrate-storage-paths.php {{target_host}}:/var/www/xamxam/migrate-storage-paths.php + ssh {{target_host}} 'cd /var/www/xamxam && php migrate-storage-paths.php {{dry_run}}' + ssh {{target_host}} 'rm -f /var/www/xamxam/migrate-storage-paths.php' + [group('deploy')] deploy-all-first: deploy deploy-backup # One-shot: full initial deploy including backup cron. diff --git a/scripts/migrate-storage-paths.php b/scripts/migrate-storage-paths.php new file mode 100755 index 0000000..3f163ce --- /dev/null +++ b/scripts/migrate-storage-paths.php @@ -0,0 +1,211 @@ +#!/usr/bin/env php +/dev/null + */ + +$dryRun = in_array('--dry-run', $argv, true); + +echo ($dryRun ? "[DRY RUN] " : "") . "Storage path migration: theses/ | documents/ → {objet}/YYYY/...\n"; +echo "─────────────────────────────────────────────────────────────\n\n"; + +// ── Auto-detect paths ────────────────────────────────────────────────────── +// Remote deploy: repo root = /var/www/xamxam, storage in storage/ +// Local dev: script in scripts/, storage in ../app/storage/ +$repoRoot = file_exists(__DIR__ . '/bootstrap.php') ? __DIR__ : dirname(__DIR__); + +// DB path: try xamxam.db first, fall back to database.sqlite +$dbPath = $repoRoot . '/storage/xamxam.db'; +if (!file_exists($dbPath)) { + $dbPath = $repoRoot . '/storage/database.sqlite'; +} +$storageRoot = $repoRoot . '/storage'; + +echo "Repo root: {$repoRoot}\n"; +echo "DB path: {$dbPath}\n"; +echo "Storage root: {$storageRoot}\n\n"; + +if (!file_exists($dbPath)) { + die("ERROR: Database not found at {$dbPath}\n"); +} + +// ── Connect to DB ─────────────────────────────────────────────────────────── +$pdo = new PDO('sqlite:' . $dbPath); +$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + +// Fetch all files that need migration +$sql = "SELECT tf.id, tf.thesis_id, tf.file_path, tf.file_name, t.objet, t.year + FROM thesis_files tf + JOIN theses t ON t.id = tf.thesis_id + WHERE (tf.file_path LIKE 'theses/%' OR tf.file_path LIKE 'documents/%') + AND t.objet IS NOT NULL + ORDER BY tf.id ASC"; + +$stmt = $pdo->query($sql); +$rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + +$total = count($rows); +echo "Found {$total} file(s) to migrate.\n\n"; + +if ($total === 0) { + echo "Nothing to do.\n"; + exit(0); +} + +$moved = 0; +$skipped = 0; +$errors = []; +$folderCache = []; + +foreach ($rows as $row) { + $fileId = (int)$row['id']; + $thesisId = (int)$row['thesis_id']; + $oldPath = $row['file_path']; + $objet = $row['objet']; + $year = $row['year']; + + // Parse old path: theses/2025/FOLDERNAME/file.pdf or documents/2025/FOLDERNAME/file.pdf + $parts = explode('/', $oldPath); + if (count($parts) < 4) { + $skipped++; + $errors[] = "SKIP file #{$fileId}: unexpected path format '{$oldPath}'"; + continue; + } + + $yearDir = $parts[1]; // '2025' + $folderName = $parts[2]; // '2025_SMITH_Titre' + $fileNameOnly = implode('/', array_slice($parts, 3)); // rest of path + + // Construct new path + $newPath = $objet . '/' . $yearDir . '/' . $folderName . '/' . $fileNameOnly; + + // Skip if paths are identical + if ($newPath === $oldPath) { + $skipped++; + echo " #{$fileId} SKIP: already correct '{$oldPath}'\n"; + continue; + } + + $oldAbs = $storageRoot . '/' . $oldPath; + $newAbs = $storageRoot . '/' . $newPath; + + // Ensure target directory exists + $newDir = dirname($newAbs); + if (!isset($folderCache[$newDir])) { + if (!is_dir($newDir)) { + if (!$dryRun) { + mkdir($newDir, 0755, true); + } + echo " MKDIR {$newDir}\n"; + } + $folderCache[$newDir] = true; + } + + // Check source exists + if (!file_exists($oldAbs)) { + // If the target already exists (moved by a previous duplicate row), just update DB + if (file_exists($newAbs)) { + echo " #{$fileId} {$oldPath}\n → {$newPath} (target exists, DB-only update)\n"; + if (!$dryRun) { + $pdo->prepare('UPDATE thesis_files SET file_path = ? WHERE id = ?') + ->execute([$newPath, $fileId]); + } + $moved++; + continue; + } + $skipped++; + echo " #{$fileId} SKIP: source not found '{$oldAbs}'\n"; + continue; + } + + // Check target doesn't exist + if (file_exists($newAbs)) { + $skipped++; + echo " #{$fileId} SKIP: target already exists '{$newAbs}'\n"; + continue; + } + + echo " #{$fileId} {$oldPath}\n → {$newPath}\n"; + + if (!$dryRun) { + if (!rename($oldAbs, $newAbs)) { + $skipped++; + $errors[] = "FAIL file #{$fileId}: rename failed '{$oldAbs}' → '{$newAbs}'"; + echo " ✗ rename failed\n"; + continue; + } + + $pdo->prepare('UPDATE thesis_files SET file_path = ? WHERE id = ?') + ->execute([$newPath, $fileId]); + } + + $moved++; +} + +echo "\n─────────────────────────────────────────────────────────────\n"; +echo "Summary: {$moved} moved, {$skipped} skipped, " . count($errors) . " errors\n"; + +if (!empty($errors)) { + echo "\nErrors:\n"; + foreach ($errors as $err) { + echo " {$err}\n"; + } +} + +// Clean up empty directories +if (!$dryRun) { + $oldRoots = [$storageRoot . '/theses', $storageRoot . '/documents']; + foreach ($oldRoots as $oldRoot) { + removeEmptyDirs($oldRoot); + } + echo "\nCleaned up empty directories.\n"; +} + +if ($dryRun) { + echo "\nRun without --dry-run to apply changes.\n"; +} + +exit(empty($errors) ? 0 : 1); + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function removeEmptyDirs(string $dir): void +{ + if (!is_dir($dir)) { + return; + } + $items = @scandir($dir); + if ($items === false) { + return; + } + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $path = $dir . '/' . $item; + if (is_dir($path)) { + removeEmptyDirs($path); + } + } + @rmdir($dir); +}