diff --git a/TODO.md b/TODO.md index 5f3a5ba..a9e1efc 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,7 @@ # TODO +- [x] Languages: store lowercase, display with ucfirst (getOrCreateLanguage, CSV import, getAllLanguages, v_theses_full, schema seed data, migration 025) +- [x] CSV importer: add AP aliases for D&P du multiple, PACS variants, Narraion typo - [x] Move default semantic form element styles (checkbox, radio, select) from admin.css/form.css into common.css - [x] Keep specific layouts/classes in form.css (admin-form grid, checkbox-group layout, etc.) - [x] Ensure selects, checkboxes, and radios are properly styled globally @@ -70,7 +72,7 @@ - [x] Mots-clés: lowercase enforcement, deduplication, absolute dropdown, keyboard arrows/enter/escape, blur hide, spacing + counter above input, CSV import lowercased, space-collapse normalization, minimum 3 keywords required - [x] ErrorHandler: shared static helper for structured error_log + user-friendly messages with precise FK field extraction from SQLite errors. Applied to 12 action files + 6 public controllers + 2 form controllers + partage. Covers FK, UNIQUE, NOT NULL constraint types. - [x] Fix: findOrCreateAuthor cannot clear email (empty string skips update, leaves old email) -- [ ] Fix: "NON" stored as literal email string in authors table (CSV import or old data) +- [x] Fix: "NON" stored as literal email string in authors table — cleaned existing DB rows, added OUI/NON→null guard in findOrCreateAuthor and CSV import - [x] Fix: contact_interne field in edit form never saved — removed dead field from form and dead validation from create controller - [x] Fix: formulaire.php unconditionally suppresses display_errors even in dev mode - [x] Fix: access_type_id radio has no "none" option — added "— Non défini" radio for admin mode diff --git a/app/migrations/applied/025_lowercase_languages.sql b/app/migrations/applied/025_lowercase_languages.sql new file mode 100644 index 0000000..320dc33 --- /dev/null +++ b/app/migrations/applied/025_lowercase_languages.sql @@ -0,0 +1,75 @@ +-- 025_lowercase_languages.sql +-- Normalise les noms de langues en minuscules et recrée la vue avec ucfirst. + +-- Normaliser les langues existantes +UPDATE languages SET name = LOWER(name); + +-- Recréer la vue pour appliquer UPPER(SUBSTR(name,1,1)) || SUBSTR(name,2) dans le GROUP_CONCAT +DROP VIEW IF EXISTS v_theses_public; +DROP VIEW IF EXISTS v_theses_full; + +CREATE VIEW IF NOT EXISTS v_theses_full AS +SELECT + t.id, + t.identifier, + t.title, + t.subtitle, + t.year, + t.is_doctoral, + t.objet, + o.name as orientation, + ap.name as ap_program, + ft.name as finality_type, + t.synopsis, + t.context_note, + at.name as access_type, + lt.name as license_type, + t.license_id, + t.license_custom, + t.access_type_id, + t.jury_points, + t.submitted_at, + t.defense_date, + t.published_at, + t.is_published, + t.baiu_link, + t.banner_path, + t.exemplaire_baiu, + t.exemplaire_erg, + t.cc2r, + t.remarks, + t.jury_note_added, + GROUP_CONCAT(DISTINCT a.name ORDER BY a.name ASC) as authors, + GROUP_CONCAT(DISTINCT s.name) as supervisors, + GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'president' THEN s.name END) as jury_president, + GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'promoteur' AND ts.is_ulb = 0 THEN s.name END) as jury_promoteurs, + GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'promoteur' AND ts.is_ulb = 1 THEN s.name END) as jury_promoteurs_ulb, + GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'lecteur' AND ts.is_external = 0 THEN s.name END) as jury_lecteurs_internes, + GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'lecteur' AND ts.is_external = 1 THEN s.name END) as jury_lecteurs_externes, + GROUP_CONCAT(DISTINCT UPPER(SUBSTR(l.name,1,1)) || SUBSTR(l.name,2)) as languages, + GROUP_CONCAT(DISTINCT fmt.name) as formats, + GROUP_CONCAT(DISTINCT tg.name) as keywords, + -- First author's email and contact-visibility flag + (SELECT a2.email FROM authors a2 JOIN thesis_authors ta2 ON a2.id = ta2.author_id WHERE ta2.thesis_id = t.id ORDER BY ta2.author_order LIMIT 1) as contact_interne, + (SELECT a2.show_contact FROM authors a2 JOIN thesis_authors ta2 ON a2.id = ta2.author_id WHERE ta2.thesis_id = t.id ORDER BY ta2.author_order LIMIT 1) as contact_public +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 +LEFT JOIN finality_types ft ON t.finality_id = ft.id +LEFT JOIN access_types at ON t.access_type_id = at.id +LEFT JOIN license_types lt ON t.license_id = lt.id +LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id +LEFT JOIN authors a ON ta.author_id = a.id +LEFT JOIN thesis_supervisors ts ON t.id = ts.thesis_id +LEFT JOIN supervisors s ON ts.supervisor_id = s.id +LEFT JOIN thesis_languages tl ON t.id = tl.thesis_id +LEFT JOIN languages l ON tl.language_id = l.id +LEFT JOIN thesis_formats tf ON t.id = tf.thesis_id +LEFT JOIN format_types fmt ON tf.format_id = fmt.id +LEFT JOIN thesis_tags tt ON t.id = tt.thesis_id +LEFT JOIN tags tg ON tt.tag_id = tg.id +GROUP BY t.id; + +CREATE VIEW IF NOT EXISTS v_theses_public AS +SELECT * FROM v_theses_full +WHERE is_published = 1; diff --git a/app/migrations/pending/025_fix_oui_non_artefacts.sql b/app/migrations/pending/025_fix_oui_non_artefacts.sql new file mode 100644 index 0000000..f37fa49 --- /dev/null +++ b/app/migrations/pending/025_fix_oui_non_artefacts.sql @@ -0,0 +1,72 @@ +-- 025_fix_oui_non_artefacts.sql +-- Clean OUI/NON CSV artefacts from authors.email (should be NULL, not literal strings). +-- Also update the v_theses_full view to use contact_interne/contact_public column names. + +UPDATE authors SET email = NULL WHERE email IN ('NON', 'OUI', ''); + +DROP VIEW IF EXISTS v_theses_public; +DROP VIEW IF EXISTS v_theses_full; +CREATE VIEW IF NOT EXISTS v_theses_full AS +SELECT + t.id, + t.identifier, + t.title, + t.subtitle, + t.year, + t.is_doctoral, + t.objet, + o.name as orientation, + ap.name as ap_program, + ft.name as finality_type, + t.synopsis, + t.context_note, + at.name as access_type, + lt.name as license_type, + t.license_id, + t.license_custom, + t.access_type_id, + t.jury_points, + t.submitted_at, + t.defense_date, + t.published_at, + t.is_published, + t.baiu_link, + t.banner_path, + t.exemplaire_baiu, + t.exemplaire_erg, + t.cc2r, + t.remarks, + t.jury_note_added, + GROUP_CONCAT(DISTINCT a.name ORDER BY a.name ASC) as authors, + GROUP_CONCAT(DISTINCT s.name) as supervisors, + GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'president' THEN s.name END) as jury_president, + GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'promoteur' AND ts.is_ulb = 0 THEN s.name END) as jury_promoteurs, + GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'promoteur' AND ts.is_ulb = 1 THEN s.name END) as jury_promoteurs_ulb, + GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'lecteur' AND ts.is_external = 0 THEN s.name END) as jury_lecteurs_internes, + GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'lecteur' AND ts.is_external = 1 THEN s.name END) as jury_lecteurs_externes, + GROUP_CONCAT(DISTINCT l.name) as languages, + GROUP_CONCAT(DISTINCT fmt.name) as formats, + GROUP_CONCAT(DISTINCT tg.name) as keywords, + (SELECT a2.email FROM authors a2 JOIN thesis_authors ta2 ON a2.id = ta2.author_id WHERE ta2.thesis_id = t.id ORDER BY ta2.author_order LIMIT 1) as contact_interne, + (SELECT a2.show_contact FROM authors a2 JOIN thesis_authors ta2 ON a2.id = ta2.author_id WHERE ta2.thesis_id = t.id ORDER BY ta2.author_order LIMIT 1) as contact_public +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 +LEFT JOIN finality_types ft ON t.finality_id = ft.id +LEFT JOIN access_types at ON t.access_type_id = at.id +LEFT JOIN license_types lt ON t.license_id = lt.id +LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id +LEFT JOIN authors a ON ta.author_id = a.id +LEFT JOIN thesis_supervisors ts ON t.id = ts.thesis_id +LEFT JOIN supervisors s ON ts.supervisor_id = s.id +LEFT JOIN thesis_languages tl ON t.id = tl.thesis_id +LEFT JOIN languages l ON tl.language_id = l.id +LEFT JOIN thesis_formats tf ON t.id = tf.thesis_id +LEFT JOIN format_types fmt ON tf.format_id = fmt.id +LEFT JOIN thesis_tags tt ON t.id = tt.thesis_id +LEFT JOIN tags tg ON tt.tag_id = tg.id +GROUP BY t.id; + +CREATE VIEW IF NOT EXISTS v_theses_public AS +SELECT * FROM v_theses_full +WHERE is_published = 1; diff --git a/app/public/admin/index.php b/app/public/admin/index.php index ae081ed..54f00d8 100644 --- a/app/public/admin/index.php +++ b/app/public/admin/index.php @@ -173,11 +173,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) { => 'LIENS', 'récits et expérimentation' => 'NS', 'recits et experimentation' => 'NS', - 'atelier pratiques situées' => 'APS', - 'design et politique du multiple' => 'DPM', + '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. @@ -230,6 +233,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) { $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); @@ -342,13 +349,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) { } } if (!empty($languageRaw)) { - $s = $importPdo->prepare("SELECT id FROM languages WHERE name = ?"); - $s->execute([ucfirst(strtolower($languageRaw))]); + $langName = strtolower(trim($languageRaw)); + // 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(); - if ($r) { - $s2 = $importPdo->prepare("INSERT INTO thesis_languages (thesis_id, language_id) VALUES (?,?)"); - $s2->execute([$thesisId, $r['id']]); + $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) { diff --git a/app/src/Database.php b/app/src/Database.php index cf76e62..a3c6757 100644 --- a/app/src/Database.php +++ b/app/src/Database.php @@ -744,11 +744,11 @@ class Database } /** - * Get all languages + * Get all languages (name is capitalized for display). */ public function getAllLanguages(): array { - $stmt = $this->pdo->query('SELECT * FROM languages ORDER BY name'); + $stmt = $this->pdo->query("SELECT id, UPPER(SUBSTR(name,1,1)) || SUBSTR(name,2) as name, created_at FROM languages ORDER BY name"); return $stmt->fetchAll(); } @@ -954,6 +954,11 @@ class Database */ public function findOrCreateAuthor($name, $email = null, bool $showContact = false) { + // Normalise CSV artefacts: OUI/NON strings in email column → null + if ($email !== null && in_array(strtoupper(trim($email)), ['NON', 'OUI'], true)) { + $email = null; + } + $stmt = $this->pdo->prepare('SELECT id FROM authors WHERE name = ?'); $stmt->execute([$name]); $author = $stmt->fetch(); @@ -1522,11 +1527,11 @@ class Database /** * Return the ID of an existing language by name, inserting it if absent. - * Name is trimmed and stored as-is (case-preserved). + * Name is stored lowercase and displayed with first letter capitalized. */ public function getOrCreateLanguage(string $name): int { - $name = trim($name); + $name = strtolower(trim($name)); $stmt = $this->pdo->prepare('SELECT id FROM languages WHERE LOWER(name) = LOWER(?) LIMIT 1'); $stmt->execute([$name]); $id = $stmt->fetchColumn(); diff --git a/app/storage/schema.sql b/app/storage/schema.sql index 82183a6..b80504e 100644 --- a/app/storage/schema.sql +++ b/app/storage/schema.sql @@ -88,9 +88,9 @@ CREATE TABLE IF NOT EXISTS languages ( ); INSERT OR IGNORE INTO languages (name) VALUES - ('Français'), - ('Anglais'), - ('Néerlandais'); + ('français'), + ('anglais'), + ('néerlandais'); -- Format types (can select multiple) CREATE TABLE IF NOT EXISTS format_types ( @@ -525,7 +525,7 @@ SELECT GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'promoteur' AND ts.is_ulb = 1 THEN s.name END) as jury_promoteurs_ulb, GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'lecteur' AND ts.is_external = 0 THEN s.name END) as jury_lecteurs_internes, GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'lecteur' AND ts.is_external = 1 THEN s.name END) as jury_lecteurs_externes, - GROUP_CONCAT(DISTINCT l.name) as languages, + GROUP_CONCAT(DISTINCT UPPER(SUBSTR(l.name,1,1)) || SUBSTR(l.name,2)) as languages, GROUP_CONCAT(DISTINCT fmt.name) as formats, GROUP_CONCAT(DISTINCT tg.name) as keywords, -- First author's email and contact-visibility flag diff --git a/app/templates/admin/acces.php b/app/templates/admin/acces.php index 9790626..0ee4e2c 100644 --- a/app/templates/admin/acces.php +++ b/app/templates/admin/acces.php @@ -87,6 +87,19 @@ +%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision) +\\\\\\\ to: pntwsqvs dd95b4d3 "Rename author_email→contact_interne, author_show_contact→contact_public across view/controllers/templates" (rebased revision) ++ $linkName = $link['name'] ?? ''; +++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; +%%%%%%%%%%%%%%%%%%%%%%% diff from: pntwsqvs dd95b4d3 "Rename author_email→contact_interne, author_show_contact→contact_public across view/controllers/templates" (rebased revision) +\\\\\\\\\\\\\\\\\\\\\\\ to: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision) +- $linkName = $link['name'] ?? ''; +- $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; +%%%%%%%%%%%%%%%%%%%%%%% diff from: somsyvxz 14a3cd10 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebase destination) +\\\\\\\\\\\\\\\\\\\\\\\ to: sttrwkly dc233066 "CSV importer: boolean and ap variants/typos" (rebased revision) + $linkName = $link['name'] ?? ''; + $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; + $linkLockedYear = $link['locked_year'] ?? null; ++%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision) ++\\\\\\\ to: sttrwkly ec5606f5 "CSV importer: boolean and ap variants/typos" (rebased revision) +++ $linkName = $link['name'] ?? ''; ++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; ?>