mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 08:09:18 +02:00
cleanup modal: list stale files to remove; storage restructure: documents/ → {objet}/
This commit is contained in:
5
TODO.md
5
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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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é.');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
/**
|
||||
* File browser fragment — returns a clickable directory tree of documents/ + theses/.
|
||||
* File browser fragment — returns a clickable directory tree of tfe/ these/ frart/ documents/ + theses/.
|
||||
*
|
||||
* GET /admin/fragments/file-browser.php?dir=documents/2025
|
||||
*
|
||||
@@ -17,7 +17,7 @@ error_log('[file-browser] ENTRY | dir=' . ($_GET['dir'] ?? '(root)') . ' | stora
|
||||
|
||||
// Determine which directory to browse
|
||||
$relDir = trim($_GET['dir'] ?? '', '/');
|
||||
if ($relDir !== '' && !preg_match('#^(documents|theses)(/|$)#', $relDir)) {
|
||||
if ($relDir !== '' && !preg_match('#^(tfe|these|frart|documents|theses)(/|$)#', $relDir)) {
|
||||
$relDir = '';
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ if ($relDir !== '') {
|
||||
$parentDir = implode('/', $parentParts);
|
||||
}
|
||||
|
||||
$rootDirs = ['documents', 'theses'];
|
||||
$rootDirs = ['tfe', 'these', 'frart', 'documents', 'theses'];
|
||||
|
||||
// SVG icon for a given extension
|
||||
function fileIcon(string $ext): string {
|
||||
|
||||
@@ -50,8 +50,8 @@ class MediaController
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3. Visibility gate for thesis files (both legacy theses/ and new documents/ paths)
|
||||
if (preg_match('#^(theses|documents)/#', $requestedPath)) {
|
||||
// 3. Visibility gate for thesis files (legacy theses/ and documents/, new tfe/these/frart/ paths)
|
||||
if (preg_match('#^(theses|documents|tfe|these|frart)/#', $requestedPath)) {
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
require_once APP_ROOT . '/src/ErrorHandler.php';
|
||||
try {
|
||||
|
||||
@@ -188,10 +188,11 @@ class ThesisCreateController
|
||||
}
|
||||
|
||||
// ── 5. File uploads (outside transaction — filesystem ops) ────────────
|
||||
$tf = $this->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'])) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 772 KiB After Width: | Height: | Size: 772 KiB |
@@ -292,8 +292,24 @@ async function fetchTmpStats() {
|
||||
}
|
||||
} else {
|
||||
html = `<p style="margin:0 0 var(--space-xs) 0;font-weight:600">⚠️ ${totalStale} élément(s) obsolète(s) à nettoyer :</p>`;
|
||||
if (data.filepond_stale_count) html += `<p style="margin:0 0 var(--space-xs) 0">📁 <strong>Téléversements abandonnés</strong> : ${data.filepond_stale_count} dossier(s) — ${data.filepond_stale_human} <span style="font-size:0.85em;color:var(--text-secondary)">(session expirée ou >2h)</span></p>`;
|
||||
if (data.trash_stale_count) html += `<p style="margin:0 0 var(--space-xs) 0">🗑️ <strong>Fichiers supprimés orphelins</strong> : ${data.trash_stale_count} fichier(s) — ${data.trash_stale_human} <span style="font-size:0.85em;color:var(--text-secondary)">(référence DB disparue ou >30j)</span></p>`;
|
||||
if (data.filepond_stale_count) {
|
||||
html += `<details style="margin:0 0 var(--space-xs) 0;font-size:0.9em" open><summary>📁 <strong>Téléversements abandonnés</strong> : ${data.filepond_stale_count} dossier(s) — ${data.filepond_stale_human} <span style="font-size:0.85em;color:var(--text-secondary)">(session expirée ou >2h)</span></summary>`;
|
||||
if (data.filepond_stale_files) {
|
||||
html += '<ul style="margin:var(--space-xs) 0 0 var(--space-md);max-height:10em;overflow-y:auto">';
|
||||
data.filepond_stale_files.forEach(f => { html += `<li>${f.name} <span style="color:var(--text-secondary)">(${f.human}, ~${Math.round(f.age_minutes)}min)</span></li>`; });
|
||||
html += '</ul>';
|
||||
}
|
||||
html += '</details>';
|
||||
}
|
||||
if (data.trash_stale_count) {
|
||||
html += `<details style="margin:0 0 var(--space-xs) 0;font-size:0.9em" open><summary>🗑️ <strong>Fichiers supprimés orphelins</strong> : ${data.trash_stale_count} fichier(s) — ${data.trash_stale_human} <span style="font-size:0.85em;color:var(--text-secondary)">(référence DB disparue ou >30j)</span></summary>`;
|
||||
if (data.trash_stale_files) {
|
||||
html += '<ul style="margin:var(--space-xs) 0 0 var(--space-md);max-height:10em;overflow-y:auto">';
|
||||
data.trash_stale_files.forEach(f => { html += `<li>${f.name} <span style="color:var(--text-secondary)">(${f.human}, ~${Math.round(f.age_days)}j)</span></li>`; });
|
||||
html += '</ul>';
|
||||
}
|
||||
html += '</details>';
|
||||
}
|
||||
if (data.filepond_active_count || data.trash_active_count) {
|
||||
html += '<p style="margin:var(--space-xs) 0 0 0;font-size:0.85em;color:var(--text-secondary)">Conservés : ';
|
||||
if (data.filepond_active_count) html += `${data.filepond_active_count} téléversement(s) actif(s), `;
|
||||
|
||||
@@ -266,7 +266,6 @@
|
||||
|
||||
<div class="admin-action-bar">
|
||||
<a href="/admin/edit.php?id=<?= $thesisId ?>" class="btn btn--primary">Modifier</a>
|
||||
<a href="/admin/add.php" class="btn btn--secondary">Ajouter un autre TFE</a>
|
||||
<a href="/admin/" class="btn btn--secondary">Retour à la liste</a>
|
||||
</div>
|
||||
|
||||
|
||||
13
justfile
13
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.
|
||||
|
||||
211
scripts/migrate-storage-paths.php
Executable file
211
scripts/migrate-storage-paths.php
Executable file
@@ -0,0 +1,211 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* Migration script: move files from theses/ and documents/ to {objet}/YYYY/...
|
||||
* and update file_path in the thesis_files table.
|
||||
*
|
||||
* Self-contained — no framework bootstrap needed.
|
||||
*
|
||||
* Usage:
|
||||
* php scripts/migrate-storage-paths.php [--dry-run]
|
||||
*
|
||||
* What it does:
|
||||
* 1. Reads all thesis_files rows where file_path starts with theses/ or documents/
|
||||
* 2. For each row, looks up the parent thesis's objet field
|
||||
* 3. Constructs the new path: {objet}/YYYY/FOLDERNAME/filename
|
||||
* 4. Creates the target directory if needed
|
||||
* 5. Renames the file on disk
|
||||
* 6. Updates file_path in the DB
|
||||
* 7. Cleans up empty directories under theses/ and documents/
|
||||
*
|
||||
* Dry-run mode prints what would be done without making changes.
|
||||
*
|
||||
* Note: Orphaned files on disk (no DB row) are NOT moved — use:
|
||||
* find storage/theses storage/documents -type f 2>/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);
|
||||
}
|
||||
Reference in New Issue
Block a user