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-19 00:08:06 +02:00
parent 6f7a02244f
commit 79eddf5d5a
30 changed files with 191580 additions and 187 deletions
+21 -47
View File
@@ -322,11 +322,11 @@ class ThesisEditController
$existingFiles = $this->db->getThesisFiles($thesisId);
foreach ($existingFiles as $f) {
$fp = $f['file_path'] ?? '';
if (str_starts_with($fp, 'theses/')) {
if (str_starts_with($fp, 'documents/') || str_starts_with($fp, 'theses/')) {
$parts = explode('/', $fp);
if (count($parts) >= 3) {
$folderName = $parts[2];
$folderPath = 'theses/' . $year . '/' . $folderName . '/';
$folderPath = 'documents/' . $year . '/' . $folderName . '/';
$filePrefix = $folderName;
break;
}
@@ -341,17 +341,15 @@ class ThesisEditController
// ── Cover image (outside transaction — filesystem op) ─────────────────
if (!empty($post['filepond_mode'])) {
// Async path: cover file_id arrives in post, not $_FILES
if (!empty($post['remove_cover'])) {
// Delete old cover only if a genuinely new cover was uploaded (hex file_id).
// Existing cover preserved in FilePond sends its DB integer ID — skip.
$coverIdRaw = ($post['queue_file']['cover'] ?? null);
$coverId = is_array($coverIdRaw) ? ($coverIdRaw[0] ?? null) : $coverIdRaw;
$isNewCover = $coverId !== null && $coverId !== '' && preg_match('/^[a-f0-9]{32}$/', (string)$coverId);
if ($isNewCover) {
foreach ($existingFiles as $f) {
if ($f['file_type'] === 'cover') {
$this->db->deleteThesisFile((int)$f['id'], $thesisId);
if (!empty($f['file_path']) && defined('STORAGE_ROOT')) {
$abs = STORAGE_ROOT . '/' . $f['file_path'];
if (file_exists($abs)) {
@unlink($abs);
}
}
$this->deleteThesisFileToTrash((int)$f['id'], $thesisId);
break;
}
}
@@ -360,13 +358,7 @@ class ThesisEditController
} elseif (!empty($post['remove_cover'])) {
foreach ($existingFiles as $f) {
if ($f['file_type'] === 'cover') {
$this->db->deleteThesisFile((int)$f['id'], $thesisId);
if (!empty($f['file_path']) && defined('STORAGE_ROOT')) {
$abs = STORAGE_ROOT . '/' . $f['file_path'];
if (file_exists($abs)) {
@unlink($abs);
}
}
$this->deleteThesisFileToTrash((int)$f['id'], $thesisId);
break;
}
}
@@ -376,19 +368,16 @@ class ThesisEditController
// ── Note d'intention (replace if uploaded) ────────────────────────────
if (!empty($post['filepond_mode'])) {
// Remove old note_intention if new one is uploaded via async path
$newNoteId = ($post['queue_file']['note_intention'] ?? null);
$hasNewNote = $newNoteId !== null && (is_array($newNoteId) ? !empty($newNoteId) : $newNoteId !== '');
if ($hasNewNote) {
// Only delete + replace if a genuinely new file was uploaded (hex file_id).
// Existing files preserved in the FilePond pool send their DB integer ID;
// we must NOT delete them — they're already stored.
$noteIdRaw = ($post['queue_file']['note_intention'] ?? null);
$noteId = is_array($noteIdRaw) ? ($noteIdRaw[0] ?? null) : $noteIdRaw;
$isNewNote = $noteId !== null && $noteId !== '' && preg_match('/^[a-f0-9]{32}$/', (string)$noteId);
if ($isNewNote) {
foreach ($existingFiles as $f) {
if ($f['file_type'] === 'note_intention') {
$this->db->deleteThesisFile((int)$f['id'], $thesisId);
if (!empty($f['file_path']) && defined('STORAGE_ROOT')) {
$abs = STORAGE_ROOT . '/' . $f['file_path'];
if (file_exists($abs)) {
@unlink($abs);
}
}
$this->deleteThesisFileToTrash((int)$f['id'], $thesisId);
break;
}
}
@@ -399,13 +388,7 @@ class ThesisEditController
if (!empty($files['note_intention']['tmp_name'] ?? null) && ($files['note_intention']['error'] ?? -1) === UPLOAD_ERR_OK) {
foreach ($existingFiles as $f) {
if ($f['file_type'] === 'note_intention') {
$this->db->deleteThesisFile((int)$f['id'], $thesisId);
if (!empty($f['file_path']) && defined('STORAGE_ROOT')) {
$abs = STORAGE_ROOT . '/' . $f['file_path'];
if (file_exists($abs)) {
@unlink($abs);
}
}
$this->deleteThesisFileToTrash((int)$f['id'], $thesisId);
break;
}
}
@@ -421,16 +404,7 @@ class ThesisEditController
if ($fileId <= 0) {
continue;
}
$filePath = $this->db->deleteThesisFile($fileId, $thesisId);
if ($filePath && defined('STORAGE_ROOT')) {
// Skip filesystem deletion for website URLs (not real files)
if (!str_starts_with($filePath, 'http://') && !str_starts_with($filePath, 'https://')) {
$abs = STORAGE_ROOT . '/' . $filePath;
if (file_exists($abs)) {
@unlink($abs);
}
}
}
$this->deleteThesisFileToTrash($fileId, $thesisId);
}
// ── Reorder existing files ────────────────────────────────────────────
@@ -681,7 +655,7 @@ class ThesisEditController
{
$websiteUrl = trim($post['website_url'] ?? '');
// Remove existing website rows
// Remove existing website rows (website URLs have no disk file)
$existingFiles = $this->db->getThesisFiles($thesisId);
foreach ($existingFiles as $f) {
if ($f['file_type'] === 'website') {