diff --git a/TODO.md b/TODO.md
index 5e3b857..2ae6365 100644
--- a/TODO.md
+++ b/TODO.md
@@ -2,9 +2,24 @@
## Completed
+- [x] TDD analysis + new test suites
+ - [x] **Bug fixed**: `SearchController::handleSearch()` — `$coverMap` undefined variable + never populated for search results
+ - [x] `ShareLinkTest` (13 tests) — `generateSlug`, all `validateLink` branches, `verifyPassword`, `incrementUsage`, `objet_restriction`
+ - [x] `PureLogicTest` (31 tests) — `TfeController` helpers (meta, OG image, jury split, captions), `ThesisCreateController` helpers (autofocus, detectFileType, authorSlug), `ThesisEditController::buildFileSizeInfo`, `ExportController` CSV column consistency, `SearchController` coverMap regression
+ - [x] Private helpers promoted to `protected` in `TfeController`, `ThesisCreateController`, `ThesisEditController` to enable subclass-based testing without reflection
+
+- [x] Form save audit + TDD
+ - [x] `createThesis()` missing `duration_pages`/`duration_minutes` columns — fixed
+ - [x] `ThesisCreateController` not passing raw page/minute values to `createThesis()` — fixed (`durationPages`, `durationMinutes` extracted and passed)
+ - [x] `FormSaveTest.php` — 14 red-green tests covering create+edit round-trips for all fields
+
- [x] Language form improvements
- [x] Add Néerlandais as default language option (schema + migration 017)
- - [x] Make `language_autre` required only when no Langue du TFE checkbox is checked (JS in form.php, PHP server-side default)
+ - [x] `language_autre` conditionally required via HTMX fragment (replaced custom JS)
+ - [x] `language_autre` saved via `getOrCreateLanguage()` in both create and edit controllers
+ - [x] `formData['languages']` wired in edit.php so checkboxes are pre-checked
+ - [x] `duration_pages`/`duration_minutes` saved in `updateThesis()` and read back in `getThesisRawFields()`
+ - [x] `beforeunload-guard` applied to add and partage forms too
- [x] Merge banner images into cover images
- [x] Migration 016: copy `storage/banners/*` → `storage/covers/`, insert `thesis_files` cover records, clear `banner_path`, remove banners dir
diff --git a/app/public/admin/add.php b/app/public/admin/add.php
index 3981c81..bba0e84 100644
--- a/app/public/admin/add.php
+++ b/app/public/admin/add.php
@@ -52,7 +52,7 @@ function wasSelected($key, $value) {
$isAdmin = true;
$bodyClass = 'admin-body';
$extraCss = ['/assets/css/form.css'];
-$extraJs = ['/assets/js/sortable.min.js', '/assets/js/file-upload-queue.js'];
+$extraJs = ['/assets/js/sortable.min.js', '/assets/js/file-upload-queue.js', '/assets/js/beforeunload-guard.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/language-autre-fragment.php b/app/public/language-autre-fragment.php
new file mode 100644
index 0000000..e85ad85
--- /dev/null
+++ b/app/public/language-autre-fragment.php
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+ Si votre TFE contient une langue absente de la liste, précisez-la ici.
+
+
+
+
+
+
+
+ Si votre TFE contient une langue absente de la liste, précisez-la ici.
+
+
+
+
diff --git a/app/public/partage/index.php b/app/public/partage/index.php
index b448715..a6b0b8c 100644
--- a/app/public/partage/index.php
+++ b/app/public/partage/index.php
@@ -330,6 +330,7 @@ function renderShareLinkForm(string $slug, array $link): void
+
diff --git a/app/src/Controllers/SearchController.php b/app/src/Controllers/SearchController.php
index daf2b5c..28d8b2e 100644
--- a/app/src/Controllers/SearchController.php
+++ b/app/src/Controllers/SearchController.php
@@ -86,6 +86,7 @@ class SearchController
$years = [];
$orientations = [];
$apPrograms = [];
+ $coverMap = [];
try {
$results = $this->db->searchTheses(
@@ -98,6 +99,9 @@ class SearchController
$years = $this->db->getAvailableYears();
$orientations = $this->db->getAllOrientations();
$apPrograms = $this->db->getAllAPPrograms();
+ if (!empty($results)) {
+ $coverMap = $this->db->getCoverPathsForTheses(array_column($results, 'id'));
+ }
} catch (InvalidArgumentException $e) {
$validationError = $e->getMessage();
} catch (Exception $e) {
diff --git a/app/src/Controllers/TfeController.php b/app/src/Controllers/TfeController.php
index 95256f9..1dd8743 100644
--- a/app/src/Controllers/TfeController.php
+++ b/app/src/Controllers/TfeController.php
@@ -141,7 +141,7 @@ class TfeController
/**
* Build a ~160-character meta description from the thesis synopsis.
*/
- private function buildMetaDescription(string $synopsis): string
+ protected function buildMetaDescription(string $synopsis): string
{
$plain = strip_tags($synopsis);
@@ -159,7 +159,7 @@ class TfeController
*
* @param array> $files
*/
- private function resolveOgImage(array $files): string
+ protected function resolveOgImage(array $files): string
{
// Prefer the dedicated cover
foreach ($files as $file) {
@@ -185,7 +185,7 @@ class TfeController
* @param array $data
* @return array
*/
- private function buildOgTags(array $data, int $thesisId, string $metaDescription): array
+ protected function buildOgTags(array $data, int $thesisId, string $metaDescription): array
{
$ogImage = $this->resolveOgImage($data['files'] ?? []);
$title = $data['title'] . (!empty($data['authors']) ? ' – ' . $data['authors'] : '');
@@ -210,7 +210,7 @@ class TfeController
* @param array> $jury
* @return array{presidents: list, internes: list, externes: list, ulb: list, lecteurs_internes: list, lecteurs_externes: list}
*/
- private function splitJuryByRole(array $jury): array
+ protected function splitJuryByRole(array $jury): array
{
$result = ['presidents' => [], 'internes' => [], 'externes' => [], 'ulb' => [], 'lecteurs_internes' => [], 'lecteurs_externes' => []];
@@ -253,7 +253,7 @@ class TfeController
* @param array> $files
* @return list
*/
- private function collectCaptionPaths(array $files): array
+ protected function collectCaptionPaths(array $files): array
{
$captions = [];
diff --git a/app/src/Controllers/ThesisCreateController.php b/app/src/Controllers/ThesisCreateController.php
index 52fb860..d969ba9 100644
--- a/app/src/Controllers/ThesisCreateController.php
+++ b/app/src/Controllers/ThesisCreateController.php
@@ -183,6 +183,8 @@ class ThesisCreateController
'synopsis' => $data['synopsis'],
'context_note' => $data['contextNote'],
'file_size_info' => $data['durationInfo'],
+ 'duration_pages' => $data['durationPages'],
+ 'duration_minutes'=> $data['durationMinutes'],
'baiu_link' => $data['lien'],
'license_id' => $data['licenseId'],
'license_custom' => $data['licenseCustom'],
@@ -338,14 +340,15 @@ class ThesisCreateController
$subtitle = $this->sanitiseString($post['subtitle'] ?? '');
$synopsis = $this->validateRequired($this->sanitiseString($post['synopsis'] ?? ''), 'Synopsis');
- $durationInfo = $this->sanitiseString($post['duration_pages'] ?? '');
+ $durationPages = $this->sanitiseString($post['duration_pages'] ?? '');
$durationMinutes = $this->sanitiseString($post['duration_minutes'] ?? '');
- if ($durationInfo !== '' && $durationMinutes !== '') {
- $durationInfo = $durationInfo . ' pages + ' . $durationMinutes . ' minutes';
+ $durationInfo = '';
+ if ($durationPages !== '' && $durationMinutes !== '') {
+ $durationInfo = $durationPages . ' pages + ' . $durationMinutes . ' minutes';
} elseif ($durationMinutes !== '') {
$durationInfo = $durationMinutes . ' minutes';
- } elseif ($durationInfo !== '') {
- $durationInfo = $durationInfo . ' pages';
+ } elseif ($durationPages !== '') {
+ $durationInfo = $durationPages . ' pages';
}
if (!empty($post['has_annexes'])) {
$durationInfo = $durationInfo ? $durationInfo . ' + annexe(s)' : 'Annexe(s)';
@@ -430,6 +433,14 @@ class ThesisCreateController
$languageIds = isset($post['languages']) && is_array($post['languages'])
? array_map('intval', $post['languages'])
: [];
+ $autreRaw = trim($post['language_autre'] ?? '');
+ if ($autreRaw !== '') {
+ foreach (array_map('trim', explode(',', $autreRaw)) as $langName) {
+ if ($langName !== '') {
+ $languageIds[] = $this->db->getOrCreateLanguage($langName);
+ }
+ }
+ }
if (empty($languageIds)) {
throw new Exception('Veuillez sélectionner au moins une langue.');
}
@@ -508,6 +519,8 @@ class ThesisCreateController
'subtitle',
'synopsis',
'durationInfo',
+ 'durationPages',
+ 'durationMinutes',
'juryMembers',
'keywords',
'languageIds',
@@ -676,7 +689,7 @@ class ThesisCreateController
/**
* Determine the logical file_type from MIME type, extension, and original filename.
*/
- private function detectFileType(string $mimeType, string $ext, string $originalName): string
+ protected function detectFileType(string $mimeType, string $ext, string $originalName): string
{
if ($ext === 'vtt' || $mimeType === 'text/vtt') {
return 'caption';
@@ -725,7 +738,7 @@ class ThesisCreateController
* Generate a filesystem-safe author slug from the author name.
* Converts to uppercase, replaces spaces with underscores, removes accents.
*/
- private function generateAuthorSlug(string $authorName): string
+ protected function generateAuthorSlug(string $authorName): string
{
// Remove accents using iconv if available, otherwise simple mapping
$normalized = $authorName;
@@ -764,7 +777,7 @@ class ThesisCreateController
* Sanitize a filename: remove accents, replace spaces with underscore, remove special chars.
* Keeps extension.
*/
- private function sanitizeFilename(string $filename): string
+ protected function sanitizeFilename(string $filename): string
{
$ext = pathinfo($filename, PATHINFO_EXTENSION);
$name = pathinfo($filename, PATHINFO_FILENAME);
@@ -800,7 +813,7 @@ class ThesisCreateController
* Find a unique folder name inside theses/{year}/.
* Pattern: {year}_{authorSlug} or {year}_{authorSlug}_{suffix} if exists.
*/
- private function ensureUniqueFolder(int $year, string $authorSlug): string
+ protected function ensureUniqueFolder(int $year, string $authorSlug): string
{
$baseDir = STORAGE_ROOT . '/theses/' . $year . '/';
if (!is_dir($baseDir)) {
@@ -822,7 +835,7 @@ class ThesisCreateController
* The URL is stored in file_path; no filesystem operation is performed.
* label and sort_order from the POST are preserved.
*/
- private function handleWebsiteUrl(int $thesisId, array $post): void
+ protected function handleWebsiteUrl(int $thesisId, array $post): void
{
$websiteUrl = trim($post['website_url'] ?? '');
if ($websiteUrl === '') {
diff --git a/app/src/Controllers/ThesisEditController.php b/app/src/Controllers/ThesisEditController.php
index 83f503c..3044cdb 100644
--- a/app/src/Controllers/ThesisEditController.php
+++ b/app/src/Controllers/ThesisEditController.php
@@ -256,6 +256,8 @@ class ThesisEditController
'synopsis' => trim($post['synopsis'] ?? ''),
'context_note' => trim($post['context_note'] ?? ''),
'file_size_info' => $this->buildFileSizeInfo($post),
+ 'duration_pages' => trim($post['duration_pages'] ?? ''),
+ 'duration_minutes'=> trim($post['duration_minutes'] ?? ''),
'baiu_link' => trim($post['lien'] ?? ''),
'license_id' => filter_var($post['license_id'] ?? '', FILTER_VALIDATE_INT) ?: null,
'access_type_id' => filter_var($post['access_type_id'] ?? '', FILTER_VALIDATE_INT) ?: null,
@@ -291,12 +293,18 @@ class ThesisEditController
$this->db->setThesisJury($thesisId, $juryMembers);
// ── 4. Languages ──────────────────────────────────────────────────
- $this->db->setThesisLanguages(
- $thesisId,
- isset($post['languages']) && is_array($post['languages'])
- ? $post['languages']
- : []
- );
+ $langIds = isset($post['languages']) && is_array($post['languages'])
+ ? $post['languages']
+ : [];
+ $autreRaw = trim($post['language_autre'] ?? '');
+ if ($autreRaw !== '') {
+ foreach (array_map('trim', explode(',', $autreRaw)) as $langName) {
+ if ($langName !== '') {
+ $langIds[] = (string)$this->db->getOrCreateLanguage($langName);
+ }
+ }
+ }
+ $this->db->setThesisLanguages($thesisId, $langIds);
// ── 5. Formats ────────────────────────────────────────────────────
$this->db->setThesisFormats(
@@ -710,7 +718,7 @@ class ThesisEditController
/**
* Build file_size_info from separate duration fields.
*/
- private function buildFileSizeInfo(array $post): string
+ protected function buildFileSizeInfo(array $post): string
{
$pages = trim($post['duration_pages'] ?? '');
$minutes = trim($post['duration_minutes'] ?? '');
diff --git a/app/src/Database.php b/app/src/Database.php
index f44a99d..e6d64a9 100644
--- a/app/src/Database.php
+++ b/app/src/Database.php
@@ -1477,6 +1477,23 @@ class Database
}
}
+ /**
+ * Return the ID of an existing language by name, inserting it if absent.
+ * Name is trimmed and stored as-is (case-preserved).
+ */
+ public function getOrCreateLanguage(string $name): int
+ {
+ $name = trim($name);
+ $stmt = $this->pdo->prepare('SELECT id FROM languages WHERE LOWER(name) = LOWER(?) LIMIT 1');
+ $stmt->execute([$name]);
+ $id = $stmt->fetchColumn();
+ if ($id !== false) {
+ return (int)$id;
+ }
+ $this->pdo->prepare('INSERT INTO languages (name) VALUES (?)')->execute([$name]);
+ return (int)$this->pdo->lastInsertId();
+ }
+
/**
* Replace all format associations for a thesis.
* @param int $thesisId
@@ -1581,7 +1598,7 @@ class Database
public function getThesisRawFields(int $thesisId): ?array
{
$stmt = $this->pdo->prepare(
- 'SELECT license_id, license_custom, access_type_id, context_note, remarks, jury_points, exemplaire_baiu, exemplaire_erg, cc4r FROM theses WHERE id = ? LIMIT 1'
+ 'SELECT license_id, license_custom, access_type_id, context_note, remarks, jury_points, exemplaire_baiu, exemplaire_erg, cc4r, duration_pages, duration_minutes FROM theses WHERE id = ? LIMIT 1'
);
$stmt->execute([$thesisId]);
$row = $stmt->fetch();
@@ -1697,6 +1714,8 @@ class Database
synopsis = ?,
context_note = ?,
file_size_info = ?,
+ duration_pages = ?,
+ duration_minutes = ?,
baiu_link = ?,
license_id = ?,
license_custom = ?,
@@ -1720,6 +1739,8 @@ class Database
$data['synopsis'],
!empty($data['context_note']) ? $data['context_note'] : null,
!empty($data['file_size_info']) ? $data['file_size_info'] : null,
+ isset($data['duration_pages']) && $data['duration_pages'] !== '' ? (int)$data['duration_pages'] : null,
+ isset($data['duration_minutes']) && $data['duration_minutes'] !== '' ? (int)$data['duration_minutes'] : null,
!empty($data['baiu_link']) ? $data['baiu_link'] : null,
$data['license_id'] ?? null,
!empty($data['license_custom']) ? $data['license_custom'] : null,
@@ -1765,6 +1786,7 @@ class Database
identifier, title, subtitle, year,
orientation_id, ap_program_id, finality_id,
synopsis, context_note, file_size_info,
+ duration_pages, duration_minutes,
baiu_link, license_id, license_custom,
access_type_id,
objet,
@@ -1773,7 +1795,7 @@ class Database
exemplaire_baiu, exemplaire_erg,
cc4r,
submitted_at
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
');
$validObjet = ['tfe', 'thèse', 'frart'];
@@ -1790,6 +1812,8 @@ class Database
$data['synopsis'],
!empty($data['context_note']) ? $data['context_note'] : null,
!empty($data['file_size_info']) ? $data['file_size_info'] : null,
+ isset($data['duration_pages']) && $data['duration_pages'] !== '' ? (int)$data['duration_pages'] : null,
+ isset($data['duration_minutes']) && $data['duration_minutes'] !== '' ? (int)$data['duration_minutes'] : null,
!empty($data['baiu_link']) ? $data['baiu_link'] : null,
$data['license_id'] ?? null,
!empty($data['license_custom']) ? $data['license_custom'] : null,
diff --git a/app/templates/admin/edit.php b/app/templates/admin/edit.php
index 733b3ca..4df2a58 100644
--- a/app/templates/admin/edit.php
+++ b/app/templates/admin/edit.php
@@ -98,6 +98,9 @@
}
}
+ // Languages — either from flash repopulation or current thesis data
+ $formData['languages'] = $formData['languages'] ?? $currentLanguages ?? [];
+
// Formats — either from flash repopulation or current thesis data
$checkedFormats = $formData['formats'] ?? $currentFormats ?? [];
// Populate formData.formats for checkbox-list partial
diff --git a/app/templates/partials/form/checkbox-list.php b/app/templates/partials/form/checkbox-list.php
index eeaabb4..554b69d 100644
--- a/app/templates/partials/form/checkbox-list.php
+++ b/app/templates/partials/form/checkbox-list.php
@@ -15,12 +15,14 @@
* bool $required — whether at least one checkbox must be checked; default false
* string $hxPost — optional hx-post URL for HTMX live update
* string $hxTarget — optional hx-target CSS selector for HTMX swap
+ * string $hxSwap — optional hx-swap value; default 'outerHTML'
*/
$checked = $checked ?? [];
$required = $required ?? false;
$hxPost = $hxPost ?? '';
$hxTarget = $hxTarget ?? '';
+$hxSwap = $hxSwap ?? 'outerHTML';
?>
= htmlspecialchars($label) ?>= $required ? ' *' : '' ?>
@@ -31,7 +33,7 @@ $hxTarget = $hxTarget ?? '';
hx-target="= htmlspecialchars($hxTarget) ?>"
hx-trigger="change"
hx-include="this, #website-url-fieldset"
- hx-swap="outerHTML"
+ hx-swap="= htmlspecialchars($hxSwap) ?>"
>
@@ -50,4 +52,4 @@ $hxTarget = $hxTarget ?? '';
-