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

@@ -0,0 +1,8 @@
<?php
/**
* Partagé fragment: generic pill-search (HTMX partial).
*/
require_once __DIR__ . '/../../../bootstrap.php';
App::boot();
require_once APP_ROOT . '/public/partage/pill-search-fragment.php';

View File

@@ -79,6 +79,12 @@ if ($slug === 'validate-file-fragment' && $_SERVER['REQUEST_METHOD'] === 'POST')
exit;
}
if ($slug === 'pill-search-fragment' && $_SERVER['REQUEST_METHOD'] === 'POST') {
App::boot();
require_once __DIR__ . '/fragments/pill-search.php';
exit;
}
// Special route: /partage/recapitulatif?id=N
if ($slug === 'recapitulatif' || $slug === 'recapitulatif.php') {
App::boot();
@@ -434,6 +440,7 @@ function renderShareLinkForm(string $slug, array $link): void
<script src="<?= App::assetV('/assets/js/app/file-upload-filepond.js') ?>" defer></script>
<script src="<?= App::assetV('/assets/js/app/beforeunload-guard.js') ?>" defer></script>
<script src="<?= App::assetV('/assets/js/app/pill-search.js') ?>" defer></script>
<script src="<?= App::assetV('/assets/js/app/jury-autocomplete.js') ?>" defer></script>
<script src="<?= App::assetV('/assets/js/vendor/htmx.min.js') ?>" defer></script>
</head>
<body class="student-body">
@@ -612,7 +619,7 @@ function handleShareLinkSubmission(string $slug): void
header('Location: /partage/' . urlencode($slug));
exit();
} catch (Exception $e) {
} catch (\Throwable $e) {
$logger->logError('partage', $e->getMessage(), [
'share_slug' => $slug,
'author' => $authorName,

View File

@@ -1,72 +1,10 @@
<?php
/**
* language-search-fragment.php
*
* Shared HTMX fragment: returns matching language suggestions for the
* "Autre(s) langue(s)" interactive search input.
* @deprecated Use /partage/fragments/pill-search.php with type=language instead.
* Kept for backward compatibility with existing hx-post URLs.
*/
require_once __DIR__ . '/../../src/Database.php';
$query = trim(preg_replace('/\s+/', ' ', strtolower($_POST['language_search_q'] ?? '')));
$currentLanguages = isset($_POST['language_autre']) && is_array($_POST['language_autre'])
? array_map(function($l) { return trim(preg_replace('/\s+/', ' ', strtolower($l))); }, $_POST['language_autre'])
: [];
error_log("[lang-search] q=" . var_export($query, true) . " cur=" . json_encode($currentLanguages));
$db = Database::getInstance();
$results = $db->searchLanguages($query);
error_log("[lang-search] raw results count=" . count($results) . " rows=" . json_encode(array_slice($results, 0, 5)));
// Deduplicate
$seen = [];
$results = array_values(array_filter($results, function($lang) use (&$seen) {
$key = strtolower($lang['name']);
if (isset($seen[$key])) return false;
$seen[$key] = true;
return true;
}));
// Exclude the main languages (handled by the checkbox list above)
$excludedLanguages = ['français', 'anglais', 'néerlandais'];
// Filter out already-selected and excluded main languages
$results = array_values(array_filter($results, function($lang) use ($currentLanguages, $excludedLanguages) {
$lower = strtolower($lang['name']);
return !in_array($lower, $currentLanguages, true)
&& !in_array($lower, $excludedLanguages, true);
}));
// Exact match check
$exactExists = false;
foreach ($results as $lang) {
if (strcasecmp($lang['name'], $query) === 0) {
$exactExists = true;
break;
}
}
$inCurrent = in_array($query, $currentLanguages, true);
$isExcluded = in_array($query, $excludedLanguages, true);
$canCreate = ($query !== '' && !$exactExists && !$inCurrent && !$isExcluded);
error_log("[lang-search] exactExists=" . var_export($exactExists, true) . " inCurrent=" . var_export($inCurrent, true) . " canCreate=" . var_export($canCreate, true));
?>
<?php if (empty($results) && !$canCreate): ?>
<div class="tag-search-empty">Aucune langue trouvée.</div>
<?php endif; ?>
<?php foreach ($results as $lang): ?>
<button type="button" class="tag-search-item" data-tag-id="<?= (int)$lang['id'] ?>" data-tag-name="<?= htmlspecialchars($lang['name']) ?>">
<span class="tag-search-item-name"><?= htmlspecialchars($lang['name']) ?></span>
<span class="tag-search-item-count">(<?= (int)$lang['thesis_count'] ?>)</span>
</button>
<?php endforeach; ?>
<?php if ($canCreate): ?>
<button type="button" class="tag-search-item tag-search-item--create" data-tag-name="<?= htmlspecialchars($query) ?>">
<span class="tag-search-item-name">Créer «&nbsp;<?= htmlspecialchars($query) ?>&nbsp;»</span>
</button>
<?php endif; ?>
$_POST['type'] = 'language';
$_POST['q'] = $_POST['language_search_q'] ?? '';
$_POST['exclude'] = $_POST['language_autre'] ?? [];
require_once __DIR__ . '/pill-search-fragment.php';

View File

@@ -0,0 +1,123 @@
<?php
/**
* pill-search-fragment.php
*
* Generic HTMX fragment: returns matching suggestions for search inputs.
*
* Supports three entity types:
* - tag → used by mot-clé pill-search component
* - language → used by "Autre(s) langue(s)" pill-search component
* - supervisor → used by jury member text inputs (inline autocomplete, no pills)
*
* Included by:
* - /admin/fragments/pill-search.php (AdminAuth gated)
* - /partage/fragments/pill-search.php (public, session already booted)
*
* Expected POST:
* type — 'tag' | 'language' | 'supervisor'
* q — search query string (partial name)
* exclude[] — already-selected names to exclude from suggestions
*/
require_once __DIR__ . '/../../src/Database.php';
$db = Database::getInstance();
$type = trim($_POST['pill_type'] ?? $_POST['type'] ?? 'tag');
// Accept both generic 'q' and type-specific field names (tag_search_q, language_search_q)
// Use the type-specific field first to avoid collision when both are present (same <form>).
$rawQ = $_POST['q']
?? ($type === 'language' ? ($_POST['language_search_q'] ?? '') : ($_POST['tag_search_q'] ?? ''))
?? '';
$q = trim(preg_replace('/\s+/', ' ', strtolower((string)$rawQ)));
// Accept both generic 'exclude[]' and type-specific field names (tag[], language_autre[])
$exclude = [];
$rawExclude = $_POST['exclude']
?? ($type === 'language' ? ($_POST['language_autre'] ?? null) : ($_POST['tag'] ?? null))
?? null;
if (isset($rawExclude) && is_array($rawExclude)) {
$exclude = array_map(function($v) {
return trim(preg_replace('/\s+/', ' ', strtolower($v)));
}, $rawExclude);
}
$results = [];
$emptyMessage = 'Aucun résultat.';
$createLabel = 'Créer';
switch ($type) {
case 'language':
$results = $db->searchLanguages($q);
$emptyMessage = 'Aucune langue trouvée.';
$createLabel = 'Créer';
break;
case 'supervisor':
$role = trim($_POST['role'] ?? '');
$results = $db->searchSupervisors($q, $role);
$emptyMessage = 'Aucun·e membre trouvé·e.';
break;
default: // tag
$results = $db->searchTags($q);
$emptyMessage = 'Aucun mot-clé trouvé.';
$createLabel = 'Créer';
break;
}
// Deduplicate results by lowercase name
$seen = [];
$results = array_values(array_filter($results, function($item) use (&$seen) {
$key = strtolower($item['name']);
if (isset($seen[$key])) return false;
$seen[$key] = true;
return true;
}));
// Exclude already-selected items and, for languages, the main three
if ($type === 'language') {
$excludedMain = ['français', 'anglais', 'néerlandais'];
$results = array_values(array_filter($results, function($lang) use ($excludedMain, $exclude) {
$lower = strtolower($lang['name']);
return !in_array($lower, $excludedMain, true)
&& !in_array($lower, $exclude, true);
}));
} elseif (!empty($exclude)) {
$results = array_values(array_filter($results, function($item) use ($exclude) {
return !in_array(strtolower($item['name']), $exclude, true);
}));
}
// "Créer" button logic
$exactExists = false;
foreach ($results as $item) {
if (strcasecmp($item['name'], $q) === 0) {
$exactExists = true;
break;
}
}
$inExcluded = in_array($q, $exclude, true);
$canCreate = ($q !== '' && !$exactExists && !$inExcluded);
if ($type === 'language') {
$excludedMain = ['français', 'anglais', 'néerlandais'];
if (in_array($q, $excludedMain, true)) {
$canCreate = false;
}
}
?>
<?php if (empty($results) && !$canCreate): ?>
<div class="tag-search-empty"><?= htmlspecialchars($emptyMessage) ?></div>
<?php endif; ?>
<?php foreach ($results as $item): ?>
<button type="button" class="tag-search-item" data-tag-name="<?= htmlspecialchars($item['name']) ?>">
<span class="tag-search-item-name"><?= htmlspecialchars($item['name']) ?></span>
<span class="tag-search-item-count">(<?= (int)$item['thesis_count'] ?>)</span>
</button>
<?php endforeach; ?>
<?php if ($canCreate): ?>
<button type="button" class="tag-search-item tag-search-item--create" data-tag-name="<?= htmlspecialchars($q) ?>">
<span class="tag-search-item-name"><?= htmlspecialchars($createLabel) ?> «&nbsp;<?= htmlspecialchars($q) ?>&nbsp;»</span>
</button>
<?php endif; ?>

View File

@@ -1,6 +1,10 @@
<?php
/**
* Partagé fragment: tag search (HTMX partial).
* @deprecated Use /partage/fragments/tag-search.php instead.
* tag-search-fragment.php
* @deprecated Use /partage/fragments/pill-search.php with type=tag instead.
* Kept for backward compatibility with existing hx-post URLs.
*/
require_once __DIR__ . '/fragments/tag-search.php';
$_POST['type'] = 'tag';
$_POST['q'] = $_POST['tag_search_q'] ?? '';
$_POST['exclude'] = $_POST['tag'] ?? [];
require_once __DIR__ . '/pill-search-fragment.php';