diff --git a/TODO.md b/TODO.md
index 7fa3005..a76ca97 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1,7 +1,32 @@
# Current tasks
+## Save fixes (files disappearing on edit/terminer)
+- [x] Fix: note_intention deleted on save — handleFilePondSingleFile treats existing DB id as new upload, deletes existing, then can't re-process (integer vs hex mismatch)
+- [x] Fix: cover removal now uses trash, same hex-vs-integer guard as note_intention
+- [x] Fix: all file deletions now route through deleteThesisFileToTrash (renames to tmp/_trash instead of unlinking)
+
+## Storage restructure
+- [x] Move storage root from theses/ to documents/ (ThesisFileHandler, ThesisEditController, ThesisCreateController, MediaController)
+- [x] MediaController: support both theses/ and documents/ prefixes for visibility gate
+- [ ] Migration: rename existing theses/ directories to documents/ on disk and update DB paths
+
+## Relink feature
+- [x] Backend: endpoint to browse documents/ directory (file-browser.php with HTMX tree)
+- [x] Backend: endpoint to relink an existing file to a thesis (relink.php inserts thesis_files row)
+- [x] Frontend: modal with folder browser, triggered by a "Relier" button next to each FilePond pool
+- [x] JS: integrate relink button into FilePond UI (XamxamOpenFileBrowser + XamxamRelinkFile)
+- [x] CSS: .relink-modal + .file-browser styles in form.css
+
+## Trash policy
+- [x] FilePond remove moves to tmp/_trash (already implemented in handleRemove)
+
- [x] Fix: partage FilePond asks admin password — shared handler + separate partage endpoints with share_active session gate
-- [ ] Deploy: just deploy (includes new partage/actions/filepond/ + FilepondHandler.php)
+- [x] Fix: mots-clé HTMX search — restored tag-search-fragment.php logic lost during fragment architecture refactor
+- [x] Generalize pill-search: single fragment endpoint (type=tag|language|supervisor), deduplicate tag & language backends, add jury autocomplete (promoteur·ice interne/externe ULB, lecteur·ice interne/externe)
+- [x] Deploy: just deploy (includes new partage/actions/filepond/ + FilepondHandler.php)
+- [x] Fix: language pill-search showing mots-clé results — form field name collision; replaced hidden inputs with scoped hx-vals; fixed exclude logic per type
+- [x] Add Créer button to jury supervisor autocomplete (removed guard in pill-search-fragment.php)
+- [x] Fix: UNIQUE constraint on authors.email — findOrCreateAuthor now checks for existing author by email before inserting; prevents crash when two authors share an email
# Current tasks
@@ -44,7 +69,7 @@
- [x] `admin/parametres.php`: always-visible accessibility table (Normal vs Maintenance)
- [x] `admin.css`: `.param-access-table` styles (border-radius via overflow:hidden, green/secondary colours)
- [x] `partage/index.php`: fix fragment routing — `$slug` was `'fragments'` but check used `str_starts_with($slug, 'fragments/')`, causing HTMX fragments to redirect to / (main page)
-- [ ] Deploy: `just deploy` + `just deploy-nginx`
+- [x] Deploy: `just deploy` + `just deploy-nginx`
## Previous items
@@ -62,3 +87,38 @@
- [x] Add `script-src 'self' 'unsafe-inline'` to main CSP header (public pages use inline scripts + onclick handlers)
- [x] Add `storage/tmp/filepond/*` to .gitignore + rsync exclude, with .gitkeep
- [ ] Deploy: `just deploy` to sync vendor JS files + updated CSP + .gitkeep to server
+
+# improvements_postlaunch — Année verrouillable dans partage + correction ID
+
+## Implémentation
+
+### 1. Schema: ajouter locked_year aux share_links
+- [ ] `Database::runMigrations()`: ALTER TABLE share_links ADD COLUMN locked_year INTEGER
+- [ ] `app/storage/schema.sql`: ajouter la colonne
+
+### 2. ShareLink model: lire/écrire locked_year
+- [ ] `ShareLink::create()`: accepter et stocker locked_year
+- [ ] `ShareLink::update()`: accepter et stocker locked_year
+- [ ] `findBySlug()` retourne déjà SELECT *, donc locked_year remonte automatiquement
+
+### 3. Admin UI — Dialog de création de lien
+- [ ] Ajouter champ "Année académique verrouillée" dans create-dialog (acces.php)
+- [ ] Ajouter champ dans edit-dialog (acces.php)
+
+### 4. Admin UI — Liste des liens
+- [ ] Afficher colonne "Année" dans le tableau des liens (acces.php)
+
+### 5. Admin actions (acces-etudiante.php)
+- [ ] Lire locked_year depuis $_POST dans action 'create' et 'update'
+- [ ] Passer au ShareLink model
+
+### 6. Partage — Formulaire
+- [ ] `partage/index.php` (`renderShareLinkForm`): lire locked_year depuis le lien
+- [ ] `fieldset-academic.php`: quand $lockedYear est défini → hidden input + span "Année académique verrouillée : YYYY" + explication; quand null → comportement actuel
+- [ ] `ThesisCreateController::validateAndSanitise()`: respecter locked_year si présent dans POST (priorité sur $_POST['année'])
+
+### 7. Admin edit.php — Forcer l'identifiant
+- [ ] Ajouter un champ "Identifiant" en lecture seule mais avec un bouton "Regénérer"
+- [ ] `ThesisEditController`: ajouter méthode `regenerateIdentifier()` qui reconstruit YYYY-NNN avec MAX+1 sur la nouvelle année
+- [ ] `Database`: méthode `regenerateThesisIdentifier(int $thesisId, int $year)` — met à jour identifier basé sur l'année dans un SELECT FOR UPDATE
+- [ ] Attention: renommer les dossiers de fichiers sur disque si l'identifiant change
diff --git a/app/public/admin/actions/acces-etudiante.php b/app/public/admin/actions/acces-etudiante.php
index 76a35b0..fcd9aa3 100644
--- a/app/public/admin/actions/acces-etudiante.php
+++ b/app/public/admin/actions/acces-etudiante.php
@@ -38,7 +38,15 @@ switch ($action) {
$validObjet = ['tfe', 'thèse', 'frart'];
$selected = is_array($objetRaw) ? array_intersect($objetRaw, $validObjet) : [];
$objetRestriction = !empty($selected) ? implode(',', $selected) : 'tfe';
- $link = $shareLink->create(1, $expiresAt, $objetRestriction, $name);
+ $lockedYearRaw = $_POST['locked_year'] ?? null;
+ $lockedYear = null;
+ if ($lockedYearRaw !== null && $lockedYearRaw !== '') {
+ $lockedYear = filter_var($lockedYearRaw, FILTER_VALIDATE_INT);
+ if ($lockedYear === false || $lockedYear < 2000 || $lockedYear > ((int)date('Y') + 3)) {
+ $lockedYear = null;
+ }
+ }
+ $link = $shareLink->create(1, $expiresAt, $objetRestriction, $name, $lockedYear);
$logger->logLinkCreate(
$link['slug'] ?? '',
true, // Always has password
@@ -86,7 +94,13 @@ switch ($action) {
if ($id > 0) {
$name = isset($_POST['name']) ? trim($_POST['name']) : null;
$expiresRaw = isset($_POST['expires_at']) ? trim($_POST['expires_at']) : null;
- $shareLink->update($id, $name, $expiresRaw);
+ // locked_year: null=not sent (keep), ""=clear, otherwise year string
+ $lockedYearRaw = $_POST['locked_year'] ?? null;
+ $lockedYear = null; // default: not sent → don't change
+ if ($lockedYearRaw !== null) {
+ $lockedYear = $lockedYearRaw; // pass through: "" for clear, "2026" for set
+ }
+ $shareLink->update($id, $name, $expiresRaw, $lockedYear);
App::redirect('/admin/acces.php', success: 'Lien mis à jour.');
} else {
App::redirect('/admin/acces.php', error: 'Lien introuvable.');
diff --git a/app/public/admin/actions/filepond/relink.php b/app/public/admin/actions/filepond/relink.php
new file mode 100644
index 0000000..a33cf22
--- /dev/null
+++ b/app/public/admin/actions/filepond/relink.php
@@ -0,0 +1,120 @@
+file($realPath);
+}
+
+// Map queue_type to file_type if not explicitly given
+if ($fileType === '') {
+ $fileTypeMap = [
+ 'cover' => 'cover',
+ 'note_intention' => 'note_intention',
+ 'tfe' => 'main',
+ 'annexe' => 'annex',
+ ];
+ $fileType = $fileTypeMap[$queueType] ?? 'main';
+}
+
+require_once APP_ROOT . '/src/Database.php';
+$db = Database::getInstance();
+
+// Check if this file is already linked to this thesis (avoid duplicate)
+$pdo = $db->getConnection();
+$stmt = $pdo->prepare('SELECT id FROM thesis_files WHERE thesis_id = ? AND file_path = ?');
+$stmt->execute([$thesisId, $filePath]);
+if ($stmt->fetch()) {
+ http_response_code(409);
+ die('Ce fichier est déjà lié à ce TFE.');
+}
+
+$db->insertThesisFile(
+ $thesisId,
+ $fileType,
+ $filePath,
+ $fileName,
+ $fileSize,
+ $mimeType,
+ null,
+ null
+);
+
+// Get the new file's ID
+$newId = $pdo->lastInsertId();
+
+error_log("[relink] thesis_id=$thesisId file_path=$filePath file_type=$fileType new_id=$newId");
+
+header('Content-Type: application/json');
+echo json_encode([
+ 'ok' => true,
+ 'id' => (int)$newId,
+ 'message' => 'Fichier relié avec succès.',
+]);
+exit;
diff --git a/app/public/admin/add.php b/app/public/admin/add.php
index f83f029..ef9e80a 100644
--- a/app/public/admin/add.php
+++ b/app/public/admin/add.php
@@ -55,7 +55,7 @@ function wasSelected($key, $value) {
$isAdmin = true;
$bodyClass = 'admin-body';
$extraCss = ['/assets/css/form.css', '/assets/css/filepond.min.css', '/assets/css/filepond-plugin-image-preview.min.css'];
-$extraJs = ['/assets/js/vendor/filepond.min.js', '/assets/js/vendor/filepond-plugin-file-validate-type.min.js', '/assets/js/vendor/filepond-plugin-file-validate-size.min.js', '/assets/js/vendor/filepond-plugin-image-preview.min.js', '/assets/js/vendor/filepond-plugin-image-exif-orientation.min.js', '/assets/js/app/file-upload-filepond.js', '/assets/js/app/beforeunload-guard.js', '/assets/js/app/pill-search.js'];
+$extraJs = ['/assets/js/vendor/filepond.min.js', '/assets/js/vendor/filepond-plugin-file-validate-type.min.js', '/assets/js/vendor/filepond-plugin-file-validate-size.min.js', '/assets/js/vendor/filepond-plugin-image-preview.min.js', '/assets/js/vendor/filepond-plugin-image-exif-orientation.min.js', '/assets/js/app/file-upload-filepond.js', '/assets/js/app/beforeunload-guard.js', '/assets/js/app/pill-search.js', '/assets/js/app/jury-autocomplete.js'];
require_once APP_ROOT . '/templates/head.php';
include APP_ROOT . '/templates/header.php';
include APP_ROOT . '/templates/admin/add.php';
diff --git a/app/public/admin/edit.php b/app/public/admin/edit.php
index 19b9d6e..8e38f4c 100644
--- a/app/public/admin/edit.php
+++ b/app/public/admin/edit.php
@@ -40,7 +40,7 @@ try {
$isAdmin = true; $bodyClass = 'admin-body';
$extraCss = ['/assets/css/form.css', '/assets/css/filepond.min.css', '/assets/css/filepond-plugin-image-preview.min.css'];
-$extraJs = ['/assets/js/vendor/filepond.min.js', '/assets/js/vendor/filepond-plugin-file-validate-type.min.js', '/assets/js/vendor/filepond-plugin-file-validate-size.min.js', '/assets/js/vendor/filepond-plugin-image-preview.min.js', '/assets/js/vendor/filepond-plugin-image-exif-orientation.min.js', '/assets/js/app/file-upload-filepond.js', '/assets/js/app/beforeunload-guard.js', '/assets/js/app/pill-search.js'];
+$extraJs = ['/assets/js/vendor/filepond.min.js', '/assets/js/vendor/filepond-plugin-file-validate-type.min.js', '/assets/js/vendor/filepond-plugin-file-validate-size.min.js', '/assets/js/vendor/filepond-plugin-image-preview.min.js', '/assets/js/vendor/filepond-plugin-image-exif-orientation.min.js', '/assets/js/app/file-upload-filepond.js', '/assets/js/app/beforeunload-guard.js', '/assets/js/app/pill-search.js', '/assets/js/app/jury-autocomplete.js'];
require_once APP_ROOT . '/templates/head.php';
include APP_ROOT . '/templates/header.php';
include APP_ROOT . '/templates/admin/edit.php';
diff --git a/app/public/admin/fragments/file-browser.php b/app/public/admin/fragments/file-browser.php
new file mode 100644
index 0000000..be6d003
--- /dev/null
+++ b/app/public/admin/fragments/file-browser.php
@@ -0,0 +1,146 @@
+ $p, 'dir' => $accum];
+ }
+}
+
+// Gather entries
+$entries = [];
+if (is_dir($realCurrent)) {
+ $dh = opendir($realCurrent);
+ if ($dh) {
+ while (($name = readdir($dh)) !== false) {
+ if ($name === '.' || $name === '..') continue;
+ $full = $realCurrent . '/' . $name;
+ $isDir = is_dir($full);
+ $entries[] = [
+ 'name' => $name,
+ 'is_dir' => $isDir,
+ 'size' => $isDir ? null : filesize($full),
+ 'ext' => $isDir ? null : strtolower(pathinfo($name, PATHINFO_EXTENSION)),
+ ];
+ }
+ closedir($dh);
+ }
+}
+
+// Sort: dirs first, then files, alphabetical
+usort($entries, function ($a, $b) {
+ if ($a['is_dir'] !== $b['is_dir']) return $a['is_dir'] ? -1 : 1;
+ return strnatcasecmp($a['name'], $b['name']);
+});
+
+// Human-readable filesize
+function fmtSize(?int $bytes): string {
+ if ($bytes === null) return '';
+ if ($bytes >= 1_000_000_000) return round($bytes / 1_000_000_000, 1) . ' GB';
+ if ($bytes >= 1_000_000) return round($bytes / 1_000_000, 1) . ' MB';
+ if ($bytes >= 1_000) return round($bytes / 1_000, 1) . ' KB';
+ return $bytes . ' B';
+}
+
+// Determine parent dir
+$parentDir = '';
+if ($relDir !== '') {
+ $parentParts = explode('/', $relDir);
+ array_pop($parentParts);
+ $parentDir = implode('/', $parentParts);
+}
+
+$rootDirs = ['documents', 'theses'];
+?>
+
diff --git a/app/public/admin/fragments/pill-search.php b/app/public/admin/fragments/pill-search.php
new file mode 100644
index 0000000..5f334c4
--- /dev/null
+++ b/app/public/admin/fragments/pill-search.php
@@ -0,0 +1,10 @@
+ {
+ var queueType = btn.dataset.queueType;
+ var thesisId = btn.dataset.thesisId;
+
+ // Store context for the relink callback
+ window.__xamxamRelinkCtx = {
+ queueType: queueType,
+ thesisId: thesisId,
+ };
+
+ var modal = document.getElementById('relink-modal');
+ if (!modal) {
+ console.error('[relink] modal #relink-modal not found');
+ return;
+ }
+
+ var body = document.getElementById('relink-modal-body');
+ body.innerHTML = 'Chargement…
';
+
+ modal.showModal();
+
+ // Load the file browser via HTMX (or fetch if htmx not available)
+ if (window.htmx) {
+ window.htmx.ajax('GET', '/admin/fragments/file-browser.php', {
+ target: '#relink-modal-body',
+ swap: 'innerHTML',
+ });
+ } else {
+ fetch('/admin/fragments/file-browser.php')
+ .then(r => r.text())
+ .then(html => { body.innerHTML = html; })
+ .catch(() => { body.innerHTML = 'Erreur de chargement.
'; });
+ }
+ };
+
+ /**
+ * Relink a selected file to the thesis.
+ * Triggered when a file is clicked in the file browser.
+ */
+ window.XamxamRelinkFile = (el) => {
+ var li = el.closest('.file-browser-entry');
+ if (!li) return;
+
+ var ctx = window.__xamxamRelinkCtx || {};
+ var thesisId = ctx.thesisId;
+ var queueType = ctx.queueType;
+
+ var filePath = li.dataset.filePath;
+ var fileName = li.dataset.fileName;
+ var fileSize = parseInt(li.dataset.fileSize, 10) || 0;
+ var ext = li.dataset.fileExt || '';
+
+ if (!filePath || !thesisId || !queueType) {
+ console.error('[relink] missing data', { filePath, thesisId, queueType });
+ return;
+ }
+
+ // Determine MIME from extension
+ var mimeMap = {
+ pdf: 'application/pdf',
+ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', gif: 'image/gif',
+ mp4: 'video/mp4', webm: 'video/webm', ogv: 'video/ogg', mov: 'video/quicktime',
+ mp3: 'audio/mpeg', ogg: 'audio/ogg', wav: 'audio/wav', flac: 'audio/flac', aac: 'audio/aac', m4a: 'audio/mp4',
+ vtt: 'text/vtt',
+ zip: 'application/zip', tar: 'application/x-tar', gz: 'application/gzip',
+ };
+ var mimeType = mimeMap[ext] || 'application/octet-stream';
+
+ var csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
+
+ var bodyEl = document.getElementById('relink-modal-body');
+ if (bodyEl) bodyEl.innerHTML = 'Reliage en cours…
';
+
+ fetch('/admin/actions/filepond/relink.php', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-Token': csrfToken,
+ },
+ body: JSON.stringify({
+ thesis_id: parseInt(thesisId, 10),
+ file_path: filePath,
+ file_name: fileName,
+ file_size: fileSize,
+ mime_type: mimeType,
+ queue_type: queueType,
+ }),
+ })
+ .then(r => r.json().then(data => ({ ok: r.ok, status: r.status, data })))
+ .then(({ ok, status, data }) => {
+ if (!ok) {
+ if (bodyEl) bodyEl.innerHTML = `Erreur : ${data}
`;
+ return;
+ }
+ console.log('[relink] success | new_id=' + data.id);
+
+ // Add the new file to the FilePond pool
+ var input = document.querySelector(`.tfe-file-picker[data-queue-type="${queueType}"]`);
+ if (input) {
+ var pond = FilePond.find(input);
+ if (pond) {
+ pond.addFile({
+ source: String(data.id),
+ options: { type: 'local' },
+ });
+ }
+ }
+
+ // Close modal
+ var modal = document.getElementById('relink-modal');
+ if (modal) modal.close();
+
+ // Mark form dirty
+ window.__xamxamDirty = true;
+ })
+ .catch(err => {
+ console.error('[relink] fetch error', err);
+ if (bodyEl) bodyEl.innerHTML = 'Erreur réseau.
';
+ });
+ };
})();
diff --git a/app/public/assets/js/app/jury-autocomplete.js b/app/public/assets/js/app/jury-autocomplete.js
new file mode 100644
index 0000000..0b78271
--- /dev/null
+++ b/app/public/assets/js/app/jury-autocomplete.js
@@ -0,0 +1,140 @@
+/**
+ * jury-autocomplete.js — inlines autocomplete for jury member text inputs.
+ *
+ * Each jury sub-fieldset (