Files
xamxam/app/public/admin/index.php
Pontoporeia bcf683c5c1 Merge Publication fieldset's is_published checkbox into Backoffice fieldset
Move the is_published checkbox from its own separate Publication fieldset
into the Backoffice fieldset (as item #8). This means the publish control
is now present in both add and edit admin forms (previously it was only
shown in edit mode via $showPublish).
2026-05-19 00:08:05 +02:00

429 lines
21 KiB
PHP

<?php
require_once __DIR__ . "/../../bootstrap.php";
require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$pageTitle = "Liste des TFE";
require_once __DIR__ . '/../../src/Database.php';
// ── CSV Import (inline, submitted to this same page) ─────────────────────────
$importMessage = '';
$importErrors = [];
$importResults = [];
$importDone = false;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
if (!isset($_POST['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
$importErrors[] = "Erreur de sécurité : token invalide.";
} else {
$importedCount = 0;
$skippedCount = 0;
try {
$importDb = new Database();
$importPdo = $importDb->getPDO();
if ($_FILES['csv_file']['error'] !== UPLOAD_ERR_OK) {
throw new Exception("Erreur lors du téléversement du fichier.");
}
$handle = fopen($_FILES['csv_file']['tmp_name'], 'r');
if (!$handle) throw new Exception("Impossible d'ouvrir le fichier CSV.");
// Scan up to 8 rows looking for a header row with known column names.
// Build colIdx[name] → position map; fall back to positional if header not found.
// Matching uses prefix + variant logic so "contact.visible" matches "contact",
// "promoteur·ice(s)" matches "promoteur", "Licence" matches "license", etc.
$colIdx = null;
$headerRowNum = 0;
$knownHeaders = [
'identifiant', 'titre', 'sous-titre', 'auteur', 'contact',
'promoteur', 'format', 'année', 'ap', 'orientation', 'finalité',
'mots-clés', 'synopsis', 'contexte', 'remarques', 'langue',
'autorisation', 'licence', 'license', 'points', 'lien baiu',
];
for ($scan = 0; $scan < 8; $scan++) {
$hrow = fgetcsv($handle, 0, ',', '"', '');
if ($hrow === false) break;
$headerRowNum++;
$normRow = array_map(fn($s) => strtolower(trim((string)$s)), $hrow);
$hits = 0;
$map = [];
$used = [];
foreach ($knownHeaders as $h) {
foreach ($normRow as $pos => $cell) {
if (isset($used[$pos])) continue;
// Exact match
if ($cell === $h) { $hits++; $map[$h] = $pos; $used[$pos] = true; break; }
// Licence/License cross-match
if (($h === 'licence' && $cell === 'license') || ($h === 'license' && $cell === 'licence'))
{ $hits++; $map[$h] = $pos; $used[$pos] = true; break; }
// Prefix match (for compound headers like "contact.visible")
$hlen = strlen($h);
if ($hlen >= 4 && str_starts_with($cell, $h)) {
// Avoid short prefixes matching unrelated words
if ($hlen >= 5 || $cell === $h) { $hits++; $map[$h] = $pos; $used[$pos] = true; break; }
}
}
}
// Require at least 8 known headers to trust the row.
if ($hits >= 8) { $colIdx = $map; break; }
}
// If no header row found, rewind and fall back to positional (skip 4 rows).
if ($colIdx === null) {
rewind($handle);
$headerRowNum = 4;
for ($i = 0; $i < 4; $i++) fgetcsv($handle);
} else {
// Consume blank/instruction/template rows between header and data.
// Stops when a row has a non-empty identifiant column that is not a
// template placeholder (e.g. "Column1") or instruction snippet.
$idPos = $colIdx['identifiant'] ?? 0;
$peekRow = null;
while (true) {
$peek = fgetcsv($handle, 0, ',', '"', '');
if ($peek === false) break;
$headerRowNum++;
$val = trim((string)($peek[$idPos] ?? ''));
if ($val === '' || str_starts_with(strtolower($val), 'column')
|| str_contains(strtolower($val), 'éparer')) {
continue; // metadata row, skip
}
$peekRow = $peek;
break;
}
}
// Helper: get cell value by column name.
// When header was found: only use mapped column (returns '' if missing from header).
// When no header found: use positional fallback index.
$cell = function(array $row, string $name, int $fallbackPos) use ($colIdx): string {
if ($colIdx !== null) {
$pos = $colIdx[$name] ?? null;
if ($pos === null) {
// Try licence/license cross-lookup
if ($name === 'license') $pos = $colIdx['licence'] ?? null;
elseif ($name === 'licence') $pos = $colIdx['license'] ?? null;
}
if ($pos === null) return '';
} else {
$pos = $fallbackPos;
}
return isset($row[$pos]) ? trim((string)$row[$pos]) : '';
};
// Code → canonical name (legacy short-code CSV format)
$orientationCodeMap = [
'SC'=>'Sculpture','VI'=>'Vidéographie','CA'=>"Cinéma d'animation",
'IP'=>'Installation-Performance','PE'=>'Peinture','PH'=>'Photographie',
'DE'=>'Dessin','AN'=>'Arts Numériques','GR'=>'Graphisme',
'TY'=>'Typographie','DN'=>'Design Numérique','IL'=>'Illustration',
'BD'=>'Bande-Dessinée','SE'=>'Sérigraphie','GV'=>'Gravure',
];
// Alias map: normalise known variant spellings → canonical DB name.
// Keys are lowercased+stripped for comparison.
$orientationAliases = [
'arts numériques' => 'Arts Numériques',
'design numérique' => 'Design Numérique',
'installation/performance' => 'Installation-Performance',
'installation performance' => 'Installation-Performance',
'cinema d\'animation' => "Cinéma d'animation",
'cinéma d\'animation' => "Cinéma d'animation",
'bande dessinée' => 'Bande-Dessinée',
];
// Resolve an orientation string (code or full name) → canonical DB name.
$resolveOrientation = function(string $raw) use ($orientationCodeMap, $orientationAliases, $importPdo): ?int {
$raw = trim($raw);
if ($raw === '') return null;
// 1. Try legacy short code
if (isset($orientationCodeMap[$raw])) {
$raw = $orientationCodeMap[$raw];
}
// 2. Try alias map (lowercase key)
$key = strtolower($raw);
if (isset($orientationAliases[$key])) {
$raw = $orientationAliases[$key];
}
// 3. Exact DB match
$s = $importPdo->prepare("SELECT id FROM orientations WHERE name = ?");
$s->execute([$raw]);
$r = $s->fetch();
if ($r) return (int)$r['id'];
// 4. Case-insensitive DB match
$s = $importPdo->prepare("SELECT id FROM orientations WHERE LOWER(name) = LOWER(?)");
$s->execute([$raw]);
$r = $s->fetch();
return $r ? (int)$r['id'] : null;
};
// AP alias map: variant spellings → canonical code.
$apAliases = [
'l.i.e.n.s.' => 'LIENS',
'liens' => 'LIENS',
'lieux, interdisciplinarités, écologie, nécessité, systèmes'
=> 'LIENS',
'récits et expérimentation' => 'NS',
'recits et experimentation' => 'NS',
'atelier pratiques situées' => 'APS',
'design et politique du multiple' => 'DPM',
'narration spéculative' => 'NS',
'pacs' => 'PACS',
'pratique de l\'art' => 'PACS',
];
// Resolve an AP string (code or full name) → ap_program id.
$resolveAP = function(string $raw) use ($apAliases, $importPdo): ?int {
$raw = trim($raw);
if ($raw === '') return null;
// 1. Try alias map (lowercase key) → canonical code
$key = strtolower($raw);
if (isset($apAliases[$key])) {
$raw = $apAliases[$key];
}
// 2. Match by code
$s = $importPdo->prepare("SELECT id FROM ap_programs WHERE code = ?");
$s->execute([$raw]);
$r = $s->fetch();
if ($r) return (int)$r['id'];
// 3. Code match (legacy)
$s = $importPdo->prepare("SELECT id FROM ap_programs WHERE code = ?");
$s->execute([$raw]);
$r = $s->fetch();
if ($r) return (int)$r['id'];
// 4. Case-insensitive name match
$s = $importPdo->prepare("SELECT id FROM ap_programs WHERE LOWER(name) = LOWER(?)");
$s->execute([$raw]);
$r = $s->fetch();
return $r ? (int)$r['id'] : null;
};
$lineNumber = $headerRowNum;
$usePeek = isset($peekRow) && $peekRow !== null;
while (true) {
if ($usePeek) {
$row = $peekRow;
$usePeek = false;
} else {
$row = fgetcsv($handle, 0, ',', '"', '');
if ($row === false) break;
}
$lineNumber++;
if (empty($row[0]) && empty($row[1])) continue;
try {
$importDb->beginTransaction();
$identifier = $cell($row, 'identifiant', 0);
$title = $cell($row, 'titre', 1);
$subtitle = $cell($row, 'sous-titre', 2);
$authorsRaw = $cell($row, 'auteur', 3);
$contact = $cell($row, 'contact', 4);
$supervisorsRaw = $cell($row, 'promoteur', 5);
$formatsRaw = $cell($row, 'format', 6);
$yearRaw = $cell($row, 'année', 7);
$year = $yearRaw !== '' ? intval($yearRaw) : 0;
// Fallback: derive year from identifier (e.g. "2024-003" → 2024)
if ($year === 0 && $identifier !== '' && preg_match('/^(\d{4})-/', $identifier, $m)) {
$year = (int)$m[1];
}
$apCode = $cell($row, 'ap', 8);
$orientationCode = $cell($row, 'orientation', 9);
$finalityName = $cell($row, 'finalité', 10);
$keywordsRaw = $cell($row, 'mots-clés', 11);
$synopsis = $cell($row, 'synopsis', 12);
$context = $cell($row, 'contexte', 13);
$remarks = $cell($row, 'remarques', 14);
$languageRaw = $cell($row, 'langue', 15);
$access = $cell($row, 'autorisation', 16);
$license = $cell($row, 'license', 17);
$juryPointsRaw = $cell($row, 'points', 18);
$juryPoints = $juryPointsRaw !== '' ? floatval($juryPointsRaw) : null;
$baiuLink = $cell($row, 'lien baiu', 19);
if ($title === '' || $year === 0) {
$missing = [];
if ($title === '') $missing[] = 'titre';
if ($year === 0) $missing[] = 'année';
throw new Exception("Champ(s) requis manquant(s) : " . implode(', ', $missing)
. " (id=\"" . ($identifier ?: '?') . "\", titre=\"" . substr($title, 0, 80) . "\")");
}
$orientationId = $resolveOrientation($orientationCode);
$apProgramId = $resolveAP($apCode);
$finalityId = null;
if (!empty($finalityName)) {
$s = $importPdo->prepare("SELECT id FROM finality_types WHERE name = ?");
$s->execute([$finalityName]);
$r = $s->fetch(); $finalityId = $r ? $r['id'] : null;
}
$accessTypeId = null;
if (!empty($access)) {
$s = $importPdo->prepare("SELECT id FROM access_types WHERE name = ?");
$s->execute([ucfirst(strtolower($access))]);
$r = $s->fetch(); $accessTypeId = $r ? $r['id'] : null;
}
if ($accessTypeId === null) $accessTypeId = 1;
if (!empty($identifier)) {
$s = $importPdo->prepare("SELECT id FROM theses WHERE identifier = ?");
$s->execute([$identifier]);
if ($s->fetch()) {
$importDb->rollback();
$skippedCount++;
$importResults[] = ['type'=>'skip', 'msg'=>"Ligne $lineNumber: identifiant \"$identifier\" déjà présent, ignoré."];
continue;
}
}
$s = $importPdo->prepare("
INSERT INTO theses (
identifier, title, subtitle, year,
orientation_id, ap_program_id, finality_id,
synopsis, context_note, remarks,
jury_points, baiu_link,
access_type_id, is_published, submitted_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,1,CURRENT_TIMESTAMP)
");
$s->execute([
!empty($identifier) ? $identifier : null, $title,
!empty($subtitle) ? $subtitle : null, $year,
$orientationId, $apProgramId, $finalityId,
!empty($synopsis) ? $synopsis : null,
!empty($context) ? $context : null,
!empty($remarks) ? $remarks : null,
$juryPoints,
!empty($baiuLink) ? $baiuLink : null,
$accessTypeId,
]);
$thesisId = $importPdo->lastInsertId();
if (!empty($authorsRaw)) {
foreach (array_map('trim', explode(',', $authorsRaw)) as $idx => $name) {
if ($name) {
$aId = $importDb->findOrCreateAuthor($name, $idx === 0 ? $contact : null);
$s = $importPdo->prepare("INSERT INTO thesis_authors (thesis_id, author_id, author_order) VALUES (?,?,?)");
$s->execute([$thesisId, $aId, $idx + 1]);
}
}
}
if (!empty($supervisorsRaw)) {
foreach (array_map('trim', explode(',', $supervisorsRaw)) as $idx => $name) {
if ($name) {
$sId = $importDb->findOrCreateSupervisor($name);
$s = $importPdo->prepare("INSERT INTO thesis_supervisors (thesis_id, supervisor_id, supervisor_order) VALUES (?,?,?)");
$s->execute([$thesisId, $sId, $idx + 1]);
}
}
}
if (!empty($keywordsRaw)) {
foreach (array_slice(array_map('trim', explode(',', $keywordsRaw)), 0, 10) as $kw) {
if ($kw) {
$tId = $importDb->findOrCreateTag($kw);
if ($tId) {
$s = $importPdo->prepare("INSERT INTO thesis_tags (thesis_id, tag_id) VALUES (?,?)");
$s->execute([$thesisId, $tId]);
}
}
}
}
if (!empty($languageRaw)) {
$s = $importPdo->prepare("SELECT id FROM languages WHERE name = ?");
$s->execute([ucfirst(strtolower($languageRaw))]);
$r = $s->fetch();
if ($r) {
$s2 = $importPdo->prepare("INSERT INTO thesis_languages (thesis_id, language_id) VALUES (?,?)");
$s2->execute([$thesisId, $r['id']]);
}
}
if (!empty($formatsRaw)) {
foreach (array_map('trim', explode(',', $formatsRaw)) as $fmt) {
if ($fmt) {
$s = $importPdo->prepare("SELECT id FROM format_types WHERE name = ?");
$s->execute([ucfirst(strtolower($fmt))]);
$r = $s->fetch();
if ($r) {
$s2 = $importPdo->prepare("INSERT INTO thesis_formats (thesis_id, format_id) VALUES (?,?)");
$s2->execute([$thesisId, $r['id']]);
}
}
}
}
$importDb->commit();
$importedCount++;
$importResults[] = ['type'=>'ok', 'msg'=>"\"$title\" (ID: $thesisId)"];
} catch (Exception $e) {
$importDb->rollback();
$skippedCount++;
$importResults[] = ['type'=>'error', 'msg'=>"Ligne $lineNumber: " . $e->getMessage()];
error_log("Import error on line $lineNumber: " . $e->getMessage());
}
}
fclose($handle);
$importMessage = "Import terminé : $importedCount TFE importés, $skippedCount ignorés.";
$importDone = true;
} catch (Exception $e) {
$importErrors[] = $e->getMessage();
error_log("CSV import error: " . $e->getMessage());
}
}
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
try {
$db = new Database();
$searchQuery = isset($_GET['search']) ? trim($_GET['search']) : '';
$yearFilter = isset($_GET['year']) ? intval($_GET['year']) : null;
$orientationFilter = isset($_GET['orientation']) ? intval($_GET['orientation']) : null;
$apFilter = isset($_GET['ap']) ? intval($_GET['ap']) : null;
$sortCol = isset($_GET['sort']) ? trim($_GET['sort']) : 'submitted_at';
$sortDir = isset($_GET['dir']) ? trim($_GET['dir']) : 'desc';
$filters = [];
if ($searchQuery) $filters['search'] = $searchQuery;
if ($yearFilter) $filters['year'] = $yearFilter;
if ($orientationFilter) $filters['orientation'] = $orientationFilter;
if ($apFilter) $filters['ap'] = $apFilter;
$filters['sort'] = $sortCol;
$filters['dir'] = $sortDir;
$perPage = 25;
$page = isset($_GET['page']) ? max(1, intval($_GET['page'])) : 1;
$totalCount = $db->getThesesListCount($filters);
$totalPages = $totalCount > 0 ? (int) ceil($totalCount / $perPage) : 1;
$page = min($page, $totalPages);
$offset = ($page - 1) * $perPage;
$theses = $db->getThesesList($filters, $perPage, $offset);
$stats = $db->getThesesStats();
$years = $db->getAllYears();
$orientations = $db->getAllOrientations();
$apPrograms = $db->getAllAPPrograms();
} catch (Exception $e) {
error_log("Error loading theses list: " . $e->getMessage());
die("Erreur lors du chargement de la liste.");
}
$isAdmin = true; $bodyClass = 'admin-body';
require_once APP_ROOT . '/templates/head.php';
include APP_ROOT . '/templates/header.php';
include APP_ROOT . '/templates/admin/index.php';
require_once APP_ROOT . '/templates/admin/footer.php';