mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 08:09:18 +02:00
405 lines
15 KiB
PHP
405 lines
15 KiB
PHP
<?php
|
|
|
|
/**
|
|
* ExportController
|
|
*
|
|
* Centralises all export logic for admin-facing data dumps.
|
|
*
|
|
* Responsibilities:
|
|
* - Export the full SQLite database as a .sqlite file download
|
|
* - Export TFE listings as CSV (the reverse of the CSV import)
|
|
*
|
|
* The class has NO output side-effects; the thin dispatcher files
|
|
* (public/admin/actions/…) perform headers and echo.
|
|
*/
|
|
class ExportController
|
|
{
|
|
private Database $db;
|
|
|
|
public function __construct(Database $db)
|
|
{
|
|
$this->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<array{id:int, thesis_id:int, identifier:?string, file_type:string,
|
|
* file_path:string, file_name:string, file_size:?int,
|
|
* mime_type:?string, description:?string, sort_order:int,
|
|
* display_label:?string, file_hash:?string}>
|
|
*/
|
|
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<list<string>> 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;
|
|
}
|
|
}
|