db = $db; } public static function create(): self { require_once APP_ROOT . '/src/Database.php'; return new self(Database::getInstance()); } // ── Database export ────────────────────────────────────────────────── /** * Return the absolute path of the live database file. */ public function getDatabasePath(): string { return $this->db->getDatabasePath(); } // ── Files export ──────────────────────────────────────────────────── /** * Fetch thesis file records with their thesis identifier. * * @param int[] $thesisIds Optional filter by thesis IDs. * @return list */ public function getAllThesisFiles(array $thesisIds = []): array { return $this->db->getAllThesisFilesForExport($thesisIds); } /** * Get the configured PeerTube instance URL, or empty string if not configured. */ private function getPeerTubeInstanceUrl(): string { require_once APP_ROOT . '/src/PeerTubeService.php'; $settings = PeerTubeService::getSettings($this->db); return $settings['instance_url']; } /** * Build a JSON manifest describing every thesis and its files. * * The manifest maps identifier → { title, files: [{type, path, name, size, mime, hash, label}] } * and is used on restore to re-link files to DB records. * * @param int[] $thesisIds Optional filter by thesis IDs. * @return array */ public function buildExportManifest(array $thesisIds = []): array { $files = $this->getAllThesisFiles($thesisIds); $theses = $this->db->getAllThesesForExport($thesisIds); // Index theses by id for O(1) lookup $byId = []; foreach ($theses as $t) { $byId[(int) $t['id']] = $t; } $entries = []; foreach ($files as $f) { $tid = (int) $f['thesis_id']; $t = $byId[$tid] ?? null; $key = $t['identifier'] ?? ('id_' . $tid); if (!isset($entries[$key])) { $entries[$key] = [ 'id' => $tid, 'identifier' => $t['identifier'] ?? null, 'title' => $t['title'] ?? '(inconnu)', 'year' => $t['year'] ?? 0, 'files' => [], ]; } $entries[$key]['files'][] = [ 'type' => $f['file_type'], 'path' => $f['file_path'], 'name' => $f['file_name'], 'size' => $f['file_size'], 'mime' => $f['mime_type'], 'hash' => $f['file_hash'] ?? null, 'label' => $f['display_label'] ?? null, 'sort_order' => (int) $f['sort_order'], ]; } return [ 'exported_at' => date('c'), 'db_file' => basename($this->db->getDatabasePath()), 'total_theses' => count($entries), 'total_files' => count($files), 'theses' => $entries, ]; } /** * Create a zip archive containing all thesis files under a files/ * directory and a manifest.json at the root. * * Returns the path to the temporary zip file. Caller is responsible * for unlink() after streaming. * * @param int[] $thesisIds Optional filter by thesis IDs. * @param string|null $baseDir Base directory path for files inside the zip. * Defaults to "files" (so files are at "files/theses/..."). * @return string Absolute path to the generated zip file. * @throws Exception if zip creation fails. */ public function createExportZip(array $thesisIds = [], ?string $baseDir = null): string { $baseDir ??= 'files'; $storageRoot = defined('STORAGE_ROOT') ? STORAGE_ROOT : APP_ROOT . '/storage'; $files = $this->getAllThesisFiles($thesisIds); $manifest = $this->buildExportManifest($thesisIds); $tmpPath = tempnam(sys_get_temp_dir(), 'xamxam-export-'); if ($tmpPath === false) { throw new Exception('Impossible de créer un fichier temporaire.'); } // tempnam creates a regular file; we want a .zip file instead. unlink($tmpPath); $tmpPath .= '.zip'; $zip = new ZipArchive(); if ($zip->open($tmpPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { throw new Exception('Impossible de créer l\'archive ZIP.'); } // Add manifest.json at the root $zip->addFromString( 'manifest.json', json_encode($manifest, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) ); // Add every thesis file under files/ $addedCount = 0; $skippedCount = 0; $peertubeLinks = []; // thesisId => [{uuid, watchUrl, fileType, label}] $ptInstanceUrl = ''; foreach ($files as $f) { $filePath = $f['file_path']; // Collect PeerTube links per-thesis if (str_starts_with($filePath, 'peertube_ids:')) { $uuid = substr($filePath, strlen('peertube_ids:')); if ($ptInstanceUrl === '') { $ptInstanceUrl = $this->getPeerTubeInstanceUrl(); } $tid = (int) $f['thesis_id']; $peertubeLinks[$tid] ??= ['dirname' => '', 'links' => []]; $peertubeLinks[$tid]['links'][] = [ 'uuid' => $uuid, 'url' => $ptInstanceUrl !== '' ? rtrim($ptInstanceUrl, '/') . '/videos/watch/' . $uuid : '', 'type' => $f['file_type'], 'label' => $f['display_label'] ?? '', 'name' => $f['file_name'], ]; $skippedCount++; // not a real file on disk continue; } // Track the directory for each thesis (for LINK.txt placement) $tid = (int) $f['thesis_id']; if (isset($peertubeLinks[$tid]) && $peertubeLinks[$tid]['dirname'] === '') { $peertubeLinks[$tid]['dirname'] = dirname($filePath); } $fullPath = $storageRoot . '/' . $filePath; if (!is_file($fullPath) || !is_readable($fullPath)) { $skippedCount++; continue; } $zipPath = $baseDir . '/' . $filePath; $zip->addFile($fullPath, $zipPath); $addedCount++; } // Add LINK.txt in each thesis directory that has PeerTube files foreach ($peertubeLinks as $info) { if ($info['dirname'] === '') { continue; } $txt = "Liens PeerTube pour ce TFE\n" . str_repeat('=', 40) . "\n\n"; foreach ($info['links'] as $link) { $label = $link['label'] !== '' ? $link['label'] : $link['name']; $txt .= ($label !== '' ? $label . "\n" : ''); $txt .= ($link['url'] !== '' ? $link['url'] . "\n" : '(instance PeerTube non configurée)' . "\n"); $txt .= "\n"; } $txtPath = $baseDir . '/' . $info['dirname'] . '/LINK.txt'; $zip->addFromString($txtPath, $txt); } // Augment manifest with PeerTube link info per thesis foreach ($manifest['theses'] as &$entry) { $tid = $entry['id']; if (isset($peertubeLinks[$tid])) { $entry['peertube_links'] = $peertubeLinks[$tid]['links']; } } unset($entry); $zip->addFromString( 'manifest.json', json_encode(array_merge($manifest, [ 'zip_skipped_count' => $skippedCount, 'zip_added_count' => $addedCount, ]), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) ); if (!$zip->close()) { @unlink($tmpPath); throw new Exception('Erreur lors de la finalisation de l\'archive ZIP.'); } return $tmpPath; } // ── CSV export ─────────────────────────────────────────────────────── /** * Column headers matching the import format. */ public const CSV_HEADERS = [ 'Identifiant', 'Titre', 'Sous-titre', 'Auteur·ice(s)', 'Contact', 'Promoteur·ice(s) interne', 'Lecteur·ice(s) interne', 'Lecteur·ice(s) externe', 'Promoteur·ice(s) ULB', 'Format(s)', 'Année', 'AP', 'Orientation', 'Finalité', 'Mots-clés', 'Synopsis', 'Contexte', 'Remarques', 'Langue', 'Autorisation', 'Licence', 'Points sur 20', 'Lien BAIU', 'CC2r', 'Exemplaire BAIU', 'Exemplaire ERG', ]; /** * Fetch all theses and their related data, then return a list of rows * shaped to match the import CSV column order. * * Uses batch queries (one per related table) to avoid N+1. * * @param int[] $thesisIds Optional filter by thesis IDs. * @return list> Each inner list has CSV_HEADERS_COUNT elements. */ public function exportAllTheses(array $thesisIds = []): array { // 1) Base thesis data (includes license_name via migration; fallback to license_type from the view) $theses = $this->db->getAllThesesForExport($thesisIds); if ($theses === []) { return []; } // 2) Load related data in batches $byThesis = function (array $rows): array { $map = []; foreach ($rows as $r) { $tid = (int) $r['thesis_id']; $map[$tid][] = $r; } return $map; }; $authors = $byThesis($this->db->getAllThesisAuthorsForExport()); $supervisors = $byThesis($this->db->getAllThesisSupervisorsForExport()); $tags = $byThesis($this->db->getAllThesisTagsForExport()); $languages = $byThesis($this->db->getAllThesisLanguagesForExport()); $formats = $byThesis($this->db->getAllThesisFormatsForExport()); // 3) Build CSV rows $csvRows = []; foreach ($theses as $t) { $tid = (int) $t['id']; // Authors + contact (first author with email) $authorList = []; $contact = ''; foreach (($authors[$tid] ?? []) as $a) { $authorList[] = $a['name']; if ($contact === '' && !empty($a['email'])) { $contact = $a['email']; } } // Supervisors — split by role $promoteursInternes = []; $lecteursInternes = []; $lecteursExternes = []; $promoteursUlb = []; foreach (($supervisors[$tid] ?? []) as $s) { $role = $s['role'] ?? ''; $isExternal = (int)($s['is_external'] ?? 0); $isUlb = (int)($s['is_ulb'] ?? 0); $name = $s['name']; if ($role === 'promoteur' && $isUlb) { $promoteursUlb[] = $name; } elseif ($role === 'promoteur') { $promoteursInternes[] = $name; } elseif ($role === 'lecteur' && $isExternal) { $lecteursExternes[] = $name; } elseif ($role === 'lecteur') { $lecteursInternes[] = $name; } else { // Legacy rows with no role: treat as promoteur interne $promoteursInternes[] = $name; } } // Tags $tagList = []; foreach (($tags[$tid] ?? []) as $tg) { $tagList[] = $tg['name']; } // Languages $langList = []; foreach (($languages[$tid] ?? []) as $l) { $langList[] = $l['name']; } // Formats $fmtList = []; foreach (($formats[$tid] ?? []) as $f) { $fmtList[] = $f['name']; } $csvRows[] = [ $t['identifier'] ?? '', $t['title'] ?? '', $t['subtitle'] ?? '', implode(', ', $authorList), $contact, implode(', ', $promoteursInternes), implode(', ', $lecteursInternes), implode(', ', $lecteursExternes), implode(', ', $promoteursUlb), implode(', ', $fmtList), $t['year'] ?? '', $t['ap_program'] ?? '', $t['orientation'] ?? '', $t['finality_type'] ?? '', implode(', ', $tagList), $t['synopsis'] ?? '', $t['context_note'] ?? '', $t['remarks'] ?? '', implode(', ', $langList), $t['access_type'] ?? '', $t['license_name'] ?? '', isset($t['jury_points']) ? (string) $t['jury_points'] : '', $t['baiu_link'] ?? '', !empty($t['cc2r']) ? 'Oui' : 'Non', !empty($t['exemplaire_baiu']) ? 'Oui' : 'Non', !empty($t['exemplaire_erg']) ? 'Oui' : 'Non', ]; } return $csvRows; } }