mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-08-10 07:11:18 +02:00
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:
File diff suppressed because one or more lines are too long
@@ -3,3 +3,41 @@
|
|||||||
- [x] Simplify `tfe.php` HTML structure (remove `.tfe-layout` wrapper, remove `.tfe-synopsis-column` wrapper)
|
- [x] Simplify `tfe.php` HTML structure (remove `.tfe-layout` wrapper, remove `.tfe-synopsis-column` wrapper)
|
||||||
- [x] Simplify `tfe.css` — replace flex-column with grid where possible, merge `.tfe-layout` into `.tfe-content-row`, remove dead wrappers
|
- [x] Simplify `tfe.css` — replace flex-column with grid where possible, merge `.tfe-layout` into `.tfe-content-row`, remove dead wrappers
|
||||||
- [x] Commit changes
|
- [x] Commit changes
|
||||||
|
- [x] Remove "Mo" option from duration — keep only minutes and pages
|
||||||
|
- [x] Combine pages and minutes as separate fields (both can be set simultaneously)
|
||||||
|
- [x] Fix minutes input visibility (can't see what's typed)
|
||||||
|
- [x] Add `has_annexes` checkbox to form + DB column
|
||||||
|
- [x] Display duration on admin backoffice recap page
|
||||||
|
- [x] Update public TFE page duration display for new combined format
|
||||||
|
- [x] Fix `handleAutosaveResponse is not defined` — load `autosave-handler.js` on about_page edit view
|
||||||
|
- [x] Fix `handleAutosaveResponse is not defined` in admin add/edit — function was scoped inside IIFE bundle, not on window
|
||||||
|
- [x] Fix search result cards: title and author always below image/gradient, remove text overlay on placeholder gradient
|
||||||
|
- [x] Change search results to 10 per page with HTMX pagination (no-JS fallback preserved)
|
||||||
|
- [x] Bump to 15 results/page, make results area vertically scrollable, sticky pagination at viewport bottom
|
||||||
|
- [x] Fix HTMX targetError: move #search-results wrapper inside fragment so it survives outerHTML swaps
|
||||||
|
- [x] Add range counter to results header (e.g. "1–15 sur 112 résultats")
|
||||||
|
- [x] Collapsible filter controls with <details>/<summary> + active filter badge on mobile
|
||||||
|
- [x] Responsive paginator: hide first/last buttons on mobile, smaller touch targets
|
||||||
|
- [x] Compact range counter on mobile: "1–15 / 112" instead of full "1–15 sur 112 résultats"
|
||||||
|
- [x] Remove orientation display from search result card meta
|
||||||
|
- [x] Add bulk select/delete to tmp cleanup dialog (like admin/index.php pattern)
|
||||||
|
- [x] Move cleanup UI from modal to dedicated page (like add.php/edit.php)
|
||||||
|
- [x] Replace <details> with <fieldset>/<legend> in cleanup fragments (like contenus.php)
|
||||||
|
- [x] Standardise test group recipes: lint-php, lint-css, lint-js, test + add fix recipe + make lint pass clean
|
||||||
|
- [x] Remove Mot de passe column from acces.php, add key.svg icon button in Actions to copy password to clipboard, reorder actions (URL/password/edit/toggle/archive), switch URL icon to link-simple.svg
|
||||||
|
- [x] Fix CC2r: removed hidden seeds (were stale/overriding user input), render initial licence-choice from \$formData; Interne want_license unchecked clears CC2r+licence, switching from other modes preserves values; CC2r independent toggle; licence dropdown has no-licence placeholder
|
||||||
|
- [x] Dans le formulaire admin, le contact privé ne se sauvegarde pas.
|
||||||
|
- [x] Remove "Rendre le contact visible publiquement sur la fiche du TFE" from admin add/edit forms
|
||||||
|
- [x] Auto-show contact on public TFE page when contact_visible is filled (no checkbox needed)
|
||||||
|
- [x] Fix file size limits: align all layers (JS/FilepondHandler/ThesisFileHandler/validate-fragment) — video/audio 5 GB, default 500 MB, PDF 100 MB; fix JS fileValidateSizeFilter gate bug that rejected AV files >1 GB despite 8 GB per-ext cap
|
||||||
|
- [x] Fix: deleting contact groups in apropos form not persisted (no change event after DOM removal, autosave never triggered)
|
||||||
|
- [x] Improve h1, h2, h3 size difference in base.css heading scale
|
||||||
|
- [x] Rework TFE page desktop layout: two columns 35vw/1fr, left = author/title/meta/synopsis, right = files, media 100% width; columns scroll independently, content fits 100vw; no bottom padding on main; add &scrollbar=0 to PDF iframe URLs
|
||||||
|
- [x] 'Promoteur·ice ULB' remplacé par 'Promoteur·ice université' (changement juste l'étiquette)
|
||||||
|
- [x] PDF viewer: fix page navigation buttons (prev/next) — build toolbar after canvases exist & query canvases fresh in scroll handler
|
||||||
|
- [x] PDF viewer: render pages smaller by default (0.85× scale)
|
||||||
|
- [x] PDF viewer: add dark transparent violet background behind pages when expanded
|
||||||
|
- [x] pdf-viewer: expand to fill right column (not whole page), center pages, freeze column on expand
|
||||||
|
- [x] pdf-viewer: fix toolbar width (align-self:stretch), hide sibling file items when expanded
|
||||||
|
- [x] Fix createThesis SQL: extra `?` placeholder in VALUES (27 values for 26 columns) + add integration tests
|
||||||
|
- [x] CSV import/export: single source of truth (CSV_COLUMNS), add missing columns (duration, annexes, license_custom, contact_visible, objet, cc2r, exemplaires), fix XamxamInitFilePonds → window.n, generate import hint from headers
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ echo "\xEF\xBB\xBF";
|
|||||||
$out = fopen('php://output', 'w');
|
$out = fopen('php://output', 'w');
|
||||||
|
|
||||||
// Column headers
|
// Column headers
|
||||||
fputcsv($out, ExportController::CSV_HEADERS, ',', '"', '');
|
fputcsv($out, ExportController::csvHeaders(), ',', '"', '');
|
||||||
|
|
||||||
// Data rows
|
// Data rows
|
||||||
$rows = $controller->exportAllTheses();
|
$rows = $controller->exportAllTheses();
|
||||||
|
|||||||
+102
-35
@@ -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.
|
// Build colIdx[name] → position map; fall back to positional if header not found.
|
||||||
// Matching uses prefix + variant logic so "contact.visible" matches "contact",
|
// Matching uses prefix + variant logic so "contact.visible" matches "contact",
|
||||||
// "promoteur·ice(s)" matches "promoteur", "Licence" matches "license", etc.
|
// "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;
|
$colIdx = null;
|
||||||
$headerRowNum = 0;
|
$headerRowNum = 0;
|
||||||
$knownHeaders = [
|
// Build knownHeaders from CSV_COLUMNS import_key column (index 2)
|
||||||
'identifiant', 'titre', 'sous-titre', 'auteur', 'contact',
|
$knownHeaders = array_unique(array_column($csvCols, 2));
|
||||||
'promoteur', 'lecteur', 'ulb', 'externe', 'format', 'année', 'ap', 'orientation', 'finalité',
|
// Add licence/license cross-match aliases
|
||||||
'mots-clés', 'synopsis', 'contexte', 'remarques', 'langue',
|
$knownHeaders[] = 'license';
|
||||||
'autorisation', 'licence', 'license', 'points', 'lien baiu',
|
|
||||||
];
|
|
||||||
for ($scan = 0; $scan < 8; $scan++) {
|
for ($scan = 0; $scan < 8; $scan++) {
|
||||||
$hrow = fgetcsv($handle, 0, ',', '"', '');
|
$hrow = fgetcsv($handle, 0, ',', '"', '');
|
||||||
if ($hrow === false) break;
|
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.
|
// Helper: get cell value by column name.
|
||||||
// When header was found: only use mapped column (returns '' if missing from header).
|
// When header was found: use mapped column.
|
||||||
// When no header found: use positional fallback index.
|
// When header was found but key is missing: use positional fallback.
|
||||||
$cell = function(array $row, string $name, int $fallbackPos) use ($colIdx): string {
|
// When no header found: use positional fallback.
|
||||||
|
$cell = function(array $row, string $name) use ($colIdx, $fallbackPositions): string {
|
||||||
if ($colIdx !== null) {
|
if ($colIdx !== null) {
|
||||||
$pos = $colIdx[$name] ?? null;
|
$pos = $colIdx[$name] ?? null;
|
||||||
if ($pos === null) {
|
if ($pos === null) {
|
||||||
@@ -112,13 +125,24 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
|
|||||||
if ($name === 'license') $pos = $colIdx['licence'] ?? null;
|
if ($name === 'license') $pos = $colIdx['licence'] ?? null;
|
||||||
elseif ($name === 'licence') $pos = $colIdx['license'] ?? 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 '';
|
if ($pos === null) return '';
|
||||||
} else {
|
} else {
|
||||||
$pos = $fallbackPos;
|
$pos = $fallbackPositions[$name] ?? null;
|
||||||
|
if ($pos === null) return '';
|
||||||
}
|
}
|
||||||
return isset($row[$pos]) ? trim((string)$row[$pos]) : '';
|
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)
|
// Code → canonical name (legacy short-code CSV format)
|
||||||
$orientationCodeMap = [
|
$orientationCodeMap = [
|
||||||
'SC'=>'Sculpture','VI'=>'Vidéographie','CA'=>"Cinéma d'animation",
|
'SC'=>'Sculpture','VI'=>'Vidéographie','CA'=>"Cinéma d'animation",
|
||||||
@@ -232,39 +256,55 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
|
|||||||
try {
|
try {
|
||||||
$importDb->beginTransaction();
|
$importDb->beginTransaction();
|
||||||
|
|
||||||
$identifier = $cell($row, 'identifiant', 0);
|
$identifier = $cell($row, 'identifiant');
|
||||||
$title = $cell($row, 'titre', 1);
|
$title = $cell($row, 'titre');
|
||||||
$subtitle = $cell($row, 'sous-titre', 2);
|
$subtitle = $cell($row, 'sous-titre');
|
||||||
$authorsRaw = $cell($row, 'auteur', 3);
|
$authorsRaw = $cell($row, 'auteur');
|
||||||
$contact = $cell($row, 'contact', 4);
|
$contact = $cell($row, 'contact');
|
||||||
// Normalise CSV artefacts: OUI/NON → empty (not a valid email)
|
// Normalise CSV artefacts: OUI/NON → empty (not a valid email)
|
||||||
if ($contact !== '' && in_array(strtoupper(trim($contact)), ['NON', 'OUI'], true)) {
|
if ($contact !== '' && in_array(strtoupper(trim($contact)), ['NON', 'OUI'], true)) {
|
||||||
$contact = '';
|
$contact = '';
|
||||||
}
|
}
|
||||||
$supervisorsRaw = $cell($row, 'promoteur', 5);
|
$supervisorsRaw = $cell($row, 'promoteur');
|
||||||
$lecteursInternesRaw = $cell($row, 'lecteur', 6); // first "lecteur" col = interne
|
$lecteursInternesRaw = $cell($row, 'lecteur'); // first "lecteur" col = interne
|
||||||
$lecteursExternesRaw = $cell($row, 'externe', 7); // contains "externe"
|
$lecteursExternesRaw = $cell($row, 'externe'); // contains "externe"
|
||||||
$promoteursUlbRaw = $cell($row, 'ulb', 8); // contains "ulb"
|
$promoteursUlbRaw = $cell($row, 'ulb'); // contains "ulb"
|
||||||
$formatsRaw = $cell($row, 'format', 9);
|
$formatsRaw = $cell($row, 'format');
|
||||||
$yearRaw = $cell($row, 'année', 10);
|
$yearRaw = $cell($row, 'année');
|
||||||
$year = $yearRaw !== '' ? intval($yearRaw) : 0;
|
$year = $yearRaw !== '' ? intval($yearRaw) : 0;
|
||||||
// Fallback: derive year from identifier (e.g. "2024-003" → 2024)
|
// Fallback: derive year from identifier (e.g. "2024-003" → 2024)
|
||||||
if ($year === 0 && $identifier !== '' && preg_match('/^(\d{4})-/', $identifier, $m)) {
|
if ($year === 0 && $identifier !== '' && preg_match('/^(\d{4})-/', $identifier, $m)) {
|
||||||
$year = (int)$m[1];
|
$year = (int)$m[1];
|
||||||
}
|
}
|
||||||
$apCode = $cell($row, 'ap', 11);
|
$apCode = $cell($row, 'ap');
|
||||||
$orientationCode = $cell($row, 'orientation', 12);
|
$orientationCode = $cell($row, 'orientation');
|
||||||
$finalityName = $cell($row, 'finalité', 13);
|
$finalityName = $cell($row, 'finalité');
|
||||||
$keywordsRaw = $cell($row, 'mots-clés', 14);
|
$keywordsRaw = $cell($row, 'mots-clés');
|
||||||
$synopsis = $cell($row, 'synopsis', 15);
|
$synopsis = $cell($row, 'synopsis');
|
||||||
$context = $cell($row, 'contexte', 16);
|
$context = $cell($row, 'contexte');
|
||||||
$remarks = $cell($row, 'remarques', 17);
|
$remarks = $cell($row, 'remarques');
|
||||||
$languageRaw = $cell($row, 'langue', 18);
|
$languageRaw = $cell($row, 'langue');
|
||||||
$access = $cell($row, 'autorisation', 19);
|
$access = $cell($row, 'autorisation');
|
||||||
$license = $cell($row, 'license', 20);
|
$license = $cell($row, 'licence');
|
||||||
$juryPointsRaw = $cell($row, 'points', 21);
|
$juryPointsRaw = $cell($row, 'points');
|
||||||
$juryPoints = $juryPointsRaw !== '' ? floatval($juryPointsRaw) : null;
|
$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) {
|
if ($title === '' || $year === 0) {
|
||||||
$missing = [];
|
$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("
|
$s = $importPdo->prepare("
|
||||||
INSERT INTO theses (
|
INSERT INTO theses (
|
||||||
identifier, title, subtitle, year,
|
identifier, title, subtitle, year,
|
||||||
orientation_id, ap_program_id, finality_id,
|
orientation_id, ap_program_id, finality_id,
|
||||||
synopsis, context_note, remarks,
|
synopsis, context_note, remarks,
|
||||||
jury_points, baiu_link,
|
jury_points, baiu_link,
|
||||||
access_type_id, is_published, submitted_at
|
access_type_id, license_id, license_custom,
|
||||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,0,CURRENT_TIMESTAMP)
|
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([
|
$s->execute([
|
||||||
!empty($identifier) ? $identifier : null, $title,
|
!empty($identifier) ? $identifier : null, $title,
|
||||||
@@ -322,6 +379,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
|
|||||||
$juryPoints,
|
$juryPoints,
|
||||||
!empty($baiuLink) ? $baiuLink : null,
|
!empty($baiuLink) ? $baiuLink : null,
|
||||||
$accessTypeId,
|
$accessTypeId,
|
||||||
|
$licenseId,
|
||||||
|
!empty($licenseCustom) ? $licenseCustom : null,
|
||||||
|
$cc2r,
|
||||||
|
$exempBaiu,
|
||||||
|
$exempErg,
|
||||||
|
$objet,
|
||||||
|
!empty($contactVisible) ? $contactVisible : null,
|
||||||
|
$durationPages,
|
||||||
|
$durationMinutes,
|
||||||
|
$hasAnnexes,
|
||||||
]);
|
]);
|
||||||
$thesisId = $importPdo->lastInsertId();
|
$thesisId = $importPdo->lastInsertId();
|
||||||
|
|
||||||
|
|||||||
@@ -2508,3 +2508,27 @@ th.admin-ap-col {
|
|||||||
padding: var(--space-3xs) 0;
|
padding: var(--space-3xs) 0;
|
||||||
border: 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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import "./smtp-error-focus.js";
|
|||||||
import "./htmx-global-setup.js";
|
import "./htmx-global-setup.js";
|
||||||
|
|
||||||
// Admin features
|
// Admin features
|
||||||
|
import "./file-upload-filepond.js";
|
||||||
import "./admin-index-bulk.js";
|
import "./admin-index-bulk.js";
|
||||||
import "./admin-cleanup-bulk.js";
|
import "./admin-cleanup-bulk.js";
|
||||||
import "./admin-tags.js";
|
import "./admin-tags.js";
|
||||||
|
|||||||
@@ -250,6 +250,59 @@ class ExportController
|
|||||||
/**
|
/**
|
||||||
* Column headers matching the import format.
|
* 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 = [
|
public const CSV_HEADERS = [
|
||||||
'Identifiant',
|
'Identifiant',
|
||||||
'Titre',
|
'Titre',
|
||||||
@@ -277,6 +330,12 @@ class ExportController
|
|||||||
'CC2r',
|
'CC2r',
|
||||||
'Exemplaire BAIU',
|
'Exemplaire BAIU',
|
||||||
'Exemplaire ERG',
|
'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['cc2r']) ? 'Oui' : 'Non',
|
||||||
!empty($t['exemplaire_baiu']) ? 'Oui' : 'Non',
|
!empty($t['exemplaire_baiu']) ? 'Oui' : 'Non',
|
||||||
!empty($t['exemplaire_erg']) ? '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'] ?? '',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2266,7 +2266,7 @@ class Database
|
|||||||
duration_pages, duration_minutes,
|
duration_pages, duration_minutes,
|
||||||
has_annexes,
|
has_annexes,
|
||||||
submitted_at
|
submitted_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, "draft", ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, "draft", ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
');
|
');
|
||||||
|
|
||||||
$validObjet = ['tfe', 'thèse', 'frart'];
|
$validObjet = ['tfe', 'thèse', 'frart'];
|
||||||
@@ -2696,7 +2696,13 @@ class Database
|
|||||||
t.baiu_link,
|
t.baiu_link,
|
||||||
t.exemplaire_baiu,
|
t.exemplaire_baiu,
|
||||||
t.exemplaire_erg,
|
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
|
FROM theses t
|
||||||
LEFT JOIN orientations o ON t.orientation_id = o.id
|
LEFT JOIN orientations o ON t.orientation_id = o.id
|
||||||
LEFT JOIN ap_programs ap ON t.ap_program_id = ap.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">
|
<dialog id="import-dialog" class="admin-dialog" aria-labelledby="import-dialog-title">
|
||||||
<div class="admin-dialog__header">
|
<div class="admin-dialog__header">
|
||||||
<h3 id="import-dialog-title">Importer une liste de TFE</h3>
|
<h3 id="import-dialog-title">Importer une liste de TFE</h3>
|
||||||
@@ -51,7 +57,7 @@
|
|||||||
data-queue-type="csv_import"
|
data-queue-type="csv_import"
|
||||||
required>
|
required>
|
||||||
<small class="admin-file-hint">
|
<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
|
Quatre premières lignes ignorées — Séparateur : virgule — UTF-8
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -102,8 +102,15 @@ async function main() {
|
|||||||
// 1. Base bundle: resolve style.css @import chain
|
// 1. Base bundle: resolve style.css @import chain
|
||||||
total += await bundleCss("style.css", "base.min.css");
|
total += await bundleCss("style.css", "base.min.css");
|
||||||
|
|
||||||
// 2. Admin bundle: just minify admin.css (standalone)
|
// 2. Admin bundle: admin.css + filepond vendor CSS (for CSV import dialog)
|
||||||
total += await bundleCss("admin.css", "admin.min.css");
|
total += await concatBundle(
|
||||||
|
[
|
||||||
|
"admin.css",
|
||||||
|
"filepond.min.css",
|
||||||
|
"filepond-plugin-image-preview.min.css",
|
||||||
|
],
|
||||||
|
"admin.min.css"
|
||||||
|
);
|
||||||
|
|
||||||
// 3. Form bundle: concat + minify form-base.css + form-admin.css + filepond vendor CSS
|
// 3. Form bundle: concat + minify form-base.css + form-admin.css + filepond vendor CSS
|
||||||
total += await concatBundle(
|
total += await concatBundle(
|
||||||
|
|||||||
@@ -345,4 +345,103 @@ class DatabaseExtendedTest extends TestCase
|
|||||||
$deleted = $pdo->query("SELECT deleted_at FROM tags WHERE id = $tagB")->fetchColumn();
|
$deleted = $pdo->query("SELECT deleted_at FROM tags WHERE id = $tagB")->fetchColumn();
|
||||||
$this->assertNotNull($deleted);
|
$this->assertNotNull($deleted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── createThesis ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testCreateThesisMinimalFields(): void
|
||||||
|
{
|
||||||
|
$id = $this->db->createThesis([
|
||||||
|
'title' => 'Minimal Thesis',
|
||||||
|
'year' => 2025,
|
||||||
|
'synopsis' => 'A minimal synopsis.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertGreaterThan(0, $id);
|
||||||
|
|
||||||
|
$row = TestDatabase::getPDO()
|
||||||
|
->query("SELECT * FROM theses WHERE id = $id")
|
||||||
|
->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$this->assertSame('Minimal Thesis', $row['title']);
|
||||||
|
$this->assertSame(2025, (int)$row['year']);
|
||||||
|
$this->assertSame('tfe', $row['objet']);
|
||||||
|
$this->assertSame('draft', $row['status']);
|
||||||
|
$this->assertSame(0, (int)$row['is_published']);
|
||||||
|
$this->assertSame(0, (int)$row['exemplaire_baiu']);
|
||||||
|
$this->assertSame(0, (int)$row['exemplaire_erg']);
|
||||||
|
$this->assertSame(0, (int)$row['cc2r']);
|
||||||
|
$this->assertSame(0, (int)$row['has_annexes']);
|
||||||
|
$this->assertStringStartsWith('2025-', $row['identifier']);
|
||||||
|
$this->assertNotNull($row['submitted_at']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateThesisAllOptionalFields(): void
|
||||||
|
{
|
||||||
|
$id = $this->db->createThesis([
|
||||||
|
'title' => 'Full Thesis',
|
||||||
|
'subtitle' => 'With a subtitle',
|
||||||
|
'year' => 2024,
|
||||||
|
'synopsis' => 'Full synopsis.',
|
||||||
|
'context_note' => 'Some context.',
|
||||||
|
'contact_visible' => 'Visible info',
|
||||||
|
'baiu_link' => 'https://example.com/thesis',
|
||||||
|
'license_id' => null,
|
||||||
|
'license_custom' => 'CC BY 4.0',
|
||||||
|
'access_type_id' => 1,
|
||||||
|
'objet' => 'tfe',
|
||||||
|
'remarks' => 'No remarks.',
|
||||||
|
'jury_points' => '16.5',
|
||||||
|
'exemplaire_baiu' => '1',
|
||||||
|
'exemplaire_erg' => '1',
|
||||||
|
'cc2r' => '1',
|
||||||
|
'duration_pages' => '42',
|
||||||
|
'duration_minutes' => '30',
|
||||||
|
'has_annexes' => '1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertGreaterThan(0, $id);
|
||||||
|
|
||||||
|
$row = TestDatabase::getPDO()
|
||||||
|
->query("SELECT * FROM theses WHERE id = $id")
|
||||||
|
->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$this->assertSame('Full Thesis', $row['title']);
|
||||||
|
$this->assertSame('With a subtitle', $row['subtitle']);
|
||||||
|
$this->assertSame(2024, (int)$row['year']);
|
||||||
|
$this->assertSame('Full synopsis.', $row['synopsis']);
|
||||||
|
$this->assertSame('Some context.', $row['context_note']);
|
||||||
|
$this->assertSame('Visible info', $row['contact_visible']);
|
||||||
|
$this->assertSame('https://example.com/thesis', $row['baiu_link']);
|
||||||
|
$this->assertSame('CC BY 4.0', $row['license_custom']);
|
||||||
|
$this->assertSame(1, (int)$row['exemplaire_baiu']);
|
||||||
|
$this->assertSame(1, (int)$row['exemplaire_erg']);
|
||||||
|
$this->assertSame(1, (int)$row['cc2r']);
|
||||||
|
$this->assertSame(42, (int)$row['duration_pages']);
|
||||||
|
$this->assertSame(30, (int)$row['duration_minutes']);
|
||||||
|
$this->assertSame(1, (int)$row['has_annexes']);
|
||||||
|
$this->assertSame('No remarks.', $row['remarks']);
|
||||||
|
$this->assertEquals(16.5, (float)$row['jury_points']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateThesisGeneratesUniqueIdentifiers(): void
|
||||||
|
{
|
||||||
|
$id1 = $this->db->createThesis([
|
||||||
|
'title' => 'First',
|
||||||
|
'year' => 2025,
|
||||||
|
'synopsis' => 'One.',
|
||||||
|
]);
|
||||||
|
$id2 = $this->db->createThesis([
|
||||||
|
'title' => 'Second',
|
||||||
|
'year' => 2025,
|
||||||
|
'synopsis' => 'Two.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$pdo = TestDatabase::getPDO();
|
||||||
|
$ident1 = $pdo->query("SELECT identifier FROM theses WHERE id = $id1")->fetchColumn();
|
||||||
|
$ident2 = $pdo->query("SELECT identifier FROM theses WHERE id = $id2")->fetchColumn();
|
||||||
|
|
||||||
|
$this->assertNotSame($ident1, $ident2);
|
||||||
|
$this->assertSame('2025-001', $ident1);
|
||||||
|
$this->assertSame('2025-002', $ident2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user