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 */ 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"); } }