mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
Mirrors the mots-clé tag-search system: dropdown suggestions from existing languages via HTMX, pill display with bin-icon remove buttons, 'Créer' option for new languages. Replaces the plain text input. - New partial: templates/partials/form/language-search.php - New fragment: public/partage/language-search-fragment.php - Admin wrapper: public/admin/language-search-fragment.php - Updated language-autre-fragment to return just the required asterisk indicator - Updated both controllers to handle language_autre as array (pill-based) with backward-compatible string path - Updated edit form to compute selectedOtherLanguages from DB - Registered new route in partage/index.php - Fix CSV importer: split comma-separated language column into individual entries - Add htmx active search to admin index, title line-clamp, predefined languages only in checkboxes - Admin index: filter form now uses htmx triggers (input delay:300ms on search, change on selects) to actively search without page reload - Sort links include hx-push-url for back-button support - Added loading indicator bar (.admin-search-indicator) - Title column: line-clamp at 2 lines with overflow hidden, native title attr tooltip for full text - Language checkboxes now show only 3 predefined languages (Français, Anglais, Néerlandais); all others go via the Autre langue search component - Added Database::getPredefinedLanguages() and excluded predefined from language-search-fragment suggestions - Included hidden sort/dir inputs in table-wrap so sort state preserved across filter changes - Fix language-search: block 'Créer' for predefined languages in dropdown The 'Créer' option in the language-search dropdown now also checks against the predefined set (français, anglais, néerlandais) to avoid offering creation of languages that already exist as checkboxes.
444 lines
22 KiB
PHP
444 lines
22 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',
|
|
'narraion spéculative' => 'NS',
|
|
'narration spéculative' => 'NS',
|
|
'atelier pratiques situées' => 'APS',
|
|
'design & politique du multiple' => 'DPM',
|
|
'design et politique du multiple' => 'DPM',
|
|
'pacs' => 'PACS',
|
|
'pratique de l\'art' => 'PACS',
|
|
'pratiques artistiques & complexité scientifique' => '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);
|
|
// Normalise CSV artefacts: OUI/NON → empty (not a valid email)
|
|
if ($contact !== '' && in_array(strtoupper(trim($contact)), ['NON', 'OUI'], true)) {
|
|
$contact = '';
|
|
}
|
|
$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 (?,?,?,?,?,?,?,?,?,?,?,?,?,0,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)) {
|
|
$normalizeTag = fn(string $t): string => strtolower(trim(preg_replace('/\s+/', ' ', $t)));
|
|
$tags = array_values(array_unique(array_map($normalizeTag, explode(',', $keywordsRaw))));
|
|
$tags = array_filter($tags, fn($t) => $t !== '');
|
|
foreach (array_slice($tags, 0, 10) as $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)) {
|
|
foreach (array_map('trim', explode(',', $languageRaw)) as $langName) {
|
|
$langName = strtolower($langName);
|
|
if ($langName === '') continue;
|
|
// Lookup case-insensitively; insert if missing (stored lowercase).
|
|
$s = $importPdo->prepare("SELECT id FROM languages WHERE LOWER(name) = LOWER(?)");
|
|
$s->execute([$langName]);
|
|
$r = $s->fetch();
|
|
$langId = $r ? (int)$r['id'] : null;
|
|
if ($langId === null) {
|
|
$importPdo->prepare("INSERT INTO languages (name) VALUES (?)")->execute([$langName]);
|
|
$langId = (int)$importPdo->lastInsertId();
|
|
}
|
|
$s2 = $importPdo->prepare("INSERT INTO thesis_languages (thesis_id, language_id) VALUES (?,?)");
|
|
$s2->execute([$thesisId, $langId]);
|
|
}
|
|
}
|
|
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;
|
|
|
|
$theses = $db->getThesesList($filters, 0, 0);
|
|
$totalCount = count($theses);
|
|
$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.");
|
|
}
|
|
|
|
$isHtmx = ($_SERVER['HTTP_HX_REQUEST'] ?? '') === 'true';
|
|
$isAdmin = true; $bodyClass = 'admin-body';
|
|
if ($isHtmx) {
|
|
include APP_ROOT . '/templates/admin/index-table.php';
|
|
} else {
|
|
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';
|
|
}
|
|
|