fileWarnings; } /** * Process a cover image upload. * * @param int $thesisId * @param array|null $upload Single-file $_FILES entry. * @param string $folderPath Relative path from STORAGE_ROOT to the thesis folder (e.g. "tfe/2025/2025_SMITH_Mon_Titre/"). * @param string $filePrefix The prefix shared by all files in this folder (e.g. "2025_SMITH_Mon_Titre"). */ protected function handleCoverUpload(int $thesisId, ?array $upload, string $folderPath, string $filePrefix): void { if (!$upload || !isset($upload['error']) || $upload['error'] !== UPLOAD_ERR_OK) { return; } $finfo = new \finfo(FILEINFO_MIME_TYPE); $mimeType = $finfo->file($upload['tmp_name']); $ext = strtolower(pathinfo($upload['name'], PATHINFO_EXTENSION)); $allowedMimes = ['image/jpeg', 'image/png', 'image/webp']; $allowedExts = ['jpg', 'jpeg', 'png', 'webp']; if (!in_array($mimeType, $allowedMimes, true) || !in_array($ext, $allowedExts, true) || $upload['size'] > self::MAX_COVER_SIZE) { $this->fileWarnings[] = "Couverture « {$upload['name']} » ignorée : format ou taille non accepté."; error_log("ThesisFileHandler: invalid cover MIME $mimeType / $ext / {$upload['size']} bytes, skipping"); return; } $dir = STORAGE_ROOT . '/' . $folderPath; if (!is_dir($dir)) { mkdir($dir, 0755, true); } $targetName = $filePrefix . '_COUVERTURE.' . $ext; $targetPath = $dir . $targetName; if (!move_uploaded_file($upload['tmp_name'], $targetPath)) { error_log("ThesisFileHandler: failed to move cover to $targetPath"); return; } chmod($targetPath, 0644); $relPath = $folderPath . $targetName; $this->db->insertThesisFile( $thesisId, 'cover', $relPath, basename($upload['name']), $upload['size'], $mimeType ); error_log("ThesisFileHandler: cover uploaded → $relPath"); } /** * Process the note d'intention upload (single PDF). * * @param int $thesisId * @param array|null $upload Single-file $_FILES entry. * @param string $folderPath Relative path from STORAGE_ROOT to the thesis folder. * @param string $filePrefix The shared file prefix. */ protected function handleNoteIntentionUpload(int $thesisId, ?array $upload, string $folderPath, string $filePrefix): void { if (!$upload || !isset($upload['error']) || $upload['error'] !== UPLOAD_ERR_OK) { return; } $finfo = new \finfo(FILEINFO_MIME_TYPE); $mimeType = $finfo->file($upload['tmp_name']); $ext = strtolower(pathinfo($upload['name'], PATHINFO_EXTENSION)); if ($mimeType !== 'application/pdf' || $ext !== 'pdf') { $this->fileWarnings[] = "Note d'intention « {$upload['name']} » ignorée : seul le format PDF est accepté."; error_log("ThesisFileHandler: invalid note d'intention MIME $mimeType, skipping"); return; } if ($upload['size'] > self::MAX_PDF_SIZE) { $this->fileWarnings[] = "Note d'intention « {$upload['name']} » ignorée : fichier trop volumineux (max 100 MB)."; error_log("ThesisFileHandler: note d'intention too large ({$upload['size']} bytes), skipping"); return; } $dir = STORAGE_ROOT . '/' . $folderPath; if (!is_dir($dir)) { mkdir($dir, 0755, true); } $targetName = $filePrefix . '_NOTE_INTENTION.pdf'; $targetPath = $dir . $targetName; if (!move_uploaded_file($upload['tmp_name'], $targetPath)) { error_log("ThesisFileHandler: failed to move note d'intention to $targetPath"); return; } chmod($targetPath, 0644); $relPath = $folderPath . $targetName; $this->db->insertThesisFile( $thesisId, 'note_intention', $relPath, basename($upload['name']), $upload['size'], 'application/pdf' ); error_log("ThesisFileHandler: note d'intention uploaded → $relPath"); } /** * Process multiple TFE file uploads (files[] — the main set). * * Files are stored with TFE_{XX} semantics. Numbering is contiguous * across all sub-types in order: PDF → video → audio → subtitles → images → archives. * Subtitles (VTT) are placed immediately after their associated video. * * @param int $thesisId * @param array|null $uploads Multi-file $_FILES entry. * @param string $folderPath Relative path from STORAGE_ROOT to the thesis folder. * @param string $filePrefix The shared file prefix. * @param array $post $_POST for per-file labels/orders. * @param int $startNum Starting number for TFE_XX (usually 01). */ protected function handleTfeFiles(int $thesisId, ?array $uploads, string $folderPath, string $filePrefix, array $post = [], int $startNum = 1): int { if (!$uploads || !is_array($uploads['name'] ?? null)) { return $startNum; } $dir = STORAGE_ROOT . '/' . $folderPath; if (!is_dir($dir)) { mkdir($dir, 0755, true); } $fileLabels = $post['file_labels'] ?? []; $fileOrders = $post['file_orders'] ?? []; // Collect all files, classify, sort by hierarchy $files = []; $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("ThesisFileHandler: TFE 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'; } if ($mimeType === 'application/octet-stream' && !in_array($ext, self::ALLOWED_EXTENSIONS, true)) { $this->fileWarnings[] = "Fichier TFE « {$uploads['name'][$i]} » ignoré : format .$ext non accepté."; error_log("ThesisFileHandler: TFE extension not allowed {$uploads['name'][$i]} ($ext), skipping"); continue; } if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true) && !in_array($ext, self::ALLOWED_EXTENSIONS, true)) { $this->fileWarnings[] = "Fichier TFE « {$uploads['name'][$i]} » ignoré : type non accepté ($mimeType)."; error_log("ThesisFileHandler: invalid TFE type {$uploads['name'][$i]} ($mimeType / $ext), skipping"); continue; } $isPdf = ($mimeType === 'application/pdf' || $ext === 'pdf'); $isAv = preg_match('/^(video|audio)\//', $mimeType) || in_array($ext, ['mp4','webm','ogv','mov','mp3','ogg','oga','wav','flac','aac','m4a']); $sizeLimit = $isPdf ? self::MAX_PDF_SIZE : ($isAv ? self::MAX_AV_SIZE : self::MAX_FILE_SIZE); if ($uploads['size'][$i] > $sizeLimit) { $limitMb = round($sizeLimit / 1024 / 1024); $sizeMb = round($uploads['size'][$i] / 1024 / 1024); $this->fileWarnings[] = "Fichier TFE « {$uploads['name'][$i]} » ignoré : trop volumineux ($sizeMb MB, max $limitMb MB)."; error_log("ThesisFileHandler: TFE file too large {$uploads['name'][$i]} (" . $sizeMb . ' MB), skipping'); continue; } $files[] = [ 'index' => $i, 'mimeType' => $mimeType, 'ext' => $ext, 'size' => $uploads['size'][$i], 'tmpName' => $uploads['tmp_name'][$i], 'origName' => $uploads['name'][$i], 'label' => trim($fileLabels[$i] ?? ''), 'sortOrder' => isset($fileOrders[$i]) ? (int)$fileOrders[$i] : null, 'hierarchy' => $this->tfeHierarchyRank($mimeType, $ext), 'fileType' => $this->detectFileType($mimeType, $ext), ]; } // Sort by hierarchy rank usort($files, fn($a, $b) => $a['hierarchy'] - $b['hierarchy']); // Assign contiguous TFE_XX numbers $videoCount = 0; $vttQueue = []; // First pass: count videos to know where to insert VTTs foreach ($files as $f) { if ($f['fileType'] === 'video') { $videoCount++; } } $num = $startNum; $vttIdx = 0; foreach ($files as $f) { // VTT files are inserted right after their corresponding video if ($f['fileType'] === 'caption') { $vttQueue[] = $f; continue; } if ($f['fileType'] === 'video') { // Write the video file $this->writeTfeFile($f, $thesisId, $dir, $folderPath, $filePrefix, $num); $num++; // Write any waiting VTT for this video if (!empty($vttQueue)) { $vtt = array_shift($vttQueue); $this->writeTfeFile($vtt, $thesisId, $dir, $folderPath, $filePrefix, $num); $num++; } } else { $this->writeTfeFile($f, $thesisId, $dir, $folderPath, $filePrefix, $num); $num++; } } // Any remaining VTTs (orphaned — write at end) foreach ($vttQueue as $vtt) { $this->writeTfeFile($vtt, $thesisId, $dir, $folderPath, $filePrefix, $num); $num++; } return $num; } /** * Extract a flat $_FILES-style sub-array from PHP's nested upload structure. * * PHP normalises FormData names like "queue_file[tfe][]" into: * $_FILES['queue_file']['name']['tfe'] = [file1, file2, ...] * $_FILES['queue_file']['tmp_name']['tfe'] = [/tmp/..., /tmp/...] * This helper extracts ['tfe'] → ['name' => [...], 'tmp_name' => [...], ...] */ protected function extractFilesSubArray(array $parent, string $key): ?array { if (!isset($parent['name'][$key]) || !is_array($parent['name'][$key])) { return null; } $result = []; foreach (['name', 'tmp_name', 'error', 'size', 'type'] as $field) { $result[$field] = $parent[$field][$key] ?? []; } return $result; } /** * Process TFE file uploads from client-side JS queue (FormData). * * Files arrive via $_FILES['queue_file'] with PHP-nested key extraction. * They are written in the order the user specified (preserved in FormData order). * * @param int $thesisId * @param array|null $uploads Flat $_FILES-style array with 'name', 'tmp_name', etc. * @param string $folderPath * @param string $filePrefix * @param int $startNum */ protected function handleTfeQueueFiles(int $thesisId, ?array $uploads, string $folderPath, string $filePrefix, int $startNum = 1): int { if (!$uploads || !is_array($uploads['name'] ?? null)) { return $startNum; } $dir = STORAGE_ROOT . '/' . $folderPath; if (!is_dir($dir)) { mkdir($dir, 0755, true); } // Build entries, validate, and classify $files = []; $vttQueue = []; $count = count($uploads['name']); for ($i = 0; $i < $count; $i++) { if (($uploads['error'][$i] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { 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'; } if ($mimeType === 'application/octet-stream' && !in_array($ext, self::ALLOWED_EXTENSIONS, true)) { $this->fileWarnings[] = "Fichier « {$uploads['name'][$i]} » ignoré : format .$ext non accepté."; error_log("ThesisFileHandler: queue file extension not allowed {$uploads['name'][$i]} ($ext), skipping"); continue; } if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true) && !in_array($ext, self::ALLOWED_EXTENSIONS, true)) { $this->fileWarnings[] = "Fichier « {$uploads['name'][$i]} » ignoré : type non accepté ($mimeType)."; error_log("ThesisFileHandler: invalid queue file type {$uploads['name'][$i]} ($mimeType / $ext), skipping"); continue; } $isPdf = ($mimeType === 'application/pdf' || $ext === 'pdf'); $isAv = preg_match('/^(video|audio)\//', $mimeType) || in_array($ext, ['mp4','webm','ogv','mov','mp3','ogg','oga','wav','flac','aac','m4a']); $sizeLimit = $isPdf ? self::MAX_PDF_SIZE : ($isAv ? self::MAX_AV_SIZE : self::MAX_FILE_SIZE); if ($uploads['size'][$i] > $sizeLimit) { $limitMb = round($sizeLimit / 1024 / 1024); $sizeMb = round($uploads['size'][$i] / 1024 / 1024); $this->fileWarnings[] = "Fichier « {$uploads['name'][$i]} » ignoré : trop volumineux ($sizeMb MB, max $limitMb MB)."; error_log("ThesisFileHandler: queue file too large {$uploads['name'][$i]} (" . $sizeMb . ' MB), skipping'); continue; } $entry = [ 'mimeType' => $mimeType, 'ext' => $ext, 'size' => $uploads['size'][$i], 'tmpName' => $uploads['tmp_name'][$i], 'origName' => $uploads['name'][$i], 'label' => '', 'sortOrder' => null, 'fileType' => $this->detectFileType($mimeType, $ext), ]; // VTTs are collected and paired with their preceding video if ($entry['fileType'] === 'caption') { $vttQueue[] = $entry; } else { $files[] = $entry; } } // Files are written in the order they arrived (user-specified via JS queue order). // VTTs are inserted immediately after the video they follow. $num = $startNum; $videosSeen = 0; foreach ($files as $f) { $this->writeTfeFile($f, $thesisId, $dir, $folderPath, $filePrefix, $num); $num++; if ($f['fileType'] === 'video' && isset($vttQueue[$videosSeen])) { $this->writeTfeFile($vttQueue[$videosSeen], $thesisId, $dir, $folderPath, $filePrefix, $num); $num++; $videosSeen++; } elseif ($f['fileType'] === 'video') { $videosSeen++; } } // Orphaned VTTs (no preceding video in this batch) for ($i = $videosSeen; $i < count($vttQueue); $i++) { $this->writeTfeFile($vttQueue[$i], $thesisId, $dir, $folderPath, $filePrefix, $num); $num++; } return $num; } /** * Process annexe file uploads from client-side JS queue (FormData). * * Files arrive via $_FILES['queue_file']['annexe']. * * @param int $thesisId * @param array|null $uploads $_FILES['queue_file']['annexe']-style array * @param string $folderPath * @param string $filePrefix */ protected function handleAnnexeQueueFiles(int $thesisId, ?array $uploads, string $folderPath, string $filePrefix): void { if (!$uploads || !is_array($uploads['name'] ?? null)) { return; } $dir = STORAGE_ROOT . '/' . $folderPath; if (!is_dir($dir)) { mkdir($dir, 0755, true); } $num = 1; $count = count($uploads['name']); for ($i = 0; $i < $count; $i++) { if (($uploads['error'][$i] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { 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'; } if ($mimeType === 'application/octet-stream' && !in_array($ext, self::ALLOWED_EXTENSIONS, true)) { $this->fileWarnings[] = "Annexe « {$uploads['name'][$i]} » ignorée : format .$ext non accepté."; error_log("ThesisFileHandler: queue annexe extension not allowed {$uploads['name'][$i]} ($ext), skipping"); continue; } if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true) && !in_array($ext, self::ALLOWED_EXTENSIONS, true)) { $this->fileWarnings[] = "Annexe « {$uploads['name'][$i]} » ignorée : type non accepté ($mimeType)."; error_log("ThesisFileHandler: invalid queue annexe type {$uploads['name'][$i]} ($mimeType / $ext), skipping"); continue; } // Annexes: PDF max 100 MB, everything else max 500 MB $isPdf = ($mimeType === 'application/pdf' || $ext === 'pdf'); $sizeLimit = $isPdf ? self::MAX_PDF_SIZE : self::MAX_FILE_SIZE; if ($uploads['size'][$i] > $sizeLimit) { $limitMb = round($sizeLimit / 1024 / 1024); $sizeMb = round($uploads['size'][$i] / 1024 / 1024); $this->fileWarnings[] = "Annexe « {$uploads['name'][$i]} » ignorée : trop volumineuse ($sizeMb MB, max $limitMb MB)."; error_log("ThesisFileHandler: queue annexe too large {$uploads['name'][$i]} (" . $sizeMb . ' MB), skipping'); continue; } $padded = sprintf('%02d', $num); $targetName = $filePrefix . '_ANNEXE_' . $padded . '.' . $ext; $targetPath = $dir . $targetName; if (!move_uploaded_file($uploads['tmp_name'][$i], $targetPath)) { error_log("ThesisFileHandler: failed to move queue annexe {$uploads['name'][$i]}"); continue; } chmod($targetPath, 0644); $relPath = $folderPath . $targetName; $this->db->insertThesisFile( $thesisId, 'annex', $relPath, basename($uploads['name'][$i]), $uploads['size'][$i], $mimeType, null, null ); error_log("ThesisFileHandler: annexe (queue) moved → $targetName"); $num++; } } /** * Process annex file uploads. * * @param int $thesisId * @param array|null $uploads Multi-file $_FILES entry. * @param string $folderPath Relative path from STORAGE_ROOT to the thesis folder. * @param string $filePrefix The shared file prefix. * @param array $post $_POST for per-file labels/orders. */ protected function handleAnnexeFiles(int $thesisId, ?array $uploads, string $folderPath, string $filePrefix, array $post = []): void { if (!$uploads || !is_array($uploads['name'] ?? null)) { return; } $dir = STORAGE_ROOT . '/' . $folderPath; if (!is_dir($dir)) { mkdir($dir, 0755, true); } $fileLabels = $post['annexe_labels'] ?? []; $fileOrders = $post['annexe_orders'] ?? []; $num = 1; $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("ThesisFileHandler: annexe 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'; } if ($mimeType === 'application/octet-stream' && !in_array($ext, self::ALLOWED_EXTENSIONS, true)) { $this->fileWarnings[] = "Annexe « {$uploads['name'][$i]} » ignorée : format .$ext non accepté."; error_log("ThesisFileHandler: annexe extension not allowed {$uploads['name'][$i]} ($ext), skipping"); continue; } if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true) && !in_array($ext, self::ALLOWED_EXTENSIONS, true)) { $this->fileWarnings[] = "Annexe « {$uploads['name'][$i]} » ignorée : type non accepté ($mimeType)."; error_log("ThesisFileHandler: invalid annexe type {$uploads['name'][$i]} ($mimeType / $ext), skipping"); continue; } $isPdf = ($mimeType === 'application/pdf' || $ext === 'pdf'); $isAv = preg_match('/^(video|audio)\//', $mimeType) || in_array($ext, ['mp4','webm','ogv','mov','mp3','ogg','oga','wav','flac','aac','m4a']); $sizeLimit = $isPdf ? self::MAX_PDF_SIZE : ($isAv ? self::MAX_AV_SIZE : self::MAX_FILE_SIZE); if ($uploads['size'][$i] > $sizeLimit) { $limitMb = round($sizeLimit / 1024 / 1024); $sizeMb = round($uploads['size'][$i] / 1024 / 1024); $this->fileWarnings[] = "Annexe « {$uploads['name'][$i]} » ignorée : trop volumineuse ($sizeMb MB, max $limitMb MB)."; error_log("ThesisFileHandler: annexe too large {$uploads['name'][$i]} (" . $sizeMb . " MB), skipping"); continue; } $padded = sprintf('%02d', $num); $targetName = $filePrefix . '_ANNEXE_' . $padded . '.' . $ext; $targetPath = $dir . $targetName; if (!move_uploaded_file($uploads['tmp_name'][$i], $targetPath)) { error_log("ThesisFileHandler: failed to move annexe {$uploads['name'][$i]}"); continue; } chmod($targetPath, 0644); $relPath = $folderPath . $targetName; $label = trim($fileLabels[$i] ?? ''); $sortOrder = isset($fileOrders[$i]) ? (int)$fileOrders[$i] : null; $fileType = ($ext === 'vtt' || $mimeType === 'text/vtt') ? 'caption' : 'annex'; $this->db->insertThesisFile( $thesisId, $fileType, $relPath, basename($uploads['name'][$i]), $uploads['size'][$i], $mimeType, $label !== '' ? $label : null, $sortOrder ); error_log("ThesisFileHandler: annexe uploaded → $targetName"); $num++; } } // ── Helpers ────────────────────────────────────────────────────────────── /** * Write a single TFE file to disk and record in DB. */ protected function writeTfeFile(array $f, int $thesisId, string $dir, string $folderPath, string $filePrefix, int $num): void { $padded = sprintf('%02d', $num); $targetName = $filePrefix . '_TFE_' . $padded . '.' . $f['ext']; $targetPath = $dir . $targetName; if (!move_uploaded_file($f['tmpName'], $targetPath)) { error_log("ThesisFileHandler: failed to move TFE {$f['origName']}"); return; } chmod($targetPath, 0644); $relPath = $folderPath . $targetName; $this->db->insertThesisFile( $thesisId, $f['fileType'], $relPath, basename($f['origName']), $f['size'], $f['mimeType'], $f['label'] !== '' ? $f['label'] : null, $f['sortOrder'] ); error_log("ThesisFileHandler: TFE uploaded → $targetName ({$f['fileType']})"); } /** * Assign a hierarchy rank for sorting TFE files. * Lower = earlier in the sequence. * * 0 = PDF * 1 = video * 2 = audio * 3 = caption (VTT) * 4 = image * 5 = archive / other */ private function tfeHierarchyRank(string $mimeType, string $ext): int { if ($mimeType === 'application/pdf' || $ext === 'pdf') { return 0; } if ($ext === 'vtt' || $mimeType === 'text/vtt') { return 3; } if (str_starts_with($mimeType, 'video/') || in_array($ext, ['mp4','webm','ogv','mov'], true)) { return 1; } if (str_starts_with($mimeType, 'audio/') || in_array($ext, ['mp3','ogg','oga','wav','flac','aac','m4a'], true)) { return 2; } if (str_starts_with($mimeType, 'image/') || in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) { return 4; } return 5; } /** * Determine the logical file_type from MIME type / extension. */ protected function detectFileType(string $mimeType, string $ext): string { if ($ext === 'vtt' || $mimeType === 'text/vtt') { return 'caption'; } if ($mimeType === 'application/pdf' || $ext === 'pdf') { return 'main'; } if (str_starts_with($mimeType, 'video/') || in_array($ext, ['mp4','webm','ogv','mov'], true)) { return 'video'; } if (str_starts_with($mimeType, 'audio/') || in_array($ext, ['mp3','ogg','oga','wav','flac','aac','m4a'], true)) { return 'audio'; } if (str_starts_with($mimeType, 'image/') || in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) { return 'image'; } return 'other'; } // ── String / filesystem helpers ────────────────────────────────────────── /** * Generate a filesystem-safe author slug from comma-separated author names. * Sorted alphabetically, joined with dash, accent-stripped, uppercase. */ protected function generateAuthorSlug(string $authorNames): string { $names = array_values(array_filter(array_map('trim', explode(',', $authorNames)), fn($n) => $n !== '')); sort($names, SORT_NATURAL); $joined = implode('-', $names); $normalized = $joined; if (function_exists('iconv')) { $normalized = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $normalized); } $accents = [ 'à' => 'a', 'â' => 'a', 'ä' => 'a', 'é' => 'e', 'è' => 'e', 'ê' => 'e', 'ë' => 'e', 'î' => 'i', 'ï' => 'i', 'ô' => 'o', 'ö' => 'o', 'ù' => 'u', 'û' => 'u', 'ü' => 'u', 'ç' => 'c', 'À' => 'A', 'Â' => 'A', 'Ä' => 'A', 'É' => 'E', 'È' => 'E', 'Ê' => 'E', 'Ë' => 'E', 'Î' => 'I', 'Ï' => 'I', 'Ô' => 'O', 'Ö' => 'O', 'Ù' => 'U', 'Û' => 'U', 'Ü' => 'U', 'Ç' => 'C', ]; $normalized = strtr($normalized, $accents); $slug = preg_replace('/[^A-Za-z0-9]+/', '_', $normalized); $slug = trim($slug, '_'); $slug = strtoupper($slug); if ($slug === '') { $slug = 'AUTHOR'; } return $slug; } /** * Generate a filesystem-safe title slug (truncated to 60 chars). */ protected function generateTitleSlug(string $title): string { $normalized = $title; if (function_exists('iconv')) { $normalized = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $normalized); } $accents = [ 'à' => 'a', 'â' => 'a', 'ä' => 'a', 'æ' => 'ae', 'é' => 'e', 'è' => 'e', 'ê' => 'e', 'ë' => 'e', 'î' => 'i', 'ï' => 'i', 'ô' => 'o', 'ö' => 'o', 'œ' => 'oe', 'ù' => 'u', 'û' => 'u', 'ü' => 'u', 'ç' => 'c', 'À' => 'A', 'Â' => 'A', 'Ä' => 'A', 'Æ' => 'AE', 'É' => 'E', 'È' => 'E', 'Ê' => 'E', 'Ë' => 'E', 'Î' => 'I', 'Ï' => 'I', 'Ô' => 'O', 'Ö' => 'O', 'Œ' => 'OE', 'Ù' => 'U', 'Û' => 'U', 'Ü' => 'U', 'Ç' => 'C', "'" => '', '"' => '', '«' => '', '»' => '', ]; $normalized = strtr($normalized, $accents); $slug = preg_replace('/[^A-Za-z0-9]+/', '_', $normalized); $slug = trim($slug, '_'); $slug = strtoupper($slug); if (strlen($slug) > 60) { $slug = substr($slug, 0, 60); } if ($slug === '') { $slug = 'TFE'; } return $slug; } /** * Build the folder path and file prefix for a thesis. * * Folder: {objet}/{YYYY}/{YYYY}_{AUTHORS}_{TITLE_SLUG}/ * Prefix: {YYYY}_{AUTHORS}_{TITLE_SLUG} * * @return array{folderPath: string, filePrefix: string, folderName: string} */ protected function buildThesisFolder(int $year, string $authorsStr, string $title, string $objet = 'tfe'): array { $authorSlug = $this->generateAuthorSlug($authorsStr); $titleSlug = $this->generateTitleSlug($title); $folderName = $year . '_' . $authorSlug . '_' . $titleSlug; $filePrefix = $folderName; $folderPath = $objet . '/' . $year . '/' . $folderName . '/'; return [ 'folderPath' => $folderPath, 'filePrefix' => $filePrefix, 'folderName' => $folderName, ]; } /** * Ensure the thesis folder exists (create if needed). * If the folder already exists, append a numeric suffix. */ protected function ensureUniqueFolder(string $folderPath): string { $baseDir = STORAGE_ROOT . '/' . dirname($folderPath) . '/'; $candidate = basename(rtrim($folderPath, '/')); if (!is_dir($baseDir . $candidate)) { mkdir($baseDir . $candidate, 0755, true); return $candidate; } $suffix = 1; while (is_dir($baseDir . $candidate . '_' . $suffix)) { $suffix++; } $candidate .= '_' . $suffix; mkdir($baseDir . $candidate, 0755, true); return $candidate; } // ── FilePond async file processing ────────────────────────────────────── /** * Process a single file from the FilePond async flow (cover, note_intention). * * Unlike queue files, these arrive as a single file_id in $post['queue_file'][$queueKey] * (which will be a string, not an array — PHP normalizes single-value inputs). */ protected function handleFilePondSingleFile( int $thesisId, array $post, string $queueKey, string $folderPath, string $filePrefix ): void { $raw = $post['queue_file'][$queueKey] ?? null; if ($raw === null || (is_array($raw) && empty($raw))) { return; } // PHP may send a single value as scalar or single-element array $fileId = is_array($raw) ? $raw[0] : $raw; $fileId = trim($fileId); if ($fileId === '' || !preg_match('/^[a-f0-9]{32}$/', $fileId)) { return; } $tmpDir = STORAGE_ROOT . '/tmp/filepond/' . $fileId; $manifestPath = $tmpDir . '/manifest.json'; if (!is_dir($tmpDir) || !file_exists($manifestPath)) { error_log("ThesisFileHandler: single file_id $fileId not found in tmp/"); return; } $manifest = json_decode(file_get_contents($manifestPath), true); if (!is_array($manifest)) { error_log("ThesisFileHandler: invalid manifest for $fileId"); return; } // Find the actual file $actualFile = null; $dh = opendir($tmpDir); while (($entry = readdir($dh)) !== false) { if ($entry === '.' || $entry === '..' || $entry === 'manifest.json') { continue; } $actualFile = $tmpDir . '/' . $entry; break; } closedir($dh); if ($actualFile === null || !file_exists($actualFile)) { error_log("ThesisFileHandler: no file found in tmp dir for $fileId"); return; } $dir = STORAGE_ROOT . '/' . $folderPath; if (!is_dir($dir)) { mkdir($dir, 0755, true); } $ext = $manifest['ext']; $mimeType = $manifest['mime']; $originalName = $manifest['original_name']; $size = $manifest['size']; if ($queueKey === 'cover') { $targetName = $filePrefix . '_COUVERTURE.' . $ext; $fileType = 'cover'; } elseif ($queueKey === 'note_intention') { $targetName = $filePrefix . '_NOTE_INTENTION.pdf'; $fileType = 'note_intention'; } else { error_log("ThesisFileHandler: unknown single file queue key $queueKey"); return; } $targetPath = $dir . $targetName; if (!rename($actualFile, $targetPath)) { error_log("ThesisFileHandler: failed to move $queueKey from tmp"); return; } chmod($targetPath, 0644); $relPath = $folderPath . $targetName; $this->db->insertThesisFile( $thesisId, $fileType, $relPath, $originalName, $size, $mimeType ); error_log("ThesisFileHandler: $queueKey uploaded (filepond) → $targetName"); $this->cleanupFilePondTmp($fileId); } /** * Process queue files that were uploaded asynchronously via FilePond. * * Instead of receiving $_FILES, this method reads ad-hoc `queue_file[tfe][]` * style hidden inputs containing opaque file_ids. Each file_id maps to * a directory under tmp/filepond/ with a manifest.json and the actual file. * * This is the new path (Step 2 of the refactor). The old $_FILES path * (handleTfeQueueFiles) is kept for backwards compatibility and can be * removed once the new flow is stable. * * @param int $thesisId * @param array $post $_POST array (contains queue_file[tfe][] etc.) * @param string $queueKey Queue sub-key ('tfe', 'video', 'audio', 'annexe', 'peertube_video', 'peertube_audio') * @param string $folderPath Relative path to the thesis folder. * @param string $filePrefix The shared file prefix. * @param int $startNum Starting number for TFE_XX (only used for tfe/video/audio queues). * @param string|null $progressToken Optional progress token for PeerTube uploads. * @return int The next TFE number (for tfe/video/audio queues). */ protected function handleFilePondQueueFiles( int $thesisId, array $post, string $queueKey, string $folderPath, string $filePrefix, int $startNum = 1, ?string $progressToken = null ): int { $fileIds = $post['queue_file'][$queueKey] ?? []; if (!is_array($fileIds) || empty($fileIds)) { return $startNum; } $dir = STORAGE_ROOT . '/' . $folderPath; if (!is_dir($dir)) { mkdir($dir, 0755, true); } $isPeerTube = false; // peerTube handled inline per fileId (peertube: prefix) $isAnnexe = $queueKey === 'annexe'; $isTfeLike = in_array($queueKey, ['tfe', 'video', 'audio'], true); // ── Collect files from tmp/ ────────────────────────────────────────── $files = []; $vttQueue = []; foreach ($fileIds as $fileId) { $fileId = trim($fileId); if ($fileId === '') { continue; } // PeerTube files have been uploaded already; just insert DB row // Format: peertube:video:UUID or peertube:audio:UUID if (str_starts_with($fileId, 'peertube:')) { $parts = explode(':', $fileId, 3); $fileType = ($parts[1] ?? '') === 'video' ? 'video' : 'audio'; $uuid = $parts[2] ?? ''; $storedPath = 'peertube_ids:' . $uuid; $this->db->insertThesisFile( $thesisId, $fileType, $storedPath, $uuid . ' (PeerTube)', 0, $fileType === 'video' ? 'video/mp4' : 'audio/mpeg', null, null ); error_log("ThesisFileHandler: PeerTube file associated → $uuid"); continue; } // Regular tmp files (hex file_id) if (!preg_match('/^[a-f0-9]{32}$/', $fileId)) { continue; } $tmpDir = STORAGE_ROOT . '/tmp/filepond/' . $fileId; $manifestPath = $tmpDir . '/manifest.json'; if (!is_dir($tmpDir) || !file_exists($manifestPath)) { error_log("ThesisFileHandler: file_id $fileId not found in tmp/"); continue; } $manifest = json_decode(file_get_contents($manifestPath), true); if (!is_array($manifest)) { error_log("ThesisFileHandler: invalid manifest for $fileId"); continue; } // Find the actual file in the tmp dir (there should be exactly one non-manifest file) $actualFile = null; $dh = opendir($tmpDir); while (($entry = readdir($dh)) !== false) { if ($entry === '.' || $entry === '..' || $entry === 'manifest.json') { continue; } $actualFile = $tmpDir . '/' . $entry; break; } closedir($dh); if ($actualFile === null || !file_exists($actualFile)) { error_log("ThesisFileHandler: no file found in tmp dir for $fileId"); continue; } $entry = [ 'fileId' => $fileId, 'tmpDir' => $tmpDir, 'mimeType' => $manifest['mime'], 'ext' => $manifest['ext'], 'size' => $manifest['size'], 'origName' => $manifest['original_name'], 'label' => '', 'sortOrder' => null, 'fileType' => $this->detectFileType($manifest['mime'], $manifest['ext']), 'actualFile' => $actualFile, ]; if ($isTfeLike && $entry['fileType'] === 'caption') { $vttQueue[] = $entry; } else { $files[] = $entry; } } // ── Handle annexe queue ────────────────────────────────────────────── if ($isAnnexe) { $num = 1; foreach ($files as $f) { $padded = sprintf('%02d', $num); $targetName = $filePrefix . '_ANNEXE_' . $padded . '.' . $f['ext']; $targetPath = $dir . $targetName; if (!rename($f['actualFile'], $targetPath)) { error_log("ThesisFileHandler: failed to move annexe {$f['origName']}"); continue; } chmod($targetPath, 0644); $relPath = $folderPath . $targetName; $this->db->insertThesisFile( $thesisId, 'annex', $relPath, basename($f['origName']), $f['size'], $f['mimeType'], null, null ); error_log("ThesisFileHandler: annexe (filepond) → $targetName"); $num++; $this->cleanupFilePondTmp($f['fileId']); } return $startNum; } // ── Handle TFE/video/audio queues ──────────────────────────────────── // Sort by hierarchy rank (PDF → video → audio → caption → image → archive) $filesWithRank = []; foreach ($files as $f) { $f['hierarchy'] = $this->tfeHierarchyRank($f['mimeType'], $f['ext']); $filesWithRank[] = $f; } usort($filesWithRank, fn($a, $b) => $a['hierarchy'] - $b['hierarchy']); $num = $startNum; $vttIdx = 0; $videoCount = 0; foreach ($filesWithRank as $f) { if ($f['fileType'] === 'video') { $videoCount++; } } foreach ($filesWithRank as $f) { if ($f['fileType'] === 'caption') { $vttQueue[] = $f; continue; } if ($f['fileType'] === 'video') { $this->writeTfeFileFromTmp($f, $thesisId, $dir, $folderPath, $filePrefix, $num); $num++; if (!empty($vttQueue)) { $vtt = array_shift($vttQueue); $this->writeTfeFileFromTmp($vtt, $thesisId, $dir, $folderPath, $filePrefix, $num); $num++; } } else { $this->writeTfeFileFromTmp($f, $thesisId, $dir, $folderPath, $filePrefix, $num); $num++; } } // Orphaned VTTs foreach ($vttQueue as $vtt) { $this->writeTfeFileFromTmp($vtt, $thesisId, $dir, $folderPath, $filePrefix, $num); $num++; } return $num; } /** * Write a single TFE file from tmp/filepond to the thesis directory. */ private function writeTfeFileFromTmp(array $f, int $thesisId, string $dir, string $folderPath, string $filePrefix, int $num): void { $padded = sprintf('%02d', $num); $targetName = $filePrefix . '_TFE_' . $padded . '.' . $f['ext']; $targetPath = $dir . $targetName; if (!rename($f['actualFile'], $targetPath)) { error_log("ThesisFileHandler: failed to move TFE {$f['origName']} from tmp"); return; } chmod($targetPath, 0644); $relPath = $folderPath . $targetName; $this->db->insertThesisFile( $thesisId, $f['fileType'], $relPath, basename($f['origName']), $f['size'], $f['mimeType'], $f['label'] !== '' ? $f['label'] : null, $f['sortOrder'] ); error_log("ThesisFileHandler: TFE uploaded (filepond) → $targetName ({$f['fileType']})"); $this->cleanupFilePondTmp($f['fileId']); } /** * Clean up a tmp/filepond directory after processing. */ private function cleanupFilePondTmp(string $fileId): void { $tmpDir = STORAGE_ROOT . '/tmp/filepond/' . $fileId; if (!is_dir($tmpDir)) { return; } $it = new \RecursiveDirectoryIterator($tmpDir, \RecursiveDirectoryIterator::SKIP_DOTS); $files_it = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST); foreach ($files_it as $file) { if ($file->isDir()) { @rmdir($file->getRealPath()); } else { @unlink($file->getRealPath()); } } @rmdir($tmpDir); } /** * Delete a thesis_files row, moving the on-disk file to the trash directory * instead of unlinking it. Website URLs and PeerTube references are only * removed from the database (no disk file). */ protected function deleteThesisFileToTrash(int $fileId, int $thesisId): void { if ($fileId <= 0) { return; } // Fetch the file row to get the path before deleting the DB record $existingFiles = $this->db->getThesisFiles($thesisId); $fileRow = null; foreach ($existingFiles as $f) { if ((int)$f['id'] === $fileId) { $fileRow = $f; break; } } $filePath = $this->db->deleteThesisFile($fileId, $thesisId); if ($filePath && defined('STORAGE_ROOT')) { // Skip filesystem for website URLs and PeerTube IDs (not real files) if (str_starts_with($filePath, 'http://') || str_starts_with($filePath, 'https://')) { return; } if (str_starts_with($filePath, 'peertube_ids:')) { return; } $abs = STORAGE_ROOT . '/' . $filePath; if (file_exists($abs)) { $trashDir = STORAGE_ROOT . '/tmp/_trash'; if (!is_dir($trashDir)) { mkdir($trashDir, 0755, true); } // Keep original filename structure for traceability $trashName = $fileId . '_' . basename($filePath); $trashPath = $trashDir . '/' . $trashName; if (!rename($abs, $trashPath)) { // Fallback to unlink if rename fails (cross-device) @copy($abs, $trashPath); @unlink($abs); } error_log("ThesisFileHandler: file \$fileId moved to trash → \$trashName"); } } } }