feat: fix file deletion on save + trash policy + documents/ prefix + relink browser

1. note_intention: Delete old file only when a genuinely new upload arrives
   (32-char hex file_id), not when the FilePond pool preserves an existing
   file by sending its DB integer ID.  Previously the DB integer ID
   triggered $hasNewNote=true, which deleted the existing note_intention
   from disk+DB, then handleFilePondSingleFile couldn't re-process it
   because the regex requires a hex pattern.  Same fix applied to cover.

2. All file deletions now use deleteThesisFileToTrash() which renames
   files to tmp/_trash/ instead of unlinking.  The trash preserves
   original filenames prefixed with DB id for traceability.  Skips
   website URLs and PeerTube refs (no disk file).

3. Storage prefix changed from theses/ to documents/ to reflect that
   the folder holds all document types (determined by file_type in DB).
   MediaController visibility gate supports both prefixes for backward
   compat with existing files.

4. File browser + relink feature for orphaned files:
   - /admin/fragments/file-browser.php — HTMX tree browser for
     storage/documents/ and storage/theses/
   - /admin/actions/filepond/relink.php — POST endpoint that inserts
     a thesis_files row pointing to existing on-disk file
   - Per-pool "📂 Relier" buttons (edit mode only)
   - JS: XamxamOpenFileBrowser / XamxamRelinkFile with FilePond integration
   - CSS: .relink-modal dialog + .file-browser tree styles
This commit is contained in:
Pontoporeia
2026-05-13 14:58:15 +02:00
parent 6f7a02244f
commit 79eddf5d5a
30 changed files with 191580 additions and 187 deletions

View File

@@ -6,7 +6,7 @@
* Shared file-upload logic used by ThesisCreateController and
* ThesisEditController. All on-disk files are stored under:
*
* theses/{YYYY}/{YYYY}_{AUTHORS}_{TITLE_SLUG}/
* documents/{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. "theses/2025/2025_SMITH_Mon_Titre/").
* @param string $folderPath Relative path from STORAGE_ROOT to the thesis folder (e.g. "documents/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,7 +802,7 @@ trait ThesisFileHandler
/**
* Build the folder path and file prefix for a thesis.
*
* Folder: theses/{YYYY}/{YYYY}_{AUTHORS}_{TITLE_SLUG}/
* Folder: documents/{YYYY}/{YYYY}_{AUTHORS}_{TITLE_SLUG}/
* Prefix: {YYYY}_{AUTHORS}_{TITLE_SLUG}
*
* @return array{folderPath: string, filePrefix: string, folderName: string}
@@ -814,7 +814,7 @@ trait ThesisFileHandler
$folderName = $year . '_' . $authorSlug . '_' . $titleSlug;
$filePrefix = $folderName;
$folderPath = 'theses/' . $year . '/' . $folderName . '/';
$folderPath = 'documents/' . $year . '/' . $folderName . '/';
return [
'folderPath' => $folderPath,
@@ -1203,4 +1203,55 @@ trait ThesisFileHandler
}
@rmdir($tmpDir);
}
/**
* Delete a thesis_files row, moving the on-disk file to the trash directory
* instead of unlinking it. Website URLs and PeerTube references are only
* removed from the database (no disk file).
*/
protected function deleteThesisFileToTrash(int $fileId, int $thesisId): void
{
if ($fileId <= 0) {
return;
}
// Fetch the file row to get the path before deleting the DB record
$existingFiles = $this->db->getThesisFiles($thesisId);
$fileRow = null;
foreach ($existingFiles as $f) {
if ((int)$f['id'] === $fileId) {
$fileRow = $f;
break;
}
}
$filePath = $this->db->deleteThesisFile($fileId, $thesisId);
if ($filePath && defined('STORAGE_ROOT')) {
// Skip filesystem for website URLs and PeerTube IDs (not real files)
if (str_starts_with($filePath, 'http://') || str_starts_with($filePath, 'https://')) {
return;
}
if (str_starts_with($filePath, 'peertube_ids:')) {
return;
}
$abs = STORAGE_ROOT . '/' . $filePath;
if (file_exists($abs)) {
$trashDir = STORAGE_ROOT . '/tmp/_trash';
if (!is_dir($trashDir)) {
mkdir($trashDir, 0755, true);
}
// Keep original filename structure for traceability
$trashName = $fileId . '_' . basename($filePath);
$trashPath = $trashDir . '/' . $trashName;
if (!rename($abs, $trashPath)) {
// Fallback to unlink if rename fails (cross-device)
@copy($abs, $trashPath);
@unlink($abs);
}
error_log("ThesisFileHandler: file \$fileId moved to trash → \$trashName");
}
}
}
}