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

@@ -47,6 +47,12 @@ class Database
} catch (\PDOException $e) {
// Column already exists — ignore
}
// Add 'locked_year' column to share_links if missing
try {
$this->pdo->exec("ALTER TABLE share_links ADD COLUMN locked_year INTEGER");
} catch (\PDOException $e) {
// Column already exists — ignore
}
}
/**
@@ -1013,19 +1019,39 @@ class Database
$email = null;
}
$stmt = $this->pdo->prepare('SELECT id FROM authors WHERE name = ?');
$cleanEmail = ($email !== null && $email !== '') ? $email : null;
// Try to find by name first
$stmt = $this->pdo->prepare('SELECT id, email FROM authors WHERE name = ?');
$stmt->execute([$name]);
$author = $stmt->fetch();
if ($author) {
// Always update email (may be null to clear) and show_contact.
// Update email and show_contact unless that email belongs to another author.
if ($cleanEmail !== null) {
$dup = $this->pdo->prepare('SELECT id FROM authors WHERE email = ? AND id != ?');
$dup->execute([$cleanEmail, $author['id']]);
if ($dup->fetch()) {
$cleanEmail = null; // don't steal another author's email
}
}
$updateStmt = $this->pdo->prepare('UPDATE authors SET email = ?, show_contact = ? WHERE id = ?');
$updateStmt->execute([$email && $email !== '' ? $email : null, $showContact ? 1 : 0, $author['id']]);
$updateStmt->execute([$cleanEmail, $showContact ? 1 : 0, $author['id']]);
return $author['id'];
}
// If an author with this email already exists (different name), reuse that record.
if ($cleanEmail !== null) {
$byEmail = $this->pdo->prepare('SELECT id FROM authors WHERE email = ?');
$byEmail->execute([$cleanEmail]);
$existing = $byEmail->fetch();
if ($existing) {
return $existing['id'];
}
}
$stmt = $this->pdo->prepare('INSERT INTO authors (name, email, show_contact) VALUES (?, ?, ?)');
$stmt->execute([$name, $email, $showContact ? 1 : 0]);
$stmt->execute([$name, $cleanEmail, $showContact ? 1 : 0]);
return $this->pdo->lastInsertId();
}
@@ -1194,6 +1220,67 @@ class Database
// TAG MANAGEMENT (admin)
// ========================================================================
/**
* Search supervisors by name prefix. Returns up to 10 matching supervisors.
* If $query is empty, returns the most-used ones (up to 10).
*
* @param string $role Optional role filter: 'promoteur_interne' (is_external=0, role=promoteur),
* 'promoteur_externe' (is_external=1, role=promoteur),
* 'lecteur_interne' (is_external=0, role=lecteur),
* 'lecteur_externe' (is_external=1, role=lecteur)
*/
public function searchSupervisors(string $query = '', string $role = ''): array
{
$query = trim($query);
// Map role to WHERE conditions
$roleWhere = '';
$roleParams = [];
switch ($role) {
case 'promoteur_interne':
$roleWhere = 'AND ts.is_external = 0 AND ts.role = \'promoteur\'';
break;
case 'promoteur_externe':
$roleWhere = 'AND ts.is_external = 1 AND ts.role = \'promoteur\'';
break;
case 'lecteur_interne':
$roleWhere = 'AND ts.is_external = 0 AND ts.role = \'lecteur\'';
break;
case 'lecteur_externe':
$roleWhere = 'AND ts.is_external = 1 AND ts.role = \'lecteur\'';
break;
default:
break;
}
if ($query === '') {
$stmt = $this->pdo->query('
SELECT s.id, s.name,
COUNT(DISTINCT ts.thesis_id) as thesis_count
FROM supervisors s
LEFT JOIN thesis_supervisors ts ON s.id = ts.supervisor_id
' . $roleWhere . '
GROUP BY s.id
ORDER BY thesis_count DESC, s.name COLLATE NOCASE
LIMIT 10
');
} else {
$stmt = $this->pdo->prepare('
SELECT s.id, s.name,
COUNT(DISTINCT ts.thesis_id) as thesis_count
FROM supervisors s
LEFT JOIN thesis_supervisors ts ON s.id = ts.supervisor_id
WHERE s.name LIKE ?
' . $roleWhere . '
GROUP BY s.id
ORDER BY s.name = ? DESC, thesis_count DESC, s.name COLLATE NOCASE
LIMIT 10
');
$stmt->execute([$query . '%', $query]);
}
return $stmt->fetchAll();
}
/**
* Search tags by name prefix. Returns up to 10 matching tags.
* If $query is empty, returns the most-used tags (up to 10).
@@ -1794,13 +1881,11 @@ class Database
}
// 2. Accent-tolerant fallback: strip accents and re-compare.
// iconv 'ASCII//TRANSLIT' turns é→e, ç→c, etc.
$asciiName = @iconv('UTF-8', 'ASCII//TRANSLIT', $name);
if ($asciiName !== false && $asciiName !== $name) {
$asciiName = self::stripAccents($name);
if ($asciiName !== $name) {
$all = $this->pdo->query('SELECT id, name FROM languages WHERE deleted_at IS NULL')->fetchAll();
foreach ($all as $row) {
$rowAscii = @iconv('UTF-8', 'ASCII//TRANSLIT', strtolower($row['name']));
if ($rowAscii !== false && $rowAscii === $asciiName) {
if (self::stripAccents(strtolower($row['name'])) === $asciiName) {
return (int)$row['id'];
}
}
@@ -1952,7 +2037,7 @@ class Database
}
/**
* Check visibility for a file path under theses/.
* Check visibility for a file path under documents/ or theses/.
* Returns the access_type_id of the owning thesis, or null if the file
* is not found or the path does not belong to a thesis file.
*
@@ -3071,4 +3156,50 @@ class Database
return $result ?: null;
}
/**
* Strip accents from a UTF-8 string (é→e, ç→c, etc.).
* Pure PHP fallback when iconv extension is not available.
*/
private static function stripAccents(string $str): string
{
if (function_exists('iconv')) {
$result = @iconv('UTF-8', 'ASCII//TRANSLIT', $str);
if ($result !== false) {
return strtolower($result);
}
}
// Manual transliteration table for common accented chars
static $map = null;
if ($map === null) {
$utf8 = [
'À','Á','Â','Ã','Ä','Å','Æ','à','á','â','ã','ä','å','æ',
'Ç','ç',
'È','É','Ê','Ë','è','é','ê','ë',
'Ì','Í','Î','Ï','ì','í','î','ï',
'Ð','ð',
'Ñ','ñ',
'Ò','Ó','Ô','Õ','Ö','Ø','ò','ó','ô','õ','ö','ø',
'Ù','Ú','Û','Ü','ù','ú','û','ü',
'Ý','ý','ÿ',
'Š','š','Ž','ž','Þ','þ','Œ','œ','ß',
];
$ascii = [
'A','A','A','A','A','A','AE','a','a','a','a','a','a','ae',
'C','c',
'E','E','E','E','e','e','e','e',
'I','I','I','I','i','i','i','i',
'D','d',
'N','n',
'O','O','O','O','O','O','o','o','o','o','o','o',
'U','U','U','U','u','u','u','u',
'Y','y','y',
'S','s','Z','z','TH','th','OE','oe','ss',
];
$map = array_combine($utf8, $ascii);
}
return strtolower(strtr($str, $map));
}
}