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'; 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->getAllLanguages(); $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'] ?? ''; // Author contact info (from view) $currentAuthorEmail = $thesis['author_email'] ?? ''; $currentAuthorShowContact = (bool)($thesis['author_show_contact'] ?? 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, 'currentAuthorEmail' => $currentAuthorEmail, 'currentAuthorShowContact' => $currentAuthorShowContact, '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, file_size_info, 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 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 ──────────────────────────────────────────── $this->db->updateThesis($thesisId, [ 'title' => trim($post['titre'] ?? ''), 'subtitle' => trim($post['subtitle'] ?? ''), 'year' => intval($post['année'] ?? 0), 'orientation_id' => intval($post['orientation'] ?? 0), 'ap_program_id' => intval($post['ap'] ?? 0), 'finality_id' => intval($post['finality'] ?? 0), '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, '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']), 'cc4r' => !empty($post['cc2r']), 'license_custom' => trim($post['license_custom'] ?? ''), ]); // ── 2. Authors (alphabetically sorted) ───────────────────────────── $authorsRaw = trim($post['auteurice'] ?? ''); $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 ? ($post['mail'] ?? null) : null, 'show_contact' => $i === 0 ? $showContact : false, ]; } $this->db->setThesisAuthors($thesisId, $authorEntries); // ── 3. Jury ─────────────────────────────────────────────────────── $juryMembers = $this->collectJuryMembers($post); $this->db->setThesisJury($thesisId, $juryMembers); // ── 4. 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( $thesisId, isset($post['formats']) && is_array($post['formats']) ? $post['formats'] : [] ); // ── 6. Tags ─────────────────────────────────────────────────────── $keywordsRaw = trim($post['tag'] ?? ''); $keywords = $keywordsRaw !== '' ? array_map('trim', explode(',', $keywordsRaw)) : []; $this->db->setThesisTags($thesisId, $keywords); $this->db->commit(); } catch (Exception $e) { $this->db->rollback(); throw $e; } // ── Cover image (outside transaction — filesystem op) ───────────────── if (isset($post['remove_cover'])) { $allFiles = $this->db->getThesisFiles($thesisId); foreach ($allFiles as $f) { if ($f['file_type'] === 'cover') { $this->db->deleteThesisFile((int)$f['id'], $thesisId); if (!empty($f['file_path']) && defined('STORAGE_ROOT')) { $abs = STORAGE_ROOT . '/' . $f['file_path']; if (file_exists($abs)) { @unlink($abs); } } break; } } } else { $this->db->handleCoverUpload($thesisId, $files['couverture'] ?? null); } // ── 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; } $filePath = $this->db->deleteThesisFile($fileId, $thesisId); if ($filePath && defined('STORAGE_ROOT')) { // Skip filesystem deletion for website URLs (not real files) if (!str_starts_with($filePath, 'http://') && !str_starts_with($filePath, 'https://')) { $abs = STORAGE_ROOT . '/' . $filePath; if (file_exists($abs)) { @unlink($abs); } } } } // ── 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 thesis files upload ─────────────────────────────────────────── if (!empty($files['files']['name'][0])) { $this->handleThesisFiles($thesisId, $post, $files['files']); } // ── PeerTube video / audio uploads ──────────────────────────────────── $this->handlePeerTubeUpload($thesisId, trim($post['titre'] ?? ''), $files, 'peertube_video'); $this->handlePeerTubeUpload($thesisId, trim($post['titre'] ?? ''), $files, 'peertube_audio'); // ── Website URL — add or update ────────────────────────────────────── $this->handleWebsiteUrl($thesisId, $post); } // ── Private: file uploads ───────────────────────────────────────────────── /** * Process multiple new thesis-file uploads. * * Files are stored in the existing folder used by this thesis (detected * from any current thesis_files row), or a new one is created following * the same {year}_{authorSlug} convention as ThesisCreateController. */ private function handleThesisFiles(int $thesisId, array $post, array $uploads): void { $allowedMimes = [ 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'video/mp4', 'video/webm', 'video/ogg', 'video/quicktime', 'audio/mpeg', 'audio/mp3', 'audio/ogg', 'audio/wav', 'audio/flac', 'audio/aac', 'audio/x-m4a', 'audio/mp4', 'text/vtt', 'application/zip', 'application/x-zip-compressed', 'application/x-tar', 'application/gzip', 'application/octet-stream', ]; $allowedExts = [ 'jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf', 'mp4', 'webm', 'ogv', 'mov', 'mp3', 'ogg', 'oga', 'wav', 'flac', 'aac', 'm4a', 'vtt', 'zip', 'tar', 'gz', 'tgz', ]; $maxBytes = 500 * 1024 * 1024; // 500 MB $maxPdfBytes = 100 * 1024 * 1024; // 100 MB for PDFs $year = (int)($post['année'] ?? date('Y')); $authorName = trim($post['auteurice'] ?? 'unknown'); // Sort the raw comma-separated string alphabetically, then slugify. $names = array_values(array_filter(array_map('trim', explode(',', $authorName)), fn ($n) => $n !== '')); sort($names, SORT_NATURAL); $authorSlug = $this->generateAuthorSlug(implode(', ', $names)); // Per-file labels and sort orders submitted alongside the upload inputs $fileLabels = $post['file_labels'] ?? []; $fileOrders = $post['file_orders'] ?? []; // Reuse existing folder if possible $existingFiles = $this->db->getThesisFiles($thesisId); $uploadDir = null; $folderName = null; foreach ($existingFiles as $f) { if (str_starts_with($f['file_path'] ?? '', 'theses/')) { $parts = explode('/', $f['file_path']); if (count($parts) >= 3) { $folderName = $parts[2]; $uploadDir = STORAGE_ROOT . "/theses/{$year}/{$folderName}/"; break; } } } if ($uploadDir === null) { $folderName = $this->ensureUniqueFolder($year, $authorSlug); $uploadDir = STORAGE_ROOT . "/theses/{$year}/{$folderName}/"; } if (!is_dir($uploadDir)) { mkdir($uploadDir, 0755, true); } $count = count($uploads['name']); for ($i = 0; $i < $count; $i++) { if (($uploads['error'][$i] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) { continue; } if (($uploads['error'][$i] ?? -1) !== UPLOAD_ERR_OK) { error_log("ThesisEditController: upload error {$uploads['error'][$i]} for {$uploads['name'][$i]}"); continue; } $finfo = new finfo(FILEINFO_MIME_TYPE); $mimeType = $finfo->file($uploads['tmp_name'][$i]); $ext = strtolower(pathinfo($uploads['name'][$i], PATHINFO_EXTENSION)); if ($mimeType === 'text/plain' && $ext === 'vtt') { $mimeType = 'text/vtt'; } // Allow any ext-matched file even if finfo returns application/octet-stream if (!in_array($mimeType, $allowedMimes, true) && !in_array($ext, $allowedExts, true)) { error_log("ThesisEditController: invalid type {$uploads['name'][$i]} ($mimeType / $ext), skipping"); continue; } $isPdf = ($mimeType === 'application/pdf' || $ext === 'pdf'); $sizeLimit = $isPdf ? $maxPdfBytes : $maxBytes; if ($uploads['size'][$i] > $sizeLimit) { error_log("ThesisEditController: file too large {$uploads['name'][$i]} (" . round($uploads['size'][$i] / 1024 / 1024) . ' MB), skipping'); continue; } $originalName = $uploads['name'][$i]; $sanitized = $this->sanitizeFilename($originalName); $prefix = $authorSlug . '_' . $sanitized; $candidate = $prefix; $suffix = 1; while (file_exists($uploadDir . $candidate)) { $candidate = $authorSlug . '_' . pathinfo($sanitized, PATHINFO_FILENAME) . '_' . $suffix . '.' . $ext; $suffix++; } $targetPath = $uploadDir . $candidate; if (!move_uploaded_file($uploads['tmp_name'][$i], $targetPath)) { error_log("ThesisEditController: failed to move {$originalName}"); continue; } chmod($targetPath, 0644); $fileType = $this->detectFileType($mimeType, $ext, $originalName); $label = trim($fileLabels[$i] ?? ''); $sortOrder = isset($fileOrders[$i]) ? (int)$fileOrders[$i] : null; $relPath = "theses/{$year}/{$folderName}/" . $candidate; $this->db->insertThesisFile( $thesisId, $fileType, $relPath, basename($originalName), $uploads['size'][$i], $mimeType, $label !== '' ? $label : null, $sortOrder ); error_log("ThesisEditController: uploaded → $candidate ($fileType)"); } } /** * Determine the logical file_type from MIME type, extension, and original filename. */ private function detectFileType(string $mimeType, string $ext, string $originalName): string { if ($ext === 'vtt' || $mimeType === 'text/vtt') { return 'caption'; } if (str_starts_with($mimeType, 'audio/') || in_array($ext, ['mp3','ogg','oga','wav','flac','aac','m4a'], true)) { return 'audio'; } if (str_starts_with($mimeType, 'video/') || in_array($ext, ['mp4','webm','ogv','mov'], true)) { return 'video'; } if ($mimeType === 'application/pdf' || $ext === 'pdf') { return 'main'; } if (str_starts_with($mimeType, 'image/') || in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) { return 'image'; } return 'other'; } // ── Private: string helpers ─────────────────────────────────────────────── private function generateAuthorSlug(string $authorName): string { $n = function_exists('iconv') ? iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $authorName) : $authorName; $accents = [ 'à' => 'a','â' => 'a','ä' => 'a','é' => 'e','è' => 'e','ê' => 'e','ë' => 'e', 'î' => 'i','ï' => 'i','ô' => 'o','ö' => 'o','ù' => 'u','û' => 'u','ü' => 'u','ç' => 'c', ]; $n = strtr($n, $accents); $slug = strtoupper(trim(preg_replace('/[^A-Za-z0-9]+/', '_', $n), '_')); return $slug !== '' ? $slug : 'AUTHOR'; } private function sanitizeFilename(string $filename): string { $ext = pathinfo($filename, PATHINFO_EXTENSION); $name = pathinfo($filename, PATHINFO_FILENAME); $n = function_exists('iconv') ? iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $name) : $name; $accents = [ 'à' => 'a','â' => 'a','ä' => 'a','é' => 'e','è' => 'e','ê' => 'e','ë' => 'e', 'î' => 'i','ï' => 'i','ô' => 'o','ö' => 'o','ù' => 'u','û' => 'u','ü' => 'u','ç' => 'c', ]; $n = trim(preg_replace('/[^A-Za-z0-9]+/', '_', strtr($n, $accents)), '_'); if ($n === '') { $n = 'file'; } return $ext !== '' ? $n . '.' . strtolower($ext) : $n; } private function ensureUniqueFolder(int $year, string $authorSlug): string { $baseDir = STORAGE_ROOT . '/theses/' . $year . '/'; $candidate = $year . '_' . $authorSlug; $suffix = 1; while (is_dir($baseDir . $candidate)) { $candidate = $year . '_' . $authorSlug . '_' . $suffix++; } return $candidate; } // ── 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; } /** * Build file_size_info from separate duration fields. */ protected function buildFileSizeInfo(array $post): string { $pages = trim($post['duration_pages'] ?? ''); $minutes = trim($post['duration_minutes'] ?? ''); $info = ''; if ($pages !== '' && $minutes !== '') { $info = $pages . ' pages + ' . $minutes . ' minutes'; } elseif ($minutes !== '') { $info = $minutes . ' minutes'; } elseif ($pages !== '') { $info = $pages . ' pages'; } if (!empty($post['has_annexes'])) { $info = $info ? $info . ' + annexe(s)' : 'Annexe(s)'; } return $info; } /** * Upload a video or audio file to PeerTube when the feature is enabled. * * @param int $thesisId Thesis to attach the result to. * @param string $title Title to use on PeerTube. * @param array $files $_FILES array. * @param string $inputName 'peertube_video' or 'peertube_audio'. */ private function handlePeerTubeUpload(int $thesisId, string $title, array $files, string $inputName): void { $upload = $files[$inputName] ?? null; if (!$upload || !isset($upload['error']) || $upload['error'] !== UPLOAD_ERR_OK) { return; } require_once APP_ROOT . '/src/PeerTubeService.php'; if (!PeerTubeService::isEnabled($this->db)) { return; } try { $watchUrl = PeerTubeService::upload( $this->db, $upload['tmp_name'], $title, '' ); $fileType = str_contains($inputName, 'audio') ? 'audio' : 'video'; $this->db->insertThesisFile( $thesisId, $fileType, $watchUrl, basename($upload['name']), $upload['size'], $upload['type'] ?? 'application/octet-stream', null, null ); error_log("ThesisEditController: PeerTube upload OK → $watchUrl"); } catch (\Throwable $e) { error_log('ThesisEditController: PeerTube upload failed — ' . $e->getMessage()); } } /** * 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 $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"); } }