Files
xamxam/app/src/Controllers/ThesisEditController.php
T
Pontoporeia 4fa57d592a refactor: combined duration (pages + minutes), has_annexes checkbox, remove Mo
- Remove Mo option from duration, keep only pages and minutes
- Redesign duration fieldset: separate Pages input + Durée h:m inputs, both can be set together
- Fix minutes input visibility: wider inputs (6ch), proper CSS layout
- Add has_annexes checkbox to fichiers fragment + DB column + controllers
- Display duration on admin backoffice recap page
- Display duration on public partage recap page
- Update public TFE page for new combined duration format
- Migration 041: add duration_pages, duration_minutes, has_annexes columns; migrate data; recreate views
2026-07-03 15:58:10 +02:00

666 lines
30 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
require_once __DIR__ . '/ThesisFileHandler.php';
/**
* ThesisEditController
*
* Centralises all data-fetching and mutation logic for the admin thesis-edit
* workflow (admin/edit.php + admin/actions/edit.php).
*
* Responsibilities:
* - Loading thesis data and lookup tables for the edit form view
* - Validating and persisting POST submissions (thesis metadata, authors,
* jury, languages, formats, tags, banner)
* - WCAG 3.3.1: mapping validation exceptions to autofocus field hints
*
* The class has NO output side-effects; all redirects, flash writes, and
* template rendering stay in the thin dispatcher files so the view layer
* remains easy to inspect and modify.
*/
class ThesisEditController
{
use ThesisFileHandler;
private Database $db;
public function __construct(Database $db)
{
$this->db = $db;
}
// ── Factory ───────────────────────────────────────────────────────────────
/**
* Convenience factory — instantiates Database and returns a ready
* controller. Accepts an optional existing Database instance so callers
* that already hold one (e.g. during testing) can avoid a second
* connection.
*/
public static function create(?Database $db = null): self
{
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/ErrorHandler.php';
return new self($db ?? Database::getInstance());
}
// ── Read / view data ─────────────────────────────────────────────────────
/**
* Load all data required to render the edit form.
*
* Returns a flat array of view variables:
* - 'thesis' thesis row (from getThesis)
* - 'currentLanguages' int[]
* - 'currentFormats' int[]
* - 'jury' jury rows
* - 'currentFiles' all thesis_files rows (cover + thesis files)
* - 'currentCover' single thesis_files row for cover, or null
* - 'orientations' lookup rows
* - 'apPrograms' lookup rows
* - 'finalityTypes' lookup rows
* - 'languages' lookup rows
* - 'formatTypes' lookup rows
* - 'licenseTypes' lookup rows
* - 'accessTypes' lookup rows
* - 'currentLicenseId' int|null
* - 'currentAccessTypeId' int|null
* - 'currentContextNote' string
* - 'pageTitle' string
*
* @throws Exception if the thesis is not found or a DB error occurs.
*/
public function load(int $thesisId): array
{
if ($thesisId <= 0) {
throw new InvalidArgumentException('ID invalide');
}
$thesis = $this->db->getThesis($thesisId);
if (!$thesis) {
throw new RuntimeException('TFE non trouvé');
}
$currentLanguages = $this->db->getThesisLanguageIds($thesisId);
$currentFormats = $this->db->getThesisFormatIds($thesisId);
$jury = $this->db->getThesisJury($thesisId);
$currentFiles = $this->db->getThesisFiles($thesisId);
// Separate out the cover entry for convenience
$currentCover = null;
foreach ($currentFiles as $f) {
if ($f['file_type'] === 'cover') {
$currentCover = $f;
break;
}
}
$orientations = $this->db->getAllOrientations();
$apPrograms = $this->db->getAllAPPrograms();
$finalityTypes = $this->db->getAllFinalityTypes();
$languages = $this->db->getPredefinedLanguages();
$formatTypes = $this->db->getAllFormatTypes();
$licenseTypes = $this->db->getAllLicenseTypes();
$enabledAccessTypes = $this->db->getEnabledFormAccessTypes();
$rawRow = $this->db->getThesisRawFields($thesisId) ?? [];
$currentLicenseId = $rawRow['license_id'] ?? null;
$currentAccessTypeId = $rawRow['access_type_id'] ?? null;
$currentContextNote = $rawRow['context_note'] ?? '';
$currentContactVisible = $rawRow['contact_visible'] ?? '';
$currentDurationPages = $rawRow['duration_pages'] ?? null;
$currentDurationMins = $rawRow['duration_minutes'] ?? null;
// Author contact info (from view)
$contactInterne = $thesis['contact_interne'] ?? '';
$contactPublic = (bool)($thesis['contact_public'] ?? false);
return [
'thesis' => $thesis,
'currentLanguages' => $currentLanguages,
'currentFormats' => $currentFormats,
'jury' => $jury,
'currentFiles' => $currentFiles,
'currentCover' => $currentCover,
'orientations' => $orientations,
'apPrograms' => $apPrograms,
'finalityTypes' => $finalityTypes,
'languages' => $languages,
'formatTypes' => $formatTypes,
'licenseTypes' => $licenseTypes,
'enabledAccessTypes' => $enabledAccessTypes,
'currentLicenseId' => $currentLicenseId,
'currentAccessTypeId' => $currentAccessTypeId,
'currentContextNote' => $currentContextNote,
'currentContactVisible' => $currentContactVisible,
'currentDurationPages' => $currentDurationPages,
'currentDurationMins' => $currentDurationMins,
'contactInterne' => $contactInterne,
'contactPublic' => $contactPublic,
'currentRaw' => $rawRow,
'pageTitle' => 'Éditer TFE - ' . htmlspecialchars($thesis['title']),
];
}
// ── Write / action ────────────────────────────────────────────────────────
/**
* Validate and persist a thesis-edit POST submission.
*
* Runs the full update inside a transaction:
* 1. Thesis metadata (title, subtitle, year, orientation, ap, finality,
* synopsis, context_note, baiu_link, license_id,
* access_type_id, is_published)
* 2. Authors (setThesisAuthors)
* 3. Jury (setThesisJury)
* 4. Languages (setThesisLanguages)
* 5. Formats (setThesisFormats)
* 6. Tags (setThesisTags)
* Then handles banner upload/removal outside the transaction.
*
* @param int $thesisId Validated thesis ID (> 0).
* @param array $post Sanitised $_POST array.
* @param array $files $_FILES array (expects 'banner' key).
*
* @throws Exception on validation or DB error (caller must rollback if
* the transaction is still open, but this method rolls
* back internally before re-throwing).
*/
public function save(int $thesisId, array $post, array $files): void
{
if ($thesisId <= 0) {
throw new InvalidArgumentException('ID de TFE invalide.');
}
// ── Basic validation (same required fields as create) ──────────────────
$errors = [];
$titre = trim($post['titre'] ?? '');
if ($titre === '') {
$errors[] = 'Le titre du TFE est requis.';
}
$auteurice = trim($post['auteurice'] ?? '');
if ($auteurice === '') {
$errors[] = "L'auteur·ice est requis.";
}
$synopsis = trim($post['synopsis'] ?? '');
if ($synopsis === '') {
$errors[] = 'Le synopsis est requis.';
}
$annee = intval($post['année'] ?? 0);
if ($annee < 2000 || $annee > ((int)date('Y') + 1)) {
$errors[] = "L'année est invalide.";
}
$orientationId = intval($post['orientation'] ?? 0);
$apProgramId = intval($post['ap'] ?? 0);
$finalityId = intval($post['finality'] ?? 0);
if (!empty($errors)) {
throw new RuntimeException(implode(' ', $errors));
}
$this->db->beginTransaction();
try {
// ── 1. Thesis metadata ────────────────────────────────────────────
$meta = [
'title' => trim($post['titre'] ?? ''),
'subtitle' => trim($post['subtitle'] ?? ''),
'year' => intval($post['année'] ?? 0),
'orientation_id' => ($v = intval($post['orientation'] ?? 0)) > 0 ? $v : null,
'ap_program_id' => ($v = intval($post['ap'] ?? 0)) > 0 ? $v : null,
'finality_id' => ($v = intval($post['finality'] ?? 0)) > 0 ? $v : null,
'synopsis' => trim($post['synopsis'] ?? ''),
'context_note' => trim($post['context_note'] ?? ''),
'contact_visible' => trim($post['contact_visible'] ?? ''),
'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,
'is_published' => isset($post['is_published']),
'remarks' => trim($post['remarks'] ?? ''),
'jury_points' => $post['jury_points'] ?? null,
'exemplaire_baiu' => !empty($post['exemplaire_baiu']),
'exemplaire_erg' => !empty($post['exemplaire_erg']),
'cc2r' => !empty($post['cc2r']),
'license_custom' => trim($post['license_custom'] ?? ''),
'duration_pages' => (isset($post['duration_pages']) && $post['duration_pages'] !== '') ? (int)$post['duration_pages'] : null,
'duration_minutes' => (isset($post['duration_minutes']) && $post['duration_minutes'] !== '') ? (int)$post['duration_minutes'] : null,
'has_annexes' => !empty($post['has_annexes']) ? 1 : 0,
];
// Regenerate identifier if year changed or if identifier prefix doesn't match year
$oldThesis = $this->db->getThesis($thesisId);
$oldYear = (int)($oldThesis['year'] ?? 0);
$newYear = $meta['year'];
$oldIdentifier = $oldThesis['identifier'] ?? '';
$oldIdentifierYear = ($oldIdentifier !== '' && preg_match('/^(\d{4})/', $oldIdentifier, $m)) ? (int)$m[1] : 0;
if ($newYear >= 2000 && ($newYear !== $oldYear || $oldIdentifierYear !== $newYear)) {
$newIdentifier = $this->db->generateThesisIdentifier($newYear);
$meta['identifier'] = $newIdentifier;
$reason = $newYear !== $oldYear
? 'Year changed ' . $oldYear . ' → ' . $newYear
: 'Mismatched identifier ' . $oldIdentifier . ' for year=' . $newYear;
error_log('[ThesisEdit] ' . $reason . ', new identifier: ' . $newIdentifier);
}
$this->db->updateThesis($thesisId, $meta);
error_log('[ThesisEdit] Step 1 OK — thesis_id=' . $thesisId);
// ── 2. Authors (alphabetically sorted) ─────────────────────────────
$authorsRaw = trim($post['auteurice'] ?? '');
// contact_interne = private email of the first author (backoffice field)
$contactInterne = trim($post['contact_interne'] ?? '');
$firstAuthorEmail = $contactInterne !== '' ? $contactInterne : null;
// contact_public: whether to show the public contact on the TFE page
$showContact = !empty($post['contact_public']);
$authorNames = [];
if ($authorsRaw !== '') {
$authorNames = array_values(array_filter(array_map('trim', explode(',', $authorsRaw)), fn ($n) => $n !== ''));
sort($authorNames, SORT_NATURAL);
}
$authorEntries = [];
foreach ($authorNames as $i => $name) {
$authorEntries[] = [
'name' => $name,
'email' => $i === 0 ? $firstAuthorEmail : null,
'show_contact' => $i === 0 && $showContact,
];
}
$this->db->setThesisAuthors($thesisId, $authorEntries);
error_log('[ThesisEdit] Step 2 OK — authors=' . json_encode($authorNames));
// ── 3. Jury ───────────────────────────────────────────────────────
$juryMembers = $this->collectJuryMembers($post);
$this->db->setThesisJury($thesisId, $juryMembers);
error_log('[ThesisEdit] Step 3 OK — jury=' . count($juryMembers));
// ── 4. Languages ──────────────────────────────────────────────────
$langIds = isset($post['languages']) && is_array($post['languages'])
? $post['languages']
: [];
// language_autre: pill-based component sends an array; also handle legacy comma-separated string
$autreRaw = $post['language_autre'] ?? '';
if (is_array($autreRaw)) {
foreach ($autreRaw as $langName) {
$langName = trim($langName);
if ($langName !== '') {
$langIds[] = (string)$this->db->getOrCreateLanguage($langName);
}
}
} elseif (is_string($autreRaw) && trim($autreRaw) !== '') {
foreach (array_map('trim', explode(',', $autreRaw)) as $langName) {
if ($langName !== '') {
$langIds[] = (string)$this->db->getOrCreateLanguage($langName);
}
}
}
$this->db->setThesisLanguages($thesisId, $langIds);
error_log('[ThesisEdit] Step 4 OK — languages=' . json_encode($langIds));
// ── 5. Formats ────────────────────────────────────────────────────
$formatIds = isset($post['formats']) && is_array($post['formats'])
? $post['formats']
: [];
$this->db->setThesisFormats($thesisId, $formatIds);
error_log('[ThesisEdit] Step 5 OK — formats=' . json_encode($formatIds));
// ── 6. Tags ───────────────────────────────────────────────────────
$normalizeTag = fn (string $t): string => strtolower(trim(preg_replace('/\s+/', ' ', $t)));
$keywords = [];
if (isset($post['tag']) && is_array($post['tag'])) {
$keywords = array_values(array_unique(array_map(
$normalizeTag,
array_map(fn ($t) => (string)$t, $post['tag'])
)));
} else {
$keywordsRaw = trim($post['tag'] ?? '');
if ($keywordsRaw !== '') {
$keywords = array_map($normalizeTag, explode(',', $keywordsRaw));
}
}
$keywords = array_values(array_unique($keywords));
$keywords = array_filter($keywords, fn ($t) => $t !== '');
$keywords = array_slice($keywords, 0, 10);
if (count($keywords) < 1) {
throw new Exception('Veuillez indiquer au moins 1 mot-clé.');
}
$this->db->setThesisTags($thesisId, $keywords);
error_log('[ThesisEdit] Step 6 OK — tags=' . json_encode($keywords));
$this->db->commit();
error_log('[ThesisEdit] COMMIT OK — thesis_id=' . $thesisId);
} catch (Exception $e) {
ErrorHandler::log('thesis_edit_tx', $e, ['thesis_id' => $thesisId]);
$this->db->rollback();
throw $e;
}
// ── Resolve thesis folder path (reuse existing or build new) ────────
$year = intval($post['année'] ?? date('Y'));
$title = trim($post['titre'] ?? '');
$authors = trim($post['auteurice'] ?? '');
$thesis = $this->db->getThesis($thesisId);
$objet = $thesis['objet'] ?? 'tfe';
$tf = $this->buildThesisFolder($year, $authors, $title, $objet);
$folderPath = $tf['folderPath'];
$filePrefix = $tf['filePrefix'];
// Reuse existing folder if this thesis already has files on disk
$existingFiles = $this->db->getThesisFiles($thesisId);
foreach ($existingFiles as $f) {
$fp = $f['file_path'] ?? '';
if (preg_match('#^(theses|documents|tfe|these|frart)/#', $fp)) {
$parts = explode('/', $fp);
if (count($parts) >= 3) {
$parentDir = $parts[0];
$folderName = $parts[2];
$folderPath = $parentDir . '/' . $year . '/' . $folderName . '/';
$filePrefix = $folderName;
break;
}
}
}
// Ensure the folder exists
$dirAbs = STORAGE_ROOT . '/' . $folderPath;
if (!is_dir($dirAbs)) {
mkdir($dirAbs, 0755, true);
}
// Determine upload path: FilePond async (JS enabled, hex IDs present)
// vs. legacy multipart. Defense-in-depth fallback for no-JS scenarios.
$useFilePond = !empty($post['filepond_mode']) && $this->hasFilePondQueueData($post);
// ── Cover image (outside transaction — filesystem op) ─────────────────
if ($useFilePond) {
// Delete old cover only if a genuinely new cover was uploaded (hex file_id).
// Existing cover preserved in FilePond sends its DB integer ID — skip.
$coverIdRaw = ($post['queue_file']['cover'] ?? null);
$coverId = is_array($coverIdRaw) ? ($coverIdRaw[0] ?? null) : $coverIdRaw;
$isNewCover = $coverId !== null && $coverId !== '' && preg_match('/^[a-f0-9]{32}$/', (string)$coverId);
if ($isNewCover) {
foreach ($existingFiles as $f) {
if ($f['file_type'] === 'cover') {
$this->deleteThesisFileToTrash((int)$f['id'], $thesisId);
break;
}
}
}
$this->handleFilePondSingleFile($thesisId, $post, 'cover', $folderPath, $filePrefix);
} elseif (!empty($post['remove_cover'])) {
foreach ($existingFiles as $f) {
if ($f['file_type'] === 'cover') {
$this->deleteThesisFileToTrash((int)$f['id'], $thesisId);
break;
}
}
} else {
$this->handleCoverUpload($thesisId, $files['couverture'] ?? null, $folderPath, $filePrefix);
}
// ── Note d'intention (replace if uploaded) ────────────────────────────
if ($useFilePond) {
// Only delete + replace if a genuinely new file was uploaded (hex file_id).
// Existing files preserved in the FilePond pool send their DB integer ID;
// we must NOT delete them — they're already stored.
$noteIdRaw = ($post['queue_file']['note_intention'] ?? null);
$noteId = is_array($noteIdRaw) ? ($noteIdRaw[0] ?? null) : $noteIdRaw;
$isNewNote = $noteId !== null && $noteId !== '' && preg_match('/^[a-f0-9]{32}$/', (string)$noteId);
if ($isNewNote) {
foreach ($existingFiles as $f) {
if ($f['file_type'] === 'note_intention') {
$this->deleteThesisFileToTrash((int)$f['id'], $thesisId);
break;
}
}
}
$this->handleFilePondSingleFile($thesisId, $post, 'note_intention', $folderPath, $filePrefix);
} else {
// Legacy path
if (!empty($files['note_intention']['tmp_name'] ?? null) && ($files['note_intention']['error'] ?? -1) === UPLOAD_ERR_OK) {
foreach ($existingFiles as $f) {
if ($f['file_type'] === 'note_intention') {
$this->deleteThesisFileToTrash((int)$f['id'], $thesisId);
break;
}
}
}
$this->handleNoteIntentionUpload($thesisId, $files['note_intention'] ?? null, $folderPath, $filePrefix);
}
// ── Delete individual thesis files ────────────────────────────────────
$deleteIds = isset($post['delete_files']) && is_array($post['delete_files'])
? array_map('intval', $post['delete_files'])
: [];
foreach ($deleteIds as $fileId) {
if ($fileId <= 0) {
continue;
}
$this->deleteThesisFileToTrash($fileId, $thesisId);
}
// ── Reorder existing files ────────────────────────────────────────────
if (!empty($post['file_sort_order']) && is_array($post['file_sort_order'])) {
$this->db->reorderThesisFiles($thesisId, $post['file_sort_order']);
}
// ── Update display labels for existing files ──────────────────────────
if (!empty($post['file_label']) && is_array($post['file_label'])) {
foreach ($post['file_label'] as $fileId => $label) {
$fileId = (int)$fileId;
if ($fileId <= 0) {
continue;
}
$this->db->updateThesisFileLabel($fileId, $thesisId, trim($label) ?: null);
}
}
// ── New TFE/video/audio files upload (choose path based on filepond_mode)
// Count existing TFE files (not cover, note_intention, website, annex, caption, PeerTube)
$tfeCount = 0;
foreach ($existingFiles as $f) {
if (!in_array($f['file_type'] ?? '', ['cover', 'note_intention', 'website', 'annex', 'caption'], true)
&& !str_starts_with($f['file_path'] ?? '', 'http')
&& !str_starts_with($f['file_path'] ?? '', 'peertube_ids:')) {
$tfeCount++;
}
}
if ($useFilePond) {
// New path: files already on server via async FilePond uploads
$nextNum = $tfeCount + 1;
$nextNum = $this->handleFilePondQueueFiles($thesisId, $post, 'tfe', $folderPath, $filePrefix, $nextNum);
$this->handleFilePondQueueFiles($thesisId, $post, 'annexe', $folderPath, $filePrefix, 0);
} else {
// Legacy path: files arrive via multipart $_FILES
$queueFiles = $files['queue_file'] ?? [];
$qTfe = $this->extractFilesSubArray($queueFiles, 'tfe');
$qAnnexe = $this->extractFilesSubArray($queueFiles, 'annexe');
$startNum = $tfeCount + 1;
$startNum = $this->handleTfeQueueFiles($thesisId, $qTfe, $folderPath, $filePrefix, $startNum);
$this->handleAnnexeQueueFiles($thesisId, $qAnnexe, $folderPath, $filePrefix);
// Legacy annexe files (direct upload, non-queue path — kept for backwards compat)
if (isset($files['annexes']) && is_array($files['annexes']['name'] ?? null)) {
$this->handleAnnexeFiles($thesisId, $files['annexes'], $folderPath, $filePrefix, $post);
}
}
// ── Website URL — add or update ──────────────────────────────────────
$this->handleWebsiteUrl($thesisId, $post);
}
// ── WCAG 3.3.1 helper ─────────────────────────────────────────────────────
/**
* Map a validation exception message to the name of the field that should
* receive autofocus when the form is re-rendered.
*
* Returns null when no field mapping is found.
*/
public static function autofocusFieldForError(string $message): ?string
{
if (str_contains($message, 'titre') || str_contains($message, 'Titre')) {
return 'titre';
}
if (str_contains($message, 'année') || str_contains($message, 'Année')) {
return 'année';
}
if (str_contains($message, 'synopsis') || str_contains($message, 'Synopsis')) {
return 'synopsis';
}
if (str_contains($message, 'auteur') || str_contains($message, 'Auteur')) {
return 'auteurice';
}
if (str_contains($message, 'orientation')) {
return 'orientation';
}
if (str_contains($message, 'atelier')) {
return 'ap';
}
if (str_contains($message, 'finalité')) {
return 'finality';
}
if (str_contains($message, 'langue')) {
return 'languages';
}
if (str_contains($message, 'format')) {
return 'formats';
}
if (str_contains($message, 'licence')) {
return 'license_id';
}
if (str_contains($message, 'promoteur')) {
return 'jury_promoteur';
}
if (str_contains($message, 'lecteur·ice interne')) {
return 'jury_lecteur_interne[]';
}
if (str_contains($message, 'lecteur·ice externe')) {
return 'jury_lecteur_externe[]';
}
return null;
}
// ── Private helpers ───────────────────────────────────────────────────────
/**
* Build the jury-members array from POST data.
*
* @param array $post Raw $_POST.
* @return array<int, array{name: string, role: string, is_external: int}>
*/
private function collectJuryMembers(array $post): array
{
$members = [];
// Promoteurs internes (accept both scalar and array)
$promoteurs = $post['jury_promoteur'] ?? null;
if ($promoteurs !== null && !is_array($promoteurs)) {
$promoteurs = [$promoteurs];
}
if (is_array($promoteurs)) {
foreach ($promoteurs as $name) {
$name = trim($name ?? '');
if ($name !== '') {
$members[] = ['name' => $name, 'role' => 'promoteur', 'is_external' => 0, 'is_ulb' => 0];
}
}
}
// Promoteurs ULB (accept both scalar and array)
$promoteursUlb = $post['jury_promoteur_ulb_name'] ?? null;
if ($promoteursUlb !== null && !is_array($promoteursUlb)) {
$promoteursUlb = [$promoteursUlb];
}
if (is_array($promoteursUlb)) {
foreach ($promoteursUlb as $name) {
$name = trim($name ?? '');
if ($name !== '') {
$members[] = ['name' => $name, 'role' => 'promoteur', 'is_external' => 1, 'is_ulb' => 1];
}
}
}
// Lecteurs internes
foreach ($post['jury_lecteur_interne'] ?? [] as $name) {
$name = trim($name);
if ($name !== '') {
$members[] = ['name' => $name, 'role' => 'lecteur', 'is_external' => 0];
}
}
// Lecteurs externes
foreach ($post['jury_lecteur_externe'] ?? [] as $name) {
$name = trim($name);
if ($name !== '') {
$members[] = ['name' => $name, 'role' => 'lecteur', 'is_external' => 1];
}
}
// Backwards compat: old jury_lecteurs[]
if (isset($post['jury_lecteurs'])) {
foreach ($post['jury_lecteurs'] as $i => $name) {
$name = trim($name);
if ($name !== '') {
$members[] = [
'name' => $name,
'role' => 'lecteur',
'is_external' => isset($post['jury_lecteurs_ext'][$i]) ? 1 : 0,
];
}
}
}
return $members;
}
/**
* Add or update a website URL thesis_file row.
*
* If a website row already exists for this thesis, it is replaced.
* Otherwise a new row is inserted.
*/
private function handleWebsiteUrl(int $thesisId, array $post): void
{
$websiteUrl = trim($post['website_url'] ?? '');
// Remove existing website rows (website URLs have no disk file)
$existingFiles = $this->db->getThesisFiles($thesisId);
foreach ($existingFiles as $f) {
if ($f['file_type'] === 'website') {
$this->db->deleteThesisFile((int)$f['id'], $thesisId);
}
}
if ($websiteUrl === '') {
return;
}
// Validate URL
$websiteUrl = filter_var($websiteUrl, FILTER_VALIDATE_URL);
if ($websiteUrl === false) {
error_log('ThesisEditController: invalid website URL, skipping');
return;
}
$label = trim($post['website_label'] ?? '');
$sortOrder = isset($post['website_order']) ? (int)$post['website_order'] : null;
$fileName = rtrim(preg_replace('#^https?://#i', '', $websiteUrl), '/');
$this->db->insertThesisFile(
$thesisId,
'website',
$websiteUrl,
$fileName,
0,
'text/html',
$label !== '' ? $label : null,
$sortOrder
);
error_log("ThesisEditController: website stored → $websiteUrl");
}
}