fix: unify CSV import/export columns, fix import JS error, fix createThesis VALUES

- Fix createThesis SQL: extra ? placeholder in VALUES (27 values for 26 columns)
- Add createThesis integration tests to catch column/value mismatches
- Add CSV_COLUMNS as single source of truth in ExportController, deriving
  csvHeaders() and positional fallback from it
- Add missing columns to export query + CSV: duration_pages, duration_minutes,
  has_annexes, license_custom, contact_visible, objet
- Add missing columns to import INSERT: license_id, license_custom, cc2r,
  exemplaire_baiu, exemplaire_erg, objet, contact_visible, duration_pages,
  duration_minutes, has_annexes
- Resolve license name → license_id during import
- Fix XamxamInitFilePonds: add file-upload-filepond.js to admin-entry.js
  so FilePond initialization code is available on the admin list page
- Add FilePond vendor CSS to admin.min.css bundle (import dialog styling)
- Generate .admin-file-hint from csvHeaders() instead of hardcoded stale list
- Use positional fallback from CSV_COLUMNS for import cell parsing
This commit is contained in:
Pontoporeia
2026-07-10 11:56:27 +02:00
parent ce936e3b71
commit 22a49f7cb6
11 changed files with 355 additions and 42 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ echo "\xEF\xBB\xBF";
$out = fopen('php://output', 'w');
// Column headers
fputcsv($out, ExportController::CSV_HEADERS, ',', '"', '');
fputcsv($out, ExportController::csvHeaders(), ',', '"', '');
// Data rows
$rows = $controller->exportAllTheses();
+102 -35
View File
@@ -37,14 +37,17 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
// 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.
require_once APP_ROOT . '/src/Controllers/ExportController.php';
$csvCols = ExportController::CSV_COLUMNS;
$csvHeaders = ExportController::csvHeaders();
$colCount = count($csvCols);
$colIdx = null;
$headerRowNum = 0;
$knownHeaders = [
'identifiant', 'titre', 'sous-titre', 'auteur', 'contact',
'promoteur', 'lecteur', 'ulb', 'externe', 'format', 'année', 'ap', 'orientation', 'finalité',
'mots-clés', 'synopsis', 'contexte', 'remarques', 'langue',
'autorisation', 'licence', 'license', 'points', 'lien baiu',
];
// Build knownHeaders from CSV_COLUMNS import_key column (index 2)
$knownHeaders = array_unique(array_column($csvCols, 2));
// Add licence/license cross-match aliases
$knownHeaders[] = 'license';
for ($scan = 0; $scan < 8; $scan++) {
$hrow = fgetcsv($handle, 0, ',', '"', '');
if ($hrow === false) break;
@@ -101,10 +104,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
}
}
// Build positional fallback from CSV_COLUMNS (keyed by import_key → position)
$fallbackPositions = [];
foreach ($csvCols as $pos => $col) {
$key = $col[2];
if (!isset($fallbackPositions[$key])) {
$fallbackPositions[$key] = $pos;
}
}
// 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 {
// When header was found: use mapped column.
// When header was found but key is missing: use positional fallback.
// When no header found: use positional fallback.
$cell = function(array $row, string $name) use ($colIdx, $fallbackPositions): string {
if ($colIdx !== null) {
$pos = $colIdx[$name] ?? null;
if ($pos === null) {
@@ -112,13 +125,24 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
if ($name === 'license') $pos = $colIdx['licence'] ?? null;
elseif ($name === 'licence') $pos = $colIdx['license'] ?? null;
}
// Fall back to positional if header didn't have this key
if ($pos === null) {
$pos = $fallbackPositions[$name] ?? null;
}
if ($pos === null) return '';
} else {
$pos = $fallbackPos;
$pos = $fallbackPositions[$name] ?? null;
if ($pos === null) return '';
}
return isset($row[$pos]) ? trim((string)$row[$pos]) : '';
};
// Helper: parse "Oui"/"Non"/1/0 strings to int
$parseBool = function(string $raw): int {
$raw = strtolower(trim($raw));
return ($raw === 'oui' || $raw === '1' || $raw === 'yes' || $raw === 'true') ? 1 : 0;
};
// Code → canonical name (legacy short-code CSV format)
$orientationCodeMap = [
'SC'=>'Sculpture','VI'=>'Vidéographie','CA'=>"Cinéma d'animation",
@@ -232,39 +256,55 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
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);
$identifier = $cell($row, 'identifiant');
$title = $cell($row, 'titre');
$subtitle = $cell($row, 'sous-titre');
$authorsRaw = $cell($row, 'auteur');
$contact = $cell($row, 'contact');
// 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);
$lecteursInternesRaw = $cell($row, 'lecteur', 6); // first "lecteur" col = interne
$lecteursExternesRaw = $cell($row, 'externe', 7); // contains "externe"
$promoteursUlbRaw = $cell($row, 'ulb', 8); // contains "ulb"
$formatsRaw = $cell($row, 'format', 9);
$yearRaw = $cell($row, 'année', 10);
$supervisorsRaw = $cell($row, 'promoteur');
$lecteursInternesRaw = $cell($row, 'lecteur'); // first "lecteur" col = interne
$lecteursExternesRaw = $cell($row, 'externe'); // contains "externe"
$promoteursUlbRaw = $cell($row, 'ulb'); // contains "ulb"
$formatsRaw = $cell($row, 'format');
$yearRaw = $cell($row, 'année');
$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', 11);
$orientationCode = $cell($row, 'orientation', 12);
$finalityName = $cell($row, 'finalité', 13);
$keywordsRaw = $cell($row, 'mots-clés', 14);
$synopsis = $cell($row, 'synopsis', 15);
$context = $cell($row, 'contexte', 16);
$remarks = $cell($row, 'remarques', 17);
$languageRaw = $cell($row, 'langue', 18);
$access = $cell($row, 'autorisation', 19);
$license = $cell($row, 'license', 20);
$juryPointsRaw = $cell($row, 'points', 21);
$apCode = $cell($row, 'ap');
$orientationCode = $cell($row, 'orientation');
$finalityName = $cell($row, 'finalité');
$keywordsRaw = $cell($row, 'mots-clés');
$synopsis = $cell($row, 'synopsis');
$context = $cell($row, 'contexte');
$remarks = $cell($row, 'remarques');
$languageRaw = $cell($row, 'langue');
$access = $cell($row, 'autorisation');
$license = $cell($row, 'licence');
$juryPointsRaw = $cell($row, 'points');
$juryPoints = $juryPointsRaw !== '' ? floatval($juryPointsRaw) : null;
$baiuLink = $cell($row, 'lien baiu', 22);
$baiuLink = $cell($row, 'lien baiu');
$cc2rRaw = $cell($row, 'cc2r');
$cc2r = $parseBool($cc2rRaw);
$exempBaiuRaw = $cell($row, 'exemplaire'); // first "exemplaire" col = BAIU
$exempBaiu = $parseBool($exempBaiuRaw);
$exempErgRaw = $cell($row, 'exemplaire-erg'); // second "exemplaire", key won't match header → positional
$exempErg = $parseBool($exempErgRaw);
$durationPagesRaw = $cell($row, 'pages');
$durationPages = $durationPagesRaw !== '' ? (int)$durationPagesRaw : null;
$durationMinutesRaw = $cell($row, 'durée');
$durationMinutes = $durationMinutesRaw !== '' ? (int)$durationMinutesRaw : null;
$hasAnnexesRaw = $cell($row, 'annexes');
$hasAnnexes = $parseBool($hasAnnexesRaw);
$licenseCustom = $cell($row, 'licence-perso'); // second "licence", key won't match header → positional
$contactVisible = $cell($row, 'contact-visible'); // second "contact", key won't match header → positional
$objetRaw = $cell($row, 'objet');
$objet = in_array($objetRaw, ['tfe', 'thèse', 'frart'], true) ? $objetRaw : 'tfe';
if ($title === '' || $year === 0) {
$missing = [];
@@ -303,14 +343,31 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
}
}
// Resolve license name → license_id
$licenseId = null;
if (!empty($license)) {
$ls = $importPdo->prepare("SELECT id FROM license_types WHERE LOWER(name) = LOWER(?)");
$ls->execute([trim($license)]);
$lr = $ls->fetch();
$licenseId = $lr ? (int)$lr['id'] : null;
}
// If a custom license string is provided and no DB license matched, store as custom
if ($licenseId === null && !empty($license) && empty($licenseCustom)) {
$licenseCustom = $license;
}
$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)
access_type_id, license_id, license_custom,
cc2r, exemplaire_baiu, exemplaire_erg,
objet, contact_visible,
duration_pages, duration_minutes, has_annexes,
is_published, status, submitted_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0,'active',CURRENT_TIMESTAMP)
");
$s->execute([
!empty($identifier) ? $identifier : null, $title,
@@ -322,6 +379,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
$juryPoints,
!empty($baiuLink) ? $baiuLink : null,
$accessTypeId,
$licenseId,
!empty($licenseCustom) ? $licenseCustom : null,
$cc2r,
$exempBaiu,
$exempErg,
$objet,
!empty($contactVisible) ? $contactVisible : null,
$durationPages,
$durationMinutes,
$hasAnnexes,
]);
$thesisId = $importPdo->lastInsertId();
+24
View File
@@ -2508,3 +2508,27 @@ th.admin-ap-col {
padding: var(--space-3xs) 0;
border: 0;
}
/* ── CSV import file input (admin list page) ──────────────────────────── */
.admin-file-input {
display: flex;
flex-direction: column;
gap: var(--space-3xs);
}
/* FilePond drop-zone border (replaces the raw input border from form-base.css) */
.admin-file-input .filepond--root {
border: 1px dashed var(--border-primary, #ccc);
border-radius: var(--radius, 8px);
background: var(--bg-elevated, #fafafa);
}
.admin-file-input .filepond--root:hover {
border-color: var(--accent-primary, #5b2d8e);
}
.admin-file-hint {
display: block;
margin-top: var(--space-2xs);
}
+1
View File
@@ -15,6 +15,7 @@ import "./smtp-error-focus.js";
import "./htmx-global-setup.js";
// Admin features
import "./file-upload-filepond.js";
import "./admin-index-bulk.js";
import "./admin-cleanup-bulk.js";
import "./admin-tags.js";
+65
View File
@@ -250,6 +250,59 @@ class ExportController
/**
* Column headers matching the import format.
*/
/**
* Single source of truth for CSV column definitions.
* Each entry: [header_label, field_key, import_search_key]
*
* field_key = key in the SELECT row and export data array
* import_key = lowercase token used to match header rows during import
*/
public const CSV_COLUMNS = [
['Identifiant', 'identifier', 'identifiant'],
['Titre', 'title', 'titre'],
['Sous-titre', 'subtitle', 'sous-titre'],
['Auteur·ice(s)', 'authors', 'auteur'],
['Contact', 'contact', 'contact'],
['Promoteur·ice(s) interne', 'promoteurs_internes', 'promoteur'],
['Lecteur·ice(s) interne', 'lecteurs_internes', 'lecteur'],
['Lecteur·ice(s) externe', 'lecteurs_externes', 'externe'],
['Promoteur·ice(s) université', 'promoteurs_ulb', 'ulb'],
['Format(s)', 'formats', 'format'],
['Année', 'year', 'année'],
['AP', 'ap_program', 'ap'],
['Orientation', 'orientation', 'orientation'],
['Finalité', 'finality', 'finalité'],
['Mots-clés', 'keywords', 'mots-clés'],
['Synopsis', 'synopsis', 'synopsis'],
['Contexte', 'context', 'contexte'],
['Remarques', 'remarks', 'remarques'],
['Langue', 'languages', 'langue'],
['Autorisation', 'access_type', 'autorisation'],
['Licence', 'license', 'licence'],
['Points sur 20', 'jury_points', 'points'],
['Lien BAIU', 'baiu_link', 'lien baiu'],
['CC2r', 'cc2r', 'cc2r'],
['Exemplaire BAIU', 'exemplaire_baiu', 'exemplaire'],
['Exemplaire ERG', 'exemplaire_erg', 'exemplaire-erg'],
['Pages', 'duration_pages', 'pages'],
['Durée (minutes)', 'duration_minutes', 'durée'],
['Annexes', 'has_annexes', 'annexes'],
['Licence personnalisée', 'license_custom', 'licence-perso'],
['Contact visible', 'contact_visible', 'contact-visible'],
['Objet', 'objet', 'objet'],
];
/**
* Derived from CSV_COLUMNS for backward compatibility.
*/
public static function csvHeaders(): array
{
return array_column(self::CSV_COLUMNS, 0);
}
/**
* @deprecated Use csvHeaders() instead.
*/
public const CSV_HEADERS = [
'Identifiant',
'Titre',
@@ -277,6 +330,12 @@ class ExportController
'CC2r',
'Exemplaire BAIU',
'Exemplaire ERG',
'Pages',
'Durée (minutes)',
'Annexes',
'Licence personnalisée',
'Contact visible',
'Objet',
];
/**
@@ -396,6 +455,12 @@ class ExportController
!empty($t['cc2r']) ? 'Oui' : 'Non',
!empty($t['exemplaire_baiu']) ? 'Oui' : 'Non',
!empty($t['exemplaire_erg']) ? 'Oui' : 'Non',
isset($t['duration_pages']) && $t['duration_pages'] !== null ? (string) $t['duration_pages'] : '',
isset($t['duration_minutes']) && $t['duration_minutes'] !== null ? (string) $t['duration_minutes'] : '',
!empty($t['has_annexes']) ? 'Oui' : 'Non',
$t['license_custom'] ?? '',
$t['contact_visible'] ?? '',
$t['objet'] ?? '',
];
}
+8 -2
View File
@@ -2266,7 +2266,7 @@ class Database
duration_pages, duration_minutes,
has_annexes,
submitted_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, "draft", ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, "draft", ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
');
$validObjet = ['tfe', 'thèse', 'frart'];
@@ -2696,7 +2696,13 @@ class Database
t.baiu_link,
t.exemplaire_baiu,
t.exemplaire_erg,
t.cc2r
t.cc2r,
t.license_custom,
t.contact_visible,
t.duration_pages,
t.duration_minutes,
t.has_annexes,
t.objet
FROM theses t
LEFT JOIN orientations o ON t.orientation_id = o.id
LEFT JOIN ap_programs ap ON t.ap_program_id = ap.id
@@ -1,3 +1,9 @@
<?php
// Ensure ExportController is available for the CSV column hint
if (!class_exists('ExportController')) {
require_once APP_ROOT . '/src/Controllers/ExportController.php';
}
?>
<dialog id="import-dialog" class="admin-dialog" aria-labelledby="import-dialog-title">
<div class="admin-dialog__header">
<h3 id="import-dialog-title">Importer une liste de TFE</h3>
@@ -51,7 +57,7 @@
data-queue-type="csv_import"
required>
<small class="admin-file-hint">
Colonnes : Identifiant, Titre, Sous-titre, Auteur·ice(s), Contact, Promoteur·ice(s), Format, Année, AP, Orientation, Finalité, Mots-clés, Synopsis, Contexte, Remarques, Langue, Autorisation, License, taille, Points sur 20, lien BAIU<br>
Colonnes : <?= htmlspecialchars(implode(', ', ExportController::csvHeaders())) ?><br>
Quatre premières lignes ignorées — Séparateur : virgule — UTF-8
</small>
</div>