Files
xamxam/app/src/Controllers/ThesisEditController.php
Pontoporeia cc0ae32df0 fix: resolve partage form submission issues
- Replace mb_strlen/mb_substr/mb_strtolower with strlen/substr/strtolower
  (mbstring extension missing on server, causing fatal error)
- Scope annexes checkbox HTMX swap to #annexes-input-block with hx-select
  (prevents duplicating entire page inside Fichiers fieldset)
- Split format+fichiers response: #format-fichiers-block (stable) and
  #format-extras-block (swappable, inside Fichiers fieldset). Format
  checkboxes use hx-select to extract only the extras, preserving file queue.
- Keep format extras inline in Fichiers fieldset (no sub-fieldsets). Remove
  website legend input (URL only).
- When PeerTube upload disabled, show direct file upload inputs for
  video/audio (name=files[]).
- Add "Glissez-déposez" sort hint below TFE file queue.
- Fix .fq-name overflow with width:0;min-width:100% chain.
- Remove legend placeholder from .fq-item.
- Merge "Récits et expérimentation" AP into "Narration Spéculative".
  Rename PACS to "Pratique de lart - outils critiques, arts et contexte
  simultanés".
- Remove président·e field from jury fieldset, form templates, and
  controller validation. Keep DB column and display logic for existing data.
2026-05-19 00:08:05 +02:00

782 lines
33 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
/**
* 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
{
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';
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<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;
}
/**
* 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");
}
}