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

@@ -61,7 +61,7 @@ class ShareLink
* @param string|null $expiresAt ISO-8601 expiration date, null = never expires
* @return array|null The created link row with _plain_password attached
*/
public function create(int $createdBy, ?string $expiresAt = null, ?string $objetRestriction = null, ?string $name = null): ?array
public function create(int $createdBy, ?string $expiresAt = null, ?string $objetRestriction = null, ?string $name = null, ?int $lockedYear = null): ?array
{
$slug = self::generateSlug();
$plainPassword = self::generatePassword();
@@ -74,11 +74,16 @@ class ShareLink
$objetRestriction = 'tfe';
}
// Validate locked_year: must be a plausible academic year (2000..current+3)
if ($lockedYear !== null && ($lockedYear < 2000 || $lockedYear > ((int)date('Y') + 3))) {
$lockedYear = null;
}
$stmt = $this->db->getConnection()->prepare(
'INSERT INTO share_links (slug, name, objet_restriction, password_hash, encrypted_password, is_active, created_by, expires_at)
VALUES (?, ?, ?, ?, ?, 1, ?, ?)'
'INSERT INTO share_links (slug, name, objet_restriction, password_hash, encrypted_password, is_active, created_by, expires_at, locked_year)
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)'
);
$stmt->execute([$slug, $name, $objetRestriction, $passwordHash, Crypto::encrypt($plainPassword), $createdBy, $expiresAt]);
$stmt->execute([$slug, $name, $objetRestriction, $passwordHash, Crypto::encrypt($plainPassword), $createdBy, $expiresAt, $lockedYear]);
$link = $this->findBySlug($slug);
if ($link) {
@@ -235,7 +240,15 @@ class ShareLink
/**
* Update a share link (name, expiration).
*/
public function update(int $id, ?string $name = null, ?string $expiresAt = null): void
/**
* Update a share link's name, expiration, and/or locked year.
*
* $lockedYear:
* - null → leave unchanged (not present in POST)
* - "" → clear (set to NULL in DB)
* - non-empty string → parse as int, validate, and set
*/
public function update(int $id, ?string $name = null, ?string $expiresAt = null, mixed $lockedYear = null): void
{
$pdo = $this->db->getConnection();
$fields = [];
@@ -250,6 +263,17 @@ class ShareLink
$fields[] = 'expires_at = ?';
$params[] = $expiresAtVal;
}
if ($lockedYear !== null) {
if ($lockedYear === '' || $lockedYear === false) {
$fields[] = 'locked_year = NULL';
} else {
$year = filter_var($lockedYear, FILTER_VALIDATE_INT);
if ($year !== false && $year >= 2000 && $year <= ((int)date('Y') + 3)) {
$fields[] = 'locked_year = ?';
$params[] = $year;
}
}
}
if (!empty($fields)) {
$params[] = $id;