ThesisEditController::save() previously only regenerated the identifier when
the year field changed during an edit. If a thesis had its year corrected in
a past edit (or via other means) and the identifier still carried the old
year prefix, subsequent edits that didn't touch the year field would leave
the mismatched identifier in place.
Now saves() also checks whether the existing identifier's 4-digit prefix
matches the thesis year, and regenerates if not — regardless of whether year
changed in the current edit.
The migration runner (run.php) only scanned for .sql files, so PHP migrations
(013, 016, 018, 038) were never auto-applied. Extended the runner to also
discover and execute .php migrations in a subprocess. If a PHP migration fails
with an idempotent error (no such column, already exists, duplicate column),
the runner treats it as already-applied and continues rather than aborting
— preventing a stale migration like 016 (banner_path already dropped by 028)
from blocking migrations that come after it alphabetically (e.g. 038).
Updated migrations 016 and 038 to accept an optional $argv[1] DB path.
Fixed 016 to gracefully handle the banner_path column already being gone
(exit 0 instead of fatal).
Root cause: SQLite uses BINARY collation, so WHERE name = ? is
case-sensitive. When changing 'john doe' to 'John Doe', the name
lookup failed and fell through to the email path which didn't update
the name. The previous fix only added UPDATE in the name-match branch.
Fixes in findOrCreateAuthor:
1. Accept optional $idHint parameter — when known (edit flow), update
directly by ID (fastest, zero ambiguity)
2. Add COLLATE NOCASE to the name lookup (fallback path)
3. Add UPDATE in the email fallback path too
setThesisAuthors now fetches existing author_ids before deletion and
passes them as position-based hints, so identity is always preserved.
- Fix#1: Add is_published to getThesisRawFields() SELECT so the publish
checkbox stays checked when editing an already-published TFE.
- Fix#2: Rename 'Note contextuelle' → 'Note contextuelle relative à
soutenance' in all templates and StudentEmail.
- Fix#3: Update findOrCreateAuthor to also UPDATE the author name when
a record is found by name (fixes inability to capitalise names).
- Fix #4/#5: Decouple contact_interne (private author email) from
contact_visible (public contact on TFE page). Add migration 037 to
add contact_visible TEXT column to theses table and rebuild
v_theses_full view. Update all controllers, templates, and DB methods
to treat them independently.
- Fix#6: Investigated libre→interne restriction — no code barrier
found; likely resolved by is_published fix.
- Identifiant: mise à jour automatique quand l'année change en back-office (updateThesis + ThesisEditController)
- Contact: hint enrichi (1 seul contact, formatage Instagram/Mastodon)
- Fichiers: TFE rendu optionnel pour Site web/Performance/Installation (note d'intention reste obligatoire)
- Ajout de PeerTubeService::deleteVideo() qui appelle DELETE /api/v1/videos/{uuid}
- deleteThesisFileToTrash() appelle maintenant deleteVideo() pour les fichiers peertube_ids:
- hardDeleteThesis() supprime aussi les vidéos PeerTube associées
- Contacts: on peut laisser vide le nom OU le rôle (plus besoin des deux)
- Sidebar: les liens « site de l'erg » et « code source » sont éditables depuis /admin/contenus-edit.php?slug=about
- Admin: les champs Nom/Email/Lien des contacts s'affichent en grille 3 colonnes
- Admin: icône corbeille (admin-icon-btn--delete) pour supprimer un contact, avec réindexation automatique
- Database::getAproposContent() gère maintenant les valeurs string (URLs) en plus des arrays
- Database::saveAproposContent() accepte array|string
- Add csv_import queue type (storeAsFile, no async upload) for CSV import dialog
- Convert file-field.php partial to FilePond with field-name→queue-type mapping
- Conditionally skip server config for storeAsFile queues in buildFilePondOptions
- Skip FilePond init for inputs inside closed <dialog> elements
- Trigger FilePond init when import dialog opens
- Load FilePond CSS/JS assets on admin index page
- account.php: replace !== CSRF token check with hash_equals
- ShareLink::setPassword(): also encrypt and store plain-text password
alongside the hash, matching create() behavior so the decrypted_password
decoration stays correct after password updates
- Remove 'Mots-clés' button from toolbar (redundant with admin sidebar tags)
- Replace export dialog with 'Exporter CSV' + 'Exporter fichiers' buttons in bulk selection bar
- Export dispatcher now accepts ?ids=1,2,3 for per-selection export
- All ExportController/Database methods accept optional thesisIds array
- Graceful error message when ZipArchive extension is missing on server
- Move DB export (SQLite download) to paramètres → Maintenance section
- Sticky table column headers (position: sticky, top: 0, z-index: 5) for index page table
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
Extract shared filepond logic into src/FilepondHandler.php class.
Admin filepond endpoints delegate to the handler after AdminAuth check.
New partage filepond endpoints at /partage/actions/filepond/ verify
share_active session flag + CSRF token, no admin auth required.
JS reads filepond-base meta tag to determine endpoint path:
- Admin pages: /admin/actions/filepond (via head.php isAdmin check)
- Partage form: /partage/actions/filepond (explicit meta)
partage/index.php sets share_active = true on form render, cleans up on
successful submit. Partage process endpoint rate-limited to 30/5min per
session. No nginx changes needed — /partage/ location already handles
PHP without auth_basic.
- Created templates/partials/form/_licence.php (shared HTML, no auth logic)
- Created templates/partials/form/_format-website.php (shared HTML, no auth logic)
- Created src/FragmentRenderer.php helper for clean fragment rendering
- Created public/{admin,partage}/fragments/ subdirectories
- Created thin fragment endpoint files: auth guard + data fetch + render template
- Updated all hx-post references in templates to new fragments/ paths
- Updated partage/index.php routing for new fragments subdirectory
- Kept old fragment files as thin delegates for backward compat
- Updated nginx config: added PHP handler in /partage/ location block
- Remove separate video/audio/peertube_video/peertube_audio pools from UI
- TFE pool now accepts all file types including video/audio
- When PeerTube is enabled, video/audio dropped into TFE pool auto-upload
to PeerTube (process.php detects MIME and uploads immediately)
- PeerTube return IDs now encode type: peertube:video:UUID or peertube:audio:UUID
- load.php returns placeholder SVG for PeerTube files so they appear in FilePond
- Edit mode: all existing files (including PeerTube) shown in TFE FilePond pool
- Remove legacy video/audio/peertube_* handling from both controllers
- Remove unused vide/audio/peertube_* entries from JS QUEUE_CONFIG
- Add Content-Security-Policy to main nginx server block (was only on /admin/)
- Add Cross-Origin-Opener-Policy and Cross-Origin-Resource-Policy headers
- Add includeSubDomains to HSTS header
- Set HttpOnly, Secure, SameSite=Lax session cookie params on public pages
(AdminAuth already hardens the /admin session with SameSite=Strict)
- Update xamxam.conf.reference and SECURITY_HEADERS.md to match
ErrorHandler::userMessage only handled RuntimeException, but all validation
throws in ThesisCreateController and ThesisEditController use plain Exception.
This caused user-friendly messages like 'Le champ Nom/Prénom/Pseudo est requis'
to fall through to the 'Une erreur inattendue est survenue…' generic message.
Fix: add Exception check (after PDOException, since PDOException extends it)
so all validation exceptions pass their message through.
- about.php: use EmailObfuscator::email() for contact email link text instead of htmlspecialchars
- SearchController: raise rate limit from 30 to 300 req/min
- request-access.php: raise rate limit from 3 to 30 req/10min
- partage/index.php: raise rate limit from 5 to 50 req/10min
- contenus.php: make Libre option toggleable (remove disabled class), move to top of Degré d'ouverture, remove temporary note about next academic year
recapitulatif.php (partage):
- Center .thanks-success and add bottom margin/padding
- Display ALL fields: identifier, synopsis, languages, formats,
jury (all roles), baiu link, license, access type
- Add validation notice asking user to verify info, with
xamxam@erg.be contact link (email obfuscated)
StudentEmail:
- Add 'Note contextuelle' and license_custom to email recap
- Rename 'Promoteur·ice(s)' to 'Promoteur·ice(s) interne'
- Change email message to ask student to verify info + contact
for errors
CSV export/import:
- Add 3 new CSV columns: Lecteur·ice(s) interne,
Lecteur·ice(s) externe, Promoteur·ice(s) ULB
- Export splits supervisors by role/is_external/is_ulb into
separate columns
- Import inserts supervisors with correct role, is_external,
and is_ulb flags (was: all treated as generic supervisors)
- Add header matching for short distinguishers (ulb, externe)
via str_contains fallback
The checkbox-list partial defaulted hx-include to 'this, #website-url-fieldset',
but #website-url-fieldset only exists when `Site web` is checked in the
format list. Every language checkbox click triggered a no-match warning
and a cascade triggering the known HTMX internal-data crash.
Drops the session-backed HTMX incremental upload system in favour of a
single JS module that manages `File` objects client-side and injects
them into `FormData` on submit.
Key changes:
* `file-upload-queue.js`: client-side queues with validation, reorder
(SortableJS), removal, dirty-state tracking, and fetch-based submit
with manual redirect handling
* `fichiers-fragment.php`: empty queue containers for JS-managed queues;
HTMX format switching still works with queue rehydration after swap;
annexe uploads now support multiple files
* Form UI cleanup: moved existing files and cover preview into the
`Fichiers` fieldset (edit mode); removed redundant queue labels while
keeping labels for single-file inputs (`couverture`,
`note d'intention`); added delete buttons for existing files
* `ThesisFileHandler.php`: added
`handleTfeQueueFiles()`/`handleAnnexeQueueFiles()` reading from
`$_FILES['queue_file']`; introduced `extractFilesSubArray()` for
nested upload arrays; removed session-based queue handling
* `ThesisCreateController.php` &
`ThesisEditController.php`: switched to extracted
`['queue_file']` uploads
* `beforeunload-guard.js`: now also watches
`window.__xamxamDirty`
* Deleted obsolete PHP upload/remove/reorder queue endpoints for
`partage` and `admin`
* Cleaned up route dispatch in `partage/index.php`
* Misc form and styling updates in templates/CSS
* Added `docs/cms-migration-plan.html`
Replace the client-side FileArray + Sortable drag-to-reorder with a
server-side session-based upload flow:
- New endpoints: /partage/upload-tfe-file, /partage/remove-tfe-file
(and /admin/ variants) — single-file incremental upload via HTMX
multipart/form-data with progress bar support
- Session storage: uploaded files go to STORAGE_ROOT/uploads/{session_id}/
with metadata in $_SESSION['tfe_uploads']
- file-upload-queue.js reduced to single-file previews only (couverture,
note_intention, annexes thumbnails)
- ThesisFileHandler gains handleTfeFilesFromSession + writeTfeFileFromSrc
+ cleanupSessionUploads for final commit from session temp
- Sortable.min.js removed from all script tags; drag handles and ghost
CSS removed
- No file_orders[]/file_labels[] hidden field injection needed
- Upload queue survives page refresh (server-owned list)
This eliminates the SortableJS dependency entirely while keeping the
same UX: pick files, see them in a queue, remove individual files.
Added EmailObfuscator class (src/EmailObfuscator.php) that converts
email addresses to HTML decimal entities (e.g. foo@...)
so browsers render them correctly but bots and scrapers see gibberish.
Methods:
- email($addr): obfuscate for display in HTML content
- mailto($addr): return obfuscated mailto: href
- obfuscateHtml($html): post-process rendered HTML to obfuscate all
mailto: links (used after Parsedown/Markdown rendering)
Applied to:
- partage/index.php: mailto link at top + error scenarios via _flash_contact
flag rendered in form.php (outside htmlspecialchars to avoid double-escape)
- admin/acces.php: request email mailto links
- admin/file-access.php: request email mailto links
- public/about.php: contact email mailto links
- public/tfe.php: author contact mailto links
- AboutController: Parsedown output post-processing
- LicenceController: Parsedown output post-processing
- Dispatcher::render(): require_once EmailObfuscator for all public views
Also fixed _flash_contact session flag in form.php partial to show
contact email line on share link validation errors (separate from
flash_error/warning to bypass htmlspecialchars double-escaping).
The share link (partage) form does not expose a license field and does
not send access_type_id (defaults to 2/Interne). Server-side validation
was unconditionally requiring a license for non-admin submissions,
causing all share link submissions to fail.
Now the license check is gated on adminMode=false AND accessTypeId=1
(Libre), matching the client-side HTMX fragment behaviour in
licence-fragment.php. Also fixed a use-before-definition where
accessTypeId was referenced before being assigned.
Student form improvements:
- Add xamxam@erg.be mailto link at top of form
- On validation error, append "Si le problème persiste, envoyez un
e-mail à xamxam@erg.be" to the flash message
- Preserve uploaded file names across validation redirects: store in
session (share_primed_files_<slug>), display as warning on form
re-render so the student knows which files to re-select
- License: only required for non-admin when access_type_id=1 (Libre),
not for Interne (2) or Interdit (3). Fixes share link submissions
failing with "Veuillez sélectionner une licence". Also fixed
use-before-definition of accessTypeId.
- Rename 'Éditer Données Secondaires' → 'Données Secondaires', remove fieldset wrapper on Mots-clés link
- Create admin-toc.php partial: IntersectionObserver-based sidebar nav
- Include TOC on contenus.php, acces.php, parametres.php
- Add .admin-with-toc flex layout (sidebar + main) and .admin-toc CSS
- Fonts (Ductus, BBB DM Sans): verified loaded via variables.css → common.css import chain
- TOC: move inside <main> as <aside>, content in <article>, fix scrolling
- Lazy load: hx-trigger='load delay:100ms' with spinner (htmx-indicator) for tags/langues
- Inline rename: edit button in Nom cell, HTMX post for rename, validate+ cancel buttons
- Checkbox column: width:1% / fit-content
- Remove per-row merge forms/selects, only bulk merge when ≥2 checkboxes selected
- Remove per-row merge dialogs, keep only bulk merge and delete dialogs
- Add htmx-settling CSS transition for lazy-load fade-in
- Update acces.php/parametres.php: article layout, TOC inside main
- TOC: DOMContentLoaded guard, use <nav>+<a> directly instead of <ul>/<li>
- Section spacing: margin-bottom on sections and fieldsets in admin-main--toc
- Language dedup: GROUP BY LOWER(name) in getAllLanguagesWithCount and searchLanguages
- deduplicateLanguages() merges duplicate names and reassigns thesis_languages
- Sticky bulk-actions: position:sticky;top:0;z-index:10
- Tags toolbar: title left, stat count right (margin-left:auto), search bar under
- Tags count stat updated via hx-swap-oob from fragment
- Remove margin/max-width from .admin-main--toc
- Gap between TOC and article: --space-xs, sticky top: --space-xs
- Main padding: --space-s / --space-m / --space-xl (was --space-l/--space-l/--space-2xl)
- Article padding-top: --space-m
Mirrors the mots-clé tag-search system: dropdown suggestions from
existing languages via HTMX, pill display with bin-icon remove buttons,
'Créer' option for new languages. Replaces the plain text input.
- New partial: templates/partials/form/language-search.php
- New fragment: public/partage/language-search-fragment.php
- Admin wrapper: public/admin/language-search-fragment.php
- Updated language-autre-fragment to return just the required asterisk indicator
- Updated both controllers to handle language_autre as array (pill-based)
with backward-compatible string path
- Updated edit form to compute selectedOtherLanguages from DB
- Registered new route in partage/index.php
- Fix CSV importer: split comma-separated language column into individual entries
- Add htmx active search to admin index, title line-clamp, predefined languages only in checkboxes
- Admin index: filter form now uses htmx triggers (input delay:300ms on search,
change on selects) to actively search without page reload
- Sort links include hx-push-url for back-button support
- Added loading indicator bar (.admin-search-indicator)
- Title column: line-clamp at 2 lines with overflow hidden, native title attr
tooltip for full text
- Language checkboxes now show only 3 predefined languages (Français, Anglais,
Néerlandais); all others go via the Autre langue search component
- Added Database::getPredefinedLanguages() and excluded predefined from
language-search-fragment suggestions
- Included hidden sort/dir inputs in table-wrap so sort state preserved across
filter changes
- Fix language-search: block 'Créer' for predefined languages in dropdown
The 'Créer' option in the language-search dropdown now also checks against the
predefined set (français, anglais, néerlandais) to avoid offering creation of
languages that already exist as checkboxes.