diff --git a/TODO.md b/TODO.md index 810fd95..44eb14e 100644 --- a/TODO.md +++ b/TODO.md @@ -1,9 +1,14 @@ # TODO -> Last updated: 2026-06-20 -> Context: Fix sticky TOC going out of view + heading anchor links not working on charte/licence/apropos pages +> Last updated: 2026-06-21 +> Context: Add SQLite indexes for contenus page language/tag query performance + fix soft-deleted thesis count filtering ## In Progress +- [x] #contenus-indexes Add index on thesis_languages(language_id) + tags(deleted_at, name); fix count queries to exclude soft-deleted theses `(Database.php, DatabaseMigrations.php, schema.sql, migrations/applied/041_thesis_languages_index.sql)` ✓ +## Completed + +- [x] #contenus-indexes Add index on thesis_languages(language_id) + tags(deleted_at, name); fix count queries to exclude soft-deleted theses `(Database.php, DatabaseMigrations.php, schema.sql, migrations/applied/041_thesis_languages_index.sql)` ✓ +- [x] #peertube-orphans-check Add Peertube orphan video check + relink in admin — listChannelVideos (PeerTubeService), peertube-orphans.php endpoint, UI in nettoyage dialog, relink support on edit page (peertube-relink.php, peertube-browser.php, fichiers-fragment.php, file-upload-filepond.js) ✓ ## Pending - [ ] #overtype-analysis Analyse and fix OverType editor reliability on contenus-edit.php @@ -17,12 +22,18 @@ - [x] #apropos-toc-style Fix TOC "Parties" label: Ductus font + lowercase, remove border-left from links, match global link style; rename .apropos-content → section.content, .apropos-section → .content-section, remove .prose wrapper `(apropos.css, about.php, charte.php, licence.php)` ✓ - [x] #apropos-toc-confirm Fixed sticky TOC: removed `flex: 1; min-height: 0` on main for apropos-body so the sticky container is full content height; added `max-height` + `overflow-y: auto` to TOC for long lists `(apropos.css)` ✓ +- [ ] #contact-test-manual Test contact decoupling end-to-end: student submission → admin edit → public TFE display - [ ] #aria-test-manual Test WCAG changes with VoiceOver and NVDA on full add/edit/partage form flows - [ ] #nojs-upload-test Test end-to-end: submit partage form with JS disabled, verify files arrive via `$_FILES` - [ ] #csp-media-iframe-deploy Deploy nginx config fix to server, test PDF iframe on /tfe?id=221 +- [x] #fix-finality-types Create standalone script + just command to rename finality types (Approfondi→Approfondie, Enseignement→Didactique, Spécialisé→Spécialisée) `(scripts/fix-finality-types.php, justfile)` ✓ +- [x] #context-note-synopsis Display contextual note above synopsis (italic) instead of in meta column on TFE page `(tfe.php, tfe.css)` ✓ + ## Completed +- [x] #decouple-contacts Decouple contact_visible (public) & contact_interne (private email): backend already decoupled; made contact_public checkbox functional in admin add/edit forms; contact_public now controls TFE page visibility `(FormBootstrap.php, ThesisCreateController.php, ThesisEditController.php, tfe.php, form.php)` ✓ + - [x] #csrf-rotation-race Stop CSRF token rotation in draft.php + remove hx-post from
— both broke FilePond uploads and form submission `(admin/actions/draft.php, partage/fragments/draft.php, FormBootstrap.php, pill-search.js)` ✓ - [x] ~~#filepond-csrf-stale~~ (superseded by #csrf-rotation-race) - [x] #adminold-return-type Fix adminOld closure return type from `:string` to `:string|array` `(FormBootstrap.php)` ✓ diff --git a/app/migrations/applied/041_thesis_languages_index.sql b/app/migrations/applied/041_thesis_languages_index.sql new file mode 100644 index 0000000..a33bd69 --- /dev/null +++ b/app/migrations/applied/041_thesis_languages_index.sql @@ -0,0 +1,16 @@ +-- 041: Add index on thesis_languages(language_id) for contenus page performance. +-- +-- The contenus page queries getAllLanguagesWithCount() and searchLanguages() join +-- languages → thesis_languages on language_id. SQLite's PRIMARY KEY on +-- (thesis_id, language_id) cannot optimize a lookup by language_id alone, so +-- it builds an AUTOMATIC COVERING INDEX per query. This persisted index +-- removes that overhead. +-- +-- Also adds a covering index on tags(deleted_at, name) to accelerate +-- getAllTagsWithCount() and searchTags() ORDER BY clauses (avoiding temp B-tree). + +CREATE INDEX IF NOT EXISTS idx_thesis_languages_language + ON thesis_languages(language_id); + +CREATE INDEX IF NOT EXISTS idx_tags_deleted_name + ON tags(deleted_at, name); diff --git a/app/public/admin/actions/cleanup-stats-fragment.php b/app/public/admin/actions/cleanup-stats-fragment.php new file mode 100644 index 0000000..bdc3c28 --- /dev/null +++ b/app/public/admin/actions/cleanup-stats-fragment.php @@ -0,0 +1,91 @@ + 0 || ($d['filepond_active_count'] ?? 0) > 0; +$hasTrash = ($d['trash_stale_count'] ?? 0) > 0 || ($d['trash_active_count'] ?? 0) > 0; + +if (!$hasFilePond && !$hasTrash): ?> +

✓ Aucun fichier temporaire.

+ + + 0): ?> +

+ + Téléversements abandonnés dossier(s) · +

+ + + + + + + + + + + + +
NomTailleÂge
~ min + +
+ + + 0): ?> +

+ + Corbeille fichier(s) · +

+ + + + + + + + + + + + +
NomTailleÂge
~ j + +
+ + + 0 || ($d['trash_active_count'] ?? 0) > 0): ?> +

Conservés : + + +

+ diff --git a/app/public/admin/actions/cleanup-tmp.php b/app/public/admin/actions/cleanup-tmp.php index df0e443..eaae11d 100644 --- a/app/public/admin/actions/cleanup-tmp.php +++ b/app/public/admin/actions/cleanup-tmp.php @@ -43,6 +43,55 @@ $details = []; $db = new Database(); $pdo = $db->getPDO(); +// ── Individual deletion mode ──────────────────────────────────────────── +$individualFilepond = trim($_POST['filepond_dir'] ?? ''); +$individualTrash = trim($_POST['trash_file'] ?? ''); + +if ($individualFilepond !== '' || $individualTrash !== '') { + if ($individualFilepond !== '') { + $dirPath = $filepondDir . '/' . basename($individualFilepond); + if (is_dir($dirPath) && str_starts_with(realpath($dirPath), realpath($filepondDir))) { + rmdirRecursive($dirPath); + $filepondRemoved = 1; + $details[] = "filepond/$individualFilepond: suppression manuelle"; + } else { + $errors[] = 'Dossier introuvable : ' . htmlspecialchars($individualFilepond); + } + } + if ($individualTrash !== '') { + $filePath = $trashDir . '/' . basename($individualTrash); + if (is_file($filePath) && str_starts_with(realpath($filePath), realpath($trashDir))) { + if (@unlink($filePath)) { + $trashRemoved = 1; + $details[] = "_trash/$individualTrash: suppression manuelle"; + } else { + $errors[] = 'Impossible de supprimer : ' . htmlspecialchars($individualTrash); + } + } else { + $errors[] = 'Fichier introuvable : ' . htmlspecialchars($individualTrash); + } + } + + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + + // HTMX request: re-render the fragment + if (isset($_SERVER['HTTP_HX_REQUEST'])) { + header('HX-Trigger: refreshStats'); + require __DIR__ . '/cleanup-stats-fragment.php'; + exit; + } + + header('Content-Type: application/json; charset=utf-8'); + echo json_encode([ + 'success' => true, + 'filepond_removed' => $filepondRemoved, + 'trash_removed' => $trashRemoved, + 'errors' => $errors, + 'details' => $details, + ]); + exit; +} + // ── Determine PHP session save path ────────────────────────────────────── $sessionSavePath = session_save_path(); if (!$sessionSavePath || $sessionSavePath === '') { diff --git a/app/public/admin/actions/peertube-delete.php b/app/public/admin/actions/peertube-delete.php new file mode 100644 index 0000000..aad5cbf --- /dev/null +++ b/app/public/admin/actions/peertube-delete.php @@ -0,0 +1,72 @@ + false, 'error' => 'CSRF invalide.']); + exit; +} + +$uuid = trim($_POST['uuid'] ?? ''); +if ($uuid === '' || !preg_match('/^[a-zA-Z0-9\-_]+$/', $uuid)) { + http_response_code(400); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['success' => false, 'error' => 'UUID invalide.']); + exit; +} + +require_once APP_ROOT . '/src/Database.php'; +require_once APP_ROOT . '/src/PeerTubeService.php'; + +$db = new Database(); + +if (!PeerTubeService::isConfigured($db)) { + http_response_code(503); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['success' => false, 'error' => 'PeerTube non configuré.']); + exit; +} + +// Also remove any stale DB references to this UUID +$pdo = $db->getConnection(); +$stmt = $pdo->prepare( + "SELECT id FROM thesis_files WHERE file_path = ?" +); +$stmt->execute(['peertube_ids:' . $uuid]); +$dbRefs = $stmt->fetchAll(PDO::FETCH_COLUMN); +$dbCleaned = count($dbRefs); +foreach ($dbRefs as $id) { + $pdo->prepare("DELETE FROM thesis_files WHERE id = ?")->execute([$id]); +} + +$deleted = PeerTubeService::deleteVideo($db, $uuid); + +$_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + +if ($deleted) { + error_log("[peertube-delete] uuid=$uuid deleted" . ($dbCleaned > 0 ? " + $dbCleaned DB ref(s) cleaned" : "")); + if (isset($_SERVER['HTTP_HX_REQUEST'])) { + require __DIR__ . '/peertube-orphans-fragment.php'; + exit; + } + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['success' => true]); +} else { + error_log("[peertube-delete] uuid=$uuid delete failed" . ($dbCleaned > 0 ? " (cleaned $dbCleaned DB refs though)" : "")); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['success' => false, 'error' => 'Échec de la suppression sur PeerTube (vérifiez les logs).']); +} +exit; diff --git a/app/public/admin/actions/peertube-orphans-fragment.php b/app/public/admin/actions/peertube-orphans-fragment.php new file mode 100644 index 0000000..82d375d --- /dev/null +++ b/app/public/admin/actions/peertube-orphans-fragment.php @@ -0,0 +1,102 @@ + +
+
+ Vidéos PeerTube orphelines +
+

⚠️ PeerTube non configuré.

+
+
+
+ + + +
+
+ Vidéos PeerTube orphelines +
+

+
+
+
+ + +
+
+ Vidéos PeerTube orphelines vidéos · liées +
+ 0): ?> + + + + + + + + + + + +
NomDate
+ + + + +
+ +

✓ Aucune vidéo orpheline.

+ +
+
+ + 0): ?> +
+ Références DB obsolètes +

Ces UUID sont référencés en base de données mais n'existent plus sur la chaîne PeerTube. Les TFE liés affichent des liens morts.

+ + + + + + + + + + +
UUIDTFE(s)
+ + ' . htmlspecialchars($label) . ''; + }, $s['theses'])) ?> + +
+
+ +
diff --git a/app/public/admin/actions/peertube-orphans.php b/app/public/admin/actions/peertube-orphans.php new file mode 100644 index 0000000..c482697 --- /dev/null +++ b/app/public/admin/actions/peertube-orphans.php @@ -0,0 +1,121 @@ + false, + 'error' => 'PeerTube non configuré.', + ]); + exit; +} + +// ── Collect all Peertube UUIDs linked in the DB ────────────────────────── +$pdo = $db->getPDO(); +$dbUuids = []; +$linkedMap = []; // uuid → [thesis_id, thesis_title, thesis_identifier] + +$stmt = $pdo->query( + "SELECT tf.file_path, tf.file_name, t.id AS thesis_id, t.title, t.identifier + FROM thesis_files tf + JOIN theses t ON t.id = tf.thesis_id + WHERE tf.file_path LIKE 'peertube_ids:%' + AND t.deleted_at IS NULL" +); +while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { + $uuid = substr($row['file_path'], strlen('peertube_ids:')); + $dbUuids[$uuid] = true; + $linkedMap[$uuid][] = [ + 'thesis_id' => (int)$row['thesis_id'], + 'title' => $row['title'], + 'identifier' => $row['identifier'] ?? '', + ]; +} + +// ── List all channel videos ────────────────────────────────────────────── +try { + $channelVideos = PeerTubeService::listChannelVideos($db); +} catch (\Throwable $e) { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode([ + 'configured' => true, + 'error' => 'Erreur lors du listage des vidéos : ' . $e->getMessage(), + ]); + exit; +} + +// ── Find orphans: on channel but not in DB ─────────────────────────────── +$orphans = []; +$linked = []; +foreach ($channelVideos as $v) { + $uuid = $v['shortUUID'] ?: $v['uuid']; + if ($uuid === '') { + continue; + } + if (isset($dbUuids[$uuid])) { + $linked[] = [ + 'uuid' => $uuid, + 'name' => $v['name'], + 'theses' => $linkedMap[$uuid] ?? [], + ]; + } else { + $orphans[] = [ + 'uuid' => $uuid, + 'name' => $v['name'], + 'createdAt' => $v['createdAt'], + ]; + } +} + +// ── Find stale DB entries: in DB but not on channel ────────────────────── +$stale = []; +foreach ($dbUuids as $uuid => $_) { + $found = false; + foreach ($channelVideos as $v) { + if (($v['shortUUID'] ?: $v['uuid']) === $uuid) { + $found = true; + break; + } + } + if (!$found) { + $stale[] = [ + 'uuid' => $uuid, + 'theses' => $linkedMap[$uuid] ?? [], + ]; + } +} + +$totalOnChannel = count($channelVideos); + +header('Content-Type: application/json; charset=utf-8'); +echo json_encode([ + 'configured' => true, + 'channel_name' => PeerTubeService::getSettings($db)['channel_name'], + 'total_on_channel' => $totalOnChannel, + 'total_linked' => count($linked), + 'orphan_count' => count($orphans), + 'orphans' => $orphans, + 'stale_count' => count($stale), + 'stale_entries' => $stale, +]); diff --git a/app/public/admin/actions/peertube-relink.php b/app/public/admin/actions/peertube-relink.php new file mode 100644 index 0000000..a8a9d9e --- /dev/null +++ b/app/public/admin/actions/peertube-relink.php @@ -0,0 +1,126 @@ + false, 'error' => $message]); + exit; +} + +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + peertubeRelinkError(405, 'Méthode non autorisée.'); +} + +$csrfHeader = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; +if (!isset($_SESSION['csrf_token']) + || !hash_equals($_SESSION['csrf_token'], $csrfHeader)) { + peertubeRelinkError(403, 'Token CSRF invalide.'); +} + +$body = json_decode(file_get_contents('php://input'), true); +if (!is_array($body)) { + peertubeRelinkError(400, 'JSON invalide.'); +} + +$thesisId = filter_var($body['thesis_id'] ?? '', FILTER_VALIDATE_INT); +$uuid = trim($body['uuid'] ?? ''); + +if (!$thesisId || $uuid === '') { + peertubeRelinkError(400, 'Paramètres invalides (thesis_id + uuid requis).'); +} + +// Validate UUID format (shortUUID or full UUID) +if (!preg_match('/^[a-zA-Z0-9\-_]+$/', $uuid)) { + peertubeRelinkError(400, 'UUID invalide.'); +} + +require_once APP_ROOT . '/src/Database.php'; +require_once APP_ROOT . '/src/PeerTubeService.php'; + +$db = new Database(); + +if (!PeerTubeService::isConfigured($db)) { + peertubeRelinkError(503, 'PeerTube non configuré.'); +} + +// Check thesis exists +$thesis = $db->getThesis($thesisId); +if (!$thesis) { + peertubeRelinkError(404, 'TFE introuvable.'); +} + +// Check this UUID is not already linked to this thesis +$pdo = $db->getConnection(); +$stmt = $pdo->prepare( + "SELECT id FROM thesis_files + WHERE thesis_id = ? AND file_path = ?" +); +$stmt->execute([$thesisId, 'peertube_ids:' . $uuid]); +if ($stmt->fetch()) { + peertubeRelinkError(409, 'Cette vidéo est déjà liée à ce TFE.'); +} + +// Verify the video exists on the channel +$info = PeerTubeService::fetchVideoInfo($db, $uuid); +if ($info === null) { + peertubeRelinkError(404, 'Vidéo introuvable sur PeerTube.'); +} + +// Verify it's not already linked to another TFE +$stmt = $pdo->prepare( + "SELECT t.identifier FROM thesis_files tf + JOIN theses t ON t.id = tf.thesis_id + WHERE tf.file_path = ? AND t.deleted_at IS NULL" +); +$stmt->execute(['peertube_ids:' . $uuid]); +$existing = $stmt->fetch(); +if ($existing) { + peertubeRelinkError(409, + 'Cette vidéo est déjà liée au TFE ' . htmlspecialchars($existing['identifier'] ?? '?') + . '. Dé-liez-la d\'abord avant de la relier à un autre.' + ); +} + +// Determine file type from PeerTube info +$videoName = $info['name'] ?? $uuid; +$fileType = 'video'; // default +$catId = (int)($info['category']['id'] ?? 0); +if ($catId === 16) { + $fileType = 'audio'; +} + +$db->insertThesisFile( + $thesisId, + $fileType, + 'peertube_ids:' . $uuid, + $videoName, + 0, // size unknown (not on disk) + 'video/mp4', // PeerTube streams HLS, mime is nominal + null, + null +); + +$newId = $pdo->lastInsertId(); + +error_log("[peertube-relink] thesis_id=$thesisId uuid=$uuid file_type=$fileType new_id=$newId"); + +header('Content-Type: application/json'); +echo json_encode([ + 'ok' => true, + 'id' => (int)$newId, + 'message' => 'Vidéo PeerTube reliée avec succès.', +]); +exit; diff --git a/app/public/admin/add.php b/app/public/admin/add.php index 4d09a76..eaee12d 100644 --- a/app/public/admin/add.php +++ b/app/public/admin/add.php @@ -24,6 +24,12 @@ try { $formData = $_SESSION['form_data'] ?? []; unset($_SESSION['form_data']); +// Default values for new TFE creation (no prior form data) +if (empty($formData)) { + $formData['exemplaire_baiu'] = true; + $formData['exemplaire_erg'] = true; +} + $siteSettings = Database::getInstance()->getAllSettings(); $helpBlocks = Database::getInstance()->getAllFormHelpBlocks(); diff --git a/app/public/admin/fragments/peertube-browser.php b/app/public/admin/fragments/peertube-browser.php new file mode 100644 index 0000000..82eb538 --- /dev/null +++ b/app/public/admin/fragments/peertube-browser.php @@ -0,0 +1,112 @@ +PeerTube non configuré.

'; + exit; +} + +$thesisId = isset($_GET['thesis_id']) ? (int)$_GET['thesis_id'] : 0; + +// ── Collect already-linked UUIDs for the current thesis (to exclude them) ─ +$pdo = $db->getConnection(); +$stmt = $pdo->prepare( + "SELECT file_path FROM thesis_files + WHERE thesis_id = ? AND file_path LIKE 'peertube_ids:%'" +); +$stmt->execute([$thesisId]); +$linkedToThis = []; +while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { + $linkedToThis[] = substr($row['file_path'], strlen('peertube_ids:')); +} + +// ── Collect all DB-linked UUIDs (any thesis that isn't soft-deleted) ───── +$stmt = $pdo->query( + "SELECT tf.file_path, t.identifier + FROM thesis_files tf + JOIN theses t ON t.id = tf.thesis_id + WHERE tf.file_path LIKE 'peertube_ids:%' + AND t.deleted_at IS NULL" +); +$dbLinked = []; // uuid → identifier +while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { + $uuid = substr($row['file_path'], strlen('peertube_ids:')); + $dbLinked[$uuid] = $row['identifier']; +} + +// ── List channel videos ───────────────────────────────────────────────── +try { + $channelVideos = PeerTubeService::listChannelVideos($db); +} catch (\Throwable $e) { + echo '

Erreur : ' . htmlspecialchars($e->getMessage()) . '

'; + exit; +} + +// ── Build orphan list ──────────────────────────────────────────────────── +$orphans = []; +foreach ($channelVideos as $v) { + $uuid = $v['shortUUID'] ?: $v['uuid']; + if ($uuid === '') { + continue; + } + // Skip if already linked to THIS thesis + if (in_array($uuid, $linkedToThis, true)) { + continue; + } + // Mark as linked-to-other if already in DB (different thesis) + $linkedTo = $dbLinked[$uuid] ?? null; + $orphans[] = [ + 'uuid' => $uuid, + 'name' => $v['name'], + 'createdAt' => $v['createdAt'], + 'linkedTo' => $linkedTo, + ]; +} + +if (empty($orphans)) { + echo '

Aucune vidéo orpheline trouvée. Toutes les vidéos de la chaîne sont déjà liées.

'; + exit; +} +?> +
+

+ vidéo(s) orpheline(s) sur la chaîne. + Cliquez pour relier à ce TFE. +

+ +
diff --git a/app/public/assets/css/admin.css b/app/public/assets/css/admin.css index 2aeb5c2..d26d435 100644 --- a/app/public/assets/css/admin.css +++ b/app/public/assets/css/admin.css @@ -948,7 +948,7 @@ th.admin-ap-col { border-bottom: 1px solid var(--border-primary); } -.admin-dialog__header h2 { +.admin-dialog__header h3 { font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; @@ -1014,6 +1014,9 @@ th.admin-ap-col { padding: var(--space-m) var(--space-l); font-size: var(--step--1); line-height: 1.6; + overflow-y: auto; + flex: 1; + min-height: 0; } .admin-dialog__body > *:first-child { @@ -1045,6 +1048,116 @@ th.admin-ap-col { padding: 0 var(--space-l) var(--space-m); } +/* Side-panel variant: pinned to right, full height */ +.admin-dialog--sheet { + max-width: 720px; + max-height: 100vh; + height: 100vh; + margin: 0 0 0 auto; + border-radius: 16px 0 0 16px; + animation: adminSheetIn 0.3s ease-out; + overflow: hidden; +} +.admin-dialog--sheet[open] { + display: flex; + flex-direction: column; +} +@keyframes adminSheetIn { + from { transform: translateX(100%); } + to { transform: translateX(0); } +} +.admin-dialog--sheet::backdrop { + background: rgba(0,0,0,0.5); +} + +/* Dialog table: replaces grey background box with flat table + heading */ +.n-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85em; + margin: var(--space-xs) 0 var(--space-md) 0; +} +.n-table thead th { + text-align: left; + padding: var(--space-3xs) var(--space-xs); + border-bottom: 1px solid var(--border-primary); + font-weight: 600; + color: var(--text-secondary); + font-size: 0.9em; +} +.n-table tbody td { + padding: var(--space-3xs) var(--space-xs); + border-bottom: 1px solid var(--border-secondary); + vertical-align: top; +} +.n-table .n-table__info { + color: var(--text-tertiary); + font-size: 0.9em; +} + +.n-heading { + display: inline-flex; + align-items: center; + gap: var(--space-2xs); + margin: 0 0 var(--space-xs) 0; + font-size: var(--step--1); + font-weight: 600; + color: var(--text-primary); + line-height: 1.4; +} +.n-meta { + font-weight: 400; + font-size: 0.9em; + color: var(--text-secondary); + margin-left: var(--space-2xs); +} + +.n-grid { + display: block; +} +.n-section, +.n-grid > section, +.n-grid > details { + margin: 2ch 0; +} +.n-section:first-child, +.n-grid > section:first-child, +.n-grid > details:first-child { + margin-top: 0; +} +.n-section:last-child, +.n-grid > section:last-child, +.n-grid > details:last-child { + margin-bottom: 0; +} +.n-grid .n-heading { + margin-top: 0; +} + +/*
/ inside cleanup sections */ +.n-grid > details > summary { + cursor: pointer; + font-weight: 600; + font-size: var(--step--1); + color: var(--text-primary); + padding: var(--space-xs) 0; + list-style: none; +} +.n-grid > details > summary::-webkit-details-marker { + display: none; +} +.n-grid > details > summary::before { + content: '▸ '; + display: inline-block; + transition: transform 0.15s; +} +.n-grid > details[open] > summary::before { + transform: rotate(90deg); +} +.n-grid > details > :not(summary) { + padding-left: calc(1ch + var(--space-xs)); +} + /* ── Import results log ─────────────────────────────────────────────── */ .admin-import-log { list-style: none; diff --git a/app/public/assets/css/apropos.css b/app/public/assets/css/apropos.css index 777cb5e..fcb83a4 100644 --- a/app/public/assets/css/apropos.css +++ b/app/public/assets/css/apropos.css @@ -6,6 +6,11 @@ /* Keep the base flex layout (no html/body overrides) — main scrolls internally so the body gradient stays at viewport bottom. */ +/* Override inherited padding on main-content (apropós, licence, charte) */ +#main-content { + padding-top: 0; +} + .page-content { flex: 1; min-height: 0; @@ -30,7 +35,7 @@ .apropos-toc-label { font-family: var(--font-display); - font-size: var(--step--2); + font-size: var(--step-1); font-weight: 400; color: var(--text-primary); margin: 0 0 var(--space-2xs) 0; @@ -49,8 +54,9 @@ .apropos-toc ul a { font-family: var(--font-body); - font-size: var(--step--1); - color: var(--text-secondary); + font-size: var(--step-0); + font-weight: 300; + color: var(--text-primary); text-decoration: none; display: block; padding: var(--space-3xs) 0; @@ -112,6 +118,10 @@ word-break: break-word; } +.content { + padding-bottom: var(--space-xl); +} + .content p, .content-section p { margin: 0 0 1em 0; @@ -127,6 +137,11 @@ margin: 1.5em 0 0.5em 0; } +.content :where(h1, h2, h3):first-child, +.content-section :where(h1, h2, h3):first-child { + margin-top: 2.2rem; +} + .content a, .content-section a { color: inherit; diff --git a/app/public/assets/css/base.css b/app/public/assets/css/base.css index b3297c1..8de1e5d 100644 --- a/app/public/assets/css/base.css +++ b/app/public/assets/css/base.css @@ -62,9 +62,15 @@ main * { } /* Global heading scale — used by admin + public pages */ +h1 { font-size: var(--step-4); } +h2 { font-size: var(--step-3); } +h3 { font-size: var(--step-2); } +h4 { font-size: var(--step-1); } +h5 { font-size: var(--step-0); } +h6 { font-size: var(--step--1); } + :where(h1, h2, h3, h4, h5, h6) { font-family: var(--font-display); - font-size: var(--step-2); font-weight: 400; margin: 0 0 var(--space-l) 0; line-height: 1.15; diff --git a/app/public/assets/css/components/header.css b/app/public/assets/css/components/header.css index f082813..efef98c 100644 --- a/app/public/assets/css/components/header.css +++ b/app/public/assets/css/components/header.css @@ -8,12 +8,11 @@ header { vertical-align: center; flex-shrink: 0; - background: #9557B5; + background: #c05de1; background: linear-gradient( 0deg, - rgba(149, 87, 181, 1) 0%, - rgba(192, 93, 225, 1) 25%, - rgba(51, 191, 135, 1) 75%, + rgba(192, 93, 225, 1) 0%, + rgba(51, 191, 135, 1) 66%, rgba(60, 133, 108, 1) 100% ); } @@ -56,9 +55,6 @@ header nav ul a:hover { header nav ul a[aria-current="page"] { color: var(--accent-primary); - text-shadow: - 0 0 4px white, - 0 0 8px white; border-radius: 0; border-bottom: 2px solid currentColor; padding-bottom: 1px; diff --git a/app/public/assets/css/components/search.css b/app/public/assets/css/components/search.css index 21ef117..c4aac58 100644 --- a/app/public/assets/css/components/search.css +++ b/app/public/assets/css/components/search.css @@ -6,7 +6,8 @@ .header-search-wrap { padding: 0; flex-shrink: 0; - background: linear-gradient(180deg, #9557B5 0%, #ffffffee 100%); + background: #C05DE1; + background: linear-gradient(180deg, rgba(192, 93, 225, 1) 0%, rgba(255, 255, 255, 1) 100%); } .header-search-form { width: 100%; } diff --git a/app/public/assets/css/repertoire.css b/app/public/assets/css/repertoire.css index ed7b9d8..a760c8f 100644 --- a/app/public/assets/css/repertoire.css +++ b/app/public/assets/css/repertoire.css @@ -83,6 +83,7 @@ text-transform: uppercase; color: var(--text-primary); font-weight: 398; + line-height: 23px; margin: 0; padding: var(--space-xs) 0 var(--space-3xs) 0; border-bottom: 1px solid var(--text-primary); @@ -150,7 +151,7 @@ /* Years column — big numbers, semi-bold (BBBDMSans Medium weight) */ .repertoire-col[data-col="years"] .rep-entry { font-size: var(--step-3); - font-weight: 498; + font-weight: 300; line-height: 1.1; letter-spacing: -0.02em; padding: var(--space-3xs) 0; diff --git a/app/public/assets/css/tfe.css b/app/public/assets/css/tfe.css index e8b96cb..ee5a92a 100644 --- a/app/public/assets/css/tfe.css +++ b/app/public/assets/css/tfe.css @@ -76,6 +76,25 @@ font-style: italic; } +/* Synopsis column wrapper (context note + synopsis) */ +.tfe-synopsis-column { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +/* Contextual note above synopsis */ +.tfe-context-note { + font-style: italic; + font-size: var(--step--1); + line-height: 1.6; + color: var(--text-secondary); + margin: 0; + padding: var(--space-xs) var(--space-s); + background: color-mix(in srgb, var(--accent-primary) 10%, var(--bg-primary)); + border-radius: var(--radius); +} + .tfe-synopsis-empty { /* placeholder to maintain grid column */ } diff --git a/app/public/assets/js/app/file-upload-filepond.js b/app/public/assets/js/app/file-upload-filepond.js index e0e773a..e9ec181 100644 --- a/app/public/assets/js/app/file-upload-filepond.js +++ b/app/public/assets/js/app/file-upload-filepond.js @@ -948,4 +948,110 @@ bodyEl.innerHTML = '

Erreur réseau.

'; }); }; + + // PeerTube video relink (edit page — binds a channel-orphan video to the thesis) + window.XamxamRelinkPeerTube = (el) => { + var li = el.closest(".file-browser-entry"); + if (!li) return; + + var uuid = li.dataset.ptUuid; + var name = li.dataset.ptName; + if (!uuid) return; + + var ctx = window.__xamxamPeertubeRelinkCtx || {}; + var thesisId = ctx.thesisId; + if (!thesisId) return; + + var bodyEl = document.getElementById("peertube-relink-modal-body"); + if (bodyEl) + bodyEl.innerHTML = + '

Reliage en cours…

'; + + var csrfToken = + document + .querySelector('meta[name="csrf-token"]') + ?.getAttribute("content") || ""; + + fetch("/admin/actions/peertube-relink.php", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": csrfToken, + }, + body: JSON.stringify({ + thesis_id: parseInt(thesisId, 10), + uuid: uuid, + }), + }) + .then((r) => + r.json().then((data) => ({ ok: r.ok, status: r.status, data })), + ) + .then(({ ok, status, data }) => { + if (!ok || (data && data.ok === false)) { + var msg = data?.error + ? data.error + : typeof data === "string" + ? data + : "Erreur " + status; + if (bodyEl) + bodyEl.innerHTML = '

Erreur : ' + msg + '

'; + return; + } + console.log("[pt-relink] success | new_id=" + data.id); + + var input = document.querySelector( + '.tfe-file-picker[data-queue-type="tfe"]', + ); + var closeAndRefresh = () => { + var modal = document.getElementById("peertube-relink-modal"); + if (modal) modal.close(); + var block = document.getElementById("format-fichiers-block"); + if (block && window.htmx) { + var url = "/admin/fragments/fichiers.php"; + if (thesisId) + url += "?_thesis_id=" + encodeURIComponent(thesisId); + htmx.ajax("GET", url, { + target: "#format-fichiers-block", + swap: "outerHTML", + }); + } + }; + if (input) { + var pond = FilePond.find(input); + if (pond) { + pond + .addFile(String(data.id), { + type: "limbo", + file: { + name: name, + size: 0, + type: "video/mp4", + }, + }) + .then(() => { + console.log("[pt-relink] addFile resolved"); + closeAndRefresh(); + }) + .catch((err) => { + console.error("[pt-relink] addFile rejected", err); + closeAndRefresh(); + }); + } else { + console.error("[pt-relink] FilePond.find returned null"); + closeAndRefresh(); + } + } else { + console.warn("[pt-relink] input not found"); + closeAndRefresh(); + } + + window.__xamxamDirty = true; + }) + .catch((err) => { + console.error("[pt-relink] fetch error", err); + if (bodyEl) + bodyEl.innerHTML = + '

Erreur réseau.

'; + }); + }; })(); diff --git a/app/src/Controllers/ExportController.php b/app/src/Controllers/ExportController.php index 1a115ab..1737752 100644 --- a/app/src/Controllers/ExportController.php +++ b/app/src/Controllers/ExportController.php @@ -274,6 +274,9 @@ class ExportController 'Licence', 'Points sur 20', 'Lien BAIU', + 'CC2r', + 'Exemplaire BAIU', + 'Exemplaire ERG', ]; /** @@ -390,6 +393,9 @@ class ExportController $t['license_name'] ?? '', isset($t['jury_points']) ? (string) $t['jury_points'] : '', $t['baiu_link'] ?? '', + !empty($t['cc2r']) ? 'Oui' : 'Non', + !empty($t['exemplaire_baiu']) ? 'Oui' : 'Non', + !empty($t['exemplaire_erg']) ? 'Oui' : 'Non', ]; } diff --git a/app/src/Controllers/ThesisCreateController.php b/app/src/Controllers/ThesisCreateController.php index 3bdea40..0816213 100644 --- a/app/src/Controllers/ThesisCreateController.php +++ b/app/src/Controllers/ThesisCreateController.php @@ -339,8 +339,13 @@ class ThesisCreateController if ($contactVisible === '') { $contactVisible = trim($post['mail'] ?? ''); } - // showContact: whether to show the contact publicly - if (array_key_exists('contact_public', $post)) { + // showContact: whether to show the contact publicly on the TFE page. + // In admin mode, the checkbox always renders — unchecked = not sent. + // In student mode (partage), the checkbox is not shown, so we + // default to true when contact_visible is filled. + if ($adminMode) { + $showContact = !empty($post['contact_public']); + } elseif (array_key_exists('contact_public', $post)) { $showContact = !empty($post['contact_public']); } else { $showContact = $contactVisible !== ''; diff --git a/app/src/Controllers/ThesisEditController.php b/app/src/Controllers/ThesisEditController.php index 6b31829..d65a8fa 100644 --- a/app/src/Controllers/ThesisEditController.php +++ b/app/src/Controllers/ThesisEditController.php @@ -249,6 +249,8 @@ class ThesisEditController // contact_interne = private email of the first author (backoffice field) $contactInterne = trim($post['contact_interne'] ?? ''); $firstAuthorEmail = $contactInterne !== '' ? $contactInterne : null; + // contact_public: whether to show the public contact on the TFE page + $showContact = !empty($post['contact_public']); $authorNames = []; if ($authorsRaw !== '') { $authorNames = array_values(array_filter(array_map('trim', explode(',', $authorsRaw)), fn ($n) => $n !== '')); @@ -259,7 +261,7 @@ class ThesisEditController $authorEntries[] = [ 'name' => $name, 'email' => $i === 0 ? $firstAuthorEmail : null, - 'show_contact' => $i === 0, + 'show_contact' => $i === 0 && $showContact, ]; } $this->db->setThesisAuthors($thesisId, $authorEntries); diff --git a/app/src/Database.php b/app/src/Database.php index 08b032c..9fbeb87 100644 --- a/app/src/Database.php +++ b/app/src/Database.php @@ -1297,9 +1297,10 @@ class Database if ($query === '') { $stmt = $this->pdo->query(' SELECT tg.id, tg.name, - COUNT(DISTINCT tt.thesis_id) as thesis_count + COUNT(DISTINCT t.id) as thesis_count FROM tags tg LEFT JOIN thesis_tags tt ON tg.id = tt.tag_id + LEFT JOIN theses t ON tt.thesis_id = t.id AND t.deleted_at IS NULL WHERE tg.deleted_at IS NULL GROUP BY tg.id ORDER BY thesis_count DESC, tg.name COLLATE NOCASE @@ -1308,9 +1309,10 @@ class Database } else { $stmt = $this->pdo->prepare(' SELECT tg.id, tg.name, - COUNT(DISTINCT tt.thesis_id) as thesis_count + COUNT(DISTINCT t.id) as thesis_count FROM tags tg LEFT JOIN thesis_tags tt ON tg.id = tt.tag_id + LEFT JOIN theses t ON tt.thesis_id = t.id AND t.deleted_at IS NULL WHERE tg.name LIKE ? AND tg.deleted_at IS NULL GROUP BY tg.id ORDER BY tg.name = ? DESC, thesis_count DESC, tg.name COLLATE NOCASE @@ -1328,9 +1330,10 @@ class Database { $stmt = $this->pdo->query(' SELECT tg.id, tg.name, - COUNT(DISTINCT tt.thesis_id) as thesis_count + COUNT(DISTINCT t.id) as thesis_count FROM tags tg LEFT JOIN thesis_tags tt ON tg.id = tt.tag_id + LEFT JOIN theses t ON tt.thesis_id = t.id AND t.deleted_at IS NULL WHERE tg.deleted_at IS NULL GROUP BY tg.id ORDER BY tg.name COLLATE NOCASE @@ -1416,9 +1419,10 @@ class Database $stmt = $this->pdo->query(' SELECT MIN(l.id) as id, UPPER(SUBSTR(MIN(l.name),1,1)) || SUBSTR(MIN(l.name),2) as name, - COUNT(DISTINCT tl.thesis_id) as thesis_count + COUNT(DISTINCT t.id) as thesis_count FROM languages l LEFT JOIN thesis_languages tl ON l.id = tl.language_id + LEFT JOIN theses t ON tl.thesis_id = t.id AND t.deleted_at IS NULL WHERE l.deleted_at IS NULL GROUP BY LOWER(l.name) ORDER BY thesis_count DESC, LOWER(MIN(l.name)) COLLATE NOCASE @@ -1428,9 +1432,10 @@ class Database $stmt = $this->pdo->prepare(' SELECT MIN(l.id) as id, UPPER(SUBSTR(MIN(l.name),1,1)) || SUBSTR(MIN(l.name),2) as name, - COUNT(DISTINCT tl.thesis_id) as thesis_count + COUNT(DISTINCT t.id) as thesis_count FROM languages l LEFT JOIN thesis_languages tl ON l.id = tl.language_id + LEFT JOIN theses t ON tl.thesis_id = t.id AND t.deleted_at IS NULL WHERE LOWER(l.name) LIKE LOWER(?) AND l.deleted_at IS NULL GROUP BY LOWER(l.name) ORDER BY LOWER(MIN(l.name)) = LOWER(?) DESC, thesis_count DESC, LOWER(MIN(l.name)) COLLATE NOCASE @@ -1450,9 +1455,10 @@ class Database $stmt = $this->pdo->query(' SELECT MIN(l.id) as id, UPPER(SUBSTR(MIN(l.name),1,1)) || SUBSTR(MIN(l.name),2) as name, - COUNT(DISTINCT tl.thesis_id) as thesis_count + COUNT(DISTINCT t.id) as thesis_count FROM languages l LEFT JOIN thesis_languages tl ON l.id = tl.language_id + LEFT JOIN theses t ON tl.thesis_id = t.id AND t.deleted_at IS NULL WHERE l.deleted_at IS NULL GROUP BY LOWER(l.name) ORDER BY LOWER(MIN(l.name)) COLLATE NOCASE @@ -2683,7 +2689,10 @@ class Database t.context_note, t.remarks, t.jury_points, - t.baiu_link + t.baiu_link, + t.exemplaire_baiu, + t.exemplaire_erg, + t.cc2r 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 diff --git a/app/src/DatabaseMigrations.php b/app/src/DatabaseMigrations.php index 9025a9d..9df878e 100644 --- a/app/src/DatabaseMigrations.php +++ b/app/src/DatabaseMigrations.php @@ -24,6 +24,48 @@ class DatabaseMigrations $this->migrateRenameFinalityTypes(); $this->migrateShareLinksNameColumn(); $this->migrateShareLinksLockedYearColumn(); + $this->migrateThesisLanguagesIndex(); + $this->migrateTagsDeletedNameIndex(); + } + + /** + * 2026-06-21 — Add index on thesis_languages(language_id). + * + * The contenus page queries join languages → thesis_languages on + * language_id. SQLite's PRIMARY KEY on (thesis_id, language_id) + * can't optimize lookups by language_id alone, so it builds an + * AUTOMATIC COVERING INDEX per query. This persisted index + * removes that overhead. + */ + private function migrateThesisLanguagesIndex(): void + { + try { + $this->pdo->exec( + 'CREATE INDEX IF NOT EXISTS idx_thesis_languages_language + ON thesis_languages(language_id)' + ); + } catch (\PDOException $e) { + // Table may not exist yet on fresh install — ignore + } + } + + /** + * 2026-06-21 — Add covering index on tags(deleted_at, name). + * + * getAllTagsWithCount() filters on deleted_at IS NULL and orders + * by name. This covering index avoids a SCAN of the tags table + * and a temp B-tree for sorting. + */ + private function migrateTagsDeletedNameIndex(): void + { + try { + $this->pdo->exec( + 'CREATE INDEX IF NOT EXISTS idx_tags_deleted_name + ON tags(deleted_at, name)' + ); + } catch (\PDOException $e) { + // Table may not exist yet on fresh install — ignore + } } /** @@ -31,7 +73,7 @@ class DatabaseMigrations * * Spécialisé → Spécialisée * Approfondi → Approfondie - * Enseignement → Didactique + * Didactique → Enseignement */ private function migrateRenameFinalityTypes(): void { @@ -39,7 +81,7 @@ class DatabaseMigrations $renames = [ 'Spécialisé' => 'Spécialisée', 'Approfondi' => 'Approfondie', - 'Enseignement' => 'Didactique', + 'Didactique' => 'Enseignement', ]; foreach ($renames as $old => $new) { // Skip if only canonical row already exists diff --git a/app/src/Form/FormBootstrap.php b/app/src/Form/FormBootstrap.php index 566405c..889fbb3 100644 --- a/app/src/Form/FormBootstrap.php +++ b/app/src/Form/FormBootstrap.php @@ -108,7 +108,7 @@ class FormBootstrap $autofocusField = App::consumeAutofocus(); // Controls - $showContact = false; + $showContact = ($mode === 'add' || $mode === 'edit'); $showBackoffice = ($mode === 'add' || $mode === 'edit'); // Licence / access toggles: admin always enables all three @@ -208,7 +208,7 @@ class FormBootstrap // Backoffice (empty for add, populated for edit by caller) 'currentRaw' => [], 'contactInterne' => null, - 'contactPublic' => false, + 'contactPublic' => null, 'currentContextNote' => null, 'currentContactVisible' => null, 'currentDurationValue' => null, diff --git a/app/src/PeerTubeService.php b/app/src/PeerTubeService.php index 1a52a09..9d1e03e 100644 --- a/app/src/PeerTubeService.php +++ b/app/src/PeerTubeService.php @@ -265,6 +265,74 @@ class PeerTubeService return rtrim($s['instance_url'], '/') . '/videos/watch/' . $uuid; } + // ------------------------------------------------------------------------- + // List channel videos + // ------------------------------------------------------------------------- + + /** + * List all videos uploaded by the authenticated user account. + * + * Uses GET /api/v1/users/me/videos which reliably returns only the + * authenticated user's own videos (all on the configured channel). + * The channel-based endpoints proved unreliable on this instance. + * + * @return array + */ + public static function listChannelVideos(Database $db): array + { + $s = self::getSettings($db); + if ($s['instance_url'] === '') { + return []; + } + + $videos = []; + $count = 100; + $start = 0; + + try { + $token = self::obtainToken($s); + $baseUrl = rtrim($s['instance_url'], '/'); + + while (true) { + $url = $baseUrl . '/api/v1/users/me/videos?count=' . $count + . '&start=' . $start . '&sort=-createdAt'; + $resp = self::httpRequest($url, 'GET', [ + 'headers' => ['Authorization' => 'Bearer ' . $token], + 'timeout' => 30, + ]); + + if ($resp['status'] !== 200) { + error_log('PeerTubeService::listChannelVideos failed: status=' . $resp['status']); + break; + } + + $json = json_decode($resp['body'], true); + if (!is_array($json) || !isset($json['data'])) { + break; + } + + foreach ($json['data'] as $v) { + $videos[] = [ + 'uuid' => $v['uuid'] ?? '', + 'shortUUID' => $v['shortUUID'] ?? '', + 'name' => $v['name'] ?? '', + 'createdAt' => $v['createdAt'] ?? '', + ]; + } + + $total = (int)($json['total'] ?? 0); + if ($start + $count >= $total) { + break; + } + $start += $count; + } + } catch (\Throwable $e) { + error_log('PeerTubeService::listChannelVideos exception: ' . $e->getMessage()); + } + + return $videos; + } + // ------------------------------------------------------------------------- // Delete // ------------------------------------------------------------------------- diff --git a/app/storage/schema.sql b/app/storage/schema.sql index ff1a9cc..3534f62 100644 --- a/app/storage/schema.sql +++ b/app/storage/schema.sql @@ -386,10 +386,14 @@ CREATE INDEX IF NOT EXISTS idx_thesis_authors_author ON thesis_authors(author_id CREATE INDEX IF NOT EXISTS idx_thesis_authors_thesis ON thesis_authors(thesis_id); +CREATE INDEX IF NOT EXISTS idx_thesis_languages_language ON thesis_languages(language_id); + CREATE INDEX IF NOT EXISTS idx_thesis_tags_tag ON thesis_tags(tag_id); CREATE INDEX IF NOT EXISTS idx_thesis_tags_thesis ON thesis_tags(thesis_id); +CREATE INDEX IF NOT EXISTS idx_tags_deleted_name ON tags(deleted_at, name); + -- ============================================================================ -- VIEWS -- ============================================================================ @@ -531,7 +535,7 @@ INSERT OR IGNORE INTO ap_programs (name, code) VALUES ('Lieux, Interdisciplinari INSERT OR IGNORE INTO ap_programs (name, code) VALUES ('Pratique de l''art - outils critiques, arts et contexte simultanés', 'PACS'); INSERT OR IGNORE INTO finality_types (name) VALUES ('Approfondie'); -INSERT OR IGNORE INTO finality_types (name) VALUES ('Didactique'); +INSERT OR IGNORE INTO finality_types (name) VALUES ('Enseignement'); INSERT OR IGNORE INTO finality_types (name) VALUES ('Spécialisée'); INSERT OR IGNORE INTO languages (name) VALUES ('français'); diff --git a/app/templates/admin/index.php b/app/templates/admin/index.php index c603758..2f73478 100644 --- a/app/templates/admin/index.php +++ b/app/templates/admin/index.php @@ -41,7 +41,7 @@ document.addEventListener('htmx:afterSwap',()=>{document.querySelectorAll('input 0): ?> @@ -97,289 +97,14 @@ document.addEventListener('htmx:afterSwap',()=>{document.querySelectorAll('input - + + + + - - -
-

Aucune sélection

- -
-
-

Sélectionnez au moins un TFE avant d'effectuer une action groupée.

-
- -
+ - - -
-

Confirmation

- -
-
-

TFE(s) ?

-
- -
- - - -
-

Supprimer des TFE

- -
-
-

Supprimer définitivement TFE(s) ? Cette action est irréversible.

-
- -
- - - -
-

Supprimer ce TFE

- -
-
-

Supprimer «  » ? Cette action est irréversible.

-
- -
- - - -
-

Importer une liste de TFE

- -
- - -
- - - - -

- -
- - - - -
- Logs d'importation ( entrées) -
    - -
  • - -
-
- - - - - - -
- -
- - - 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
- Quatre premières lignes ignorées — Séparateur : virgule — UTF-8 -
-
-
- - - - - -
- Logs d'importation ( entrées) -
    - -
  • - -
-
- - -
- - - -
-

Nettoyer les fichiers temporaires

- -
-
-

Les fichiers temporaires s'accumulent lorsque des téléversements sont abandonnés (formulaire fermé avant envoi).

-
- Chargement… -
-

- Seuls les fichiers de plus de 2 heures (FilePond) et 30 jours (corbeille) seront supprimés. - Les téléversements récents sont conservés. -

- - -
- -
- - + diff --git a/app/templates/admin/partials/dialogs/bulk-confirm.php b/app/templates/admin/partials/dialogs/bulk-confirm.php new file mode 100644 index 0000000..aab5210 --- /dev/null +++ b/app/templates/admin/partials/dialogs/bulk-confirm.php @@ -0,0 +1,14 @@ + +
+

Confirmation

+ +
+
+

TFE(s) ?

+
+ +
diff --git a/app/templates/admin/partials/dialogs/bulk-delete.php b/app/templates/admin/partials/dialogs/bulk-delete.php new file mode 100644 index 0000000..f3dd2ef --- /dev/null +++ b/app/templates/admin/partials/dialogs/bulk-delete.php @@ -0,0 +1,14 @@ + +
+

Supprimer des TFE

+ +
+
+

Supprimer définitivement TFE(s) ? Cette action est irréversible.

+
+ +
diff --git a/app/templates/admin/partials/dialogs/delete-thesis.php b/app/templates/admin/partials/dialogs/delete-thesis.php new file mode 100644 index 0000000..a7cda55 --- /dev/null +++ b/app/templates/admin/partials/dialogs/delete-thesis.php @@ -0,0 +1,14 @@ + +
+

Supprimer ce TFE

+ +
+
+

Supprimer «  » ? Cette action est irréversible.

+
+ +
diff --git a/app/templates/admin/partials/dialogs/import.php b/app/templates/admin/partials/dialogs/import.php new file mode 100644 index 0000000..357b5cf --- /dev/null +++ b/app/templates/admin/partials/dialogs/import.php @@ -0,0 +1,78 @@ + +
+

Importer une liste de TFE

+ +
+ + +
+ + + + +

+ +
+ + + + +
+ Logs d'importation ( entrées) +
    + +
  • + +
+
+ + + +
+ + +
+ +
+ + + 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
+ Quatre premières lignes ignorées — Séparateur : virgule — UTF-8 +
+
+
+ + +
+ + +
+ Logs d'importation ( entrées) +
    + +
  • + +
+
+ + +
diff --git a/app/templates/admin/partials/dialogs/no-selection.php b/app/templates/admin/partials/dialogs/no-selection.php new file mode 100644 index 0000000..74c3de4 --- /dev/null +++ b/app/templates/admin/partials/dialogs/no-selection.php @@ -0,0 +1,13 @@ + +
+

Aucune sélection

+ +
+
+

Sélectionnez au moins un TFE avant d'effectuer une action groupée.

+
+ +
diff --git a/app/templates/admin/partials/dialogs/tmp-cleanup.php b/app/templates/admin/partials/dialogs/tmp-cleanup.php new file mode 100644 index 0000000..6191bf8 --- /dev/null +++ b/app/templates/admin/partials/dialogs/tmp-cleanup.php @@ -0,0 +1,29 @@ + +
+

Nettoyer les fichiers temporaires

+ +
+
+ +
+ +
+ Fichiers temporaires +
+

Chargement…

+
+
+ +
+

Chargement…

+
+
+
+
diff --git a/app/templates/partials/form/fichiers-fragment.php b/app/templates/partials/form/fichiers-fragment.php index a0f4218..8f3748e 100644 --- a/app/templates/partials/form/fichiers-fragment.php +++ b/app/templates/partials/form/fichiers-fragment.php @@ -100,7 +100,7 @@ $websiteLabel = htmlspecialchars($_POST['website_label'] ?? ''); hx-swap="innerHTML" hx-trigger="click" onclick="document.getElementById('relink-modal').showModal(); window.__xamxamRelinkCtx = { queueType: 'cover', thesisId: '' };"> - 📂 Relier un fichier existant + Relier un fichier existant @@ -128,7 +128,7 @@ $websiteLabel = htmlspecialchars($_POST['website_label'] ?? ''); hx-swap="innerHTML" hx-trigger="click" onclick="document.getElementById('relink-modal').showModal(); window.__xamxamRelinkCtx = { queueType: 'note_intention', thesisId: '' };"> - 📂 Relier un fichier existant + Relier un fichier existant @@ -166,8 +166,20 @@ $websiteLabel = htmlspecialchars($_POST['website_label'] ?? ''); hx-swap="innerHTML" hx-trigger="click" onclick="document.getElementById('relink-modal').showModal(); window.__xamxamRelinkCtx = { queueType: 'tfe', thesisId: '' };"> - 📂 Relier un fichier existant + Relier un fichier existant + + + @@ -195,7 +207,7 @@ $websiteLabel = htmlspecialchars($_POST['website_label'] ?? ''); hx-swap="innerHTML" hx-trigger="click" onclick="document.getElementById('relink-modal').showModal(); window.__xamxamRelinkCtx = { queueType: 'annexe', thesisId: '' };"> - 📂 Relier un fichier existant + Relier un fichier existant @@ -219,4 +231,22 @@ $websiteLabel = htmlspecialchars($_POST['website_label'] ?? ''); + + + + + + + + + diff --git a/app/templates/partials/form/form.php b/app/templates/partials/form/form.php index cc50cbe..c3b2aeb 100644 --- a/app/templates/partials/form/form.php +++ b/app/templates/partials/form/form.php @@ -32,7 +32,7 @@ * bool $showContact — Contact checkbox fieldset * bool $showCoverPreview — cover image preview + remove checkbox * bool $showExistingFiles — existing thesis files list (deletable) - * bool $showBackoffice — Backoffice fieldset (context_note, jury_points, remarks, baiu_link, exemplaires, contact_visible, contact_interne, is_published) + * bool $showBackoffice — Backoffice fieldset (context_note, jury_points, remarks, baiu_link, exemplaires, contact_interne, is_published) * bool $showEmailConfirmation — E-mail de confirmation fieldset * string $helpFn — fn(string $key): string (for help blocks) @@ -45,7 +45,7 @@ * array $currentFiles — existing thesis files for edit mode * ?string $currentContextNote — existing context note for edit mode * array $currentRaw — raw thesis row for edit mode - * ?string $contactPublic — contact visibility flag for edit mode + * ?bool $contactPublic — contact visibility flag for edit mode * ?string $contactInterne — contact email for edit mode * * Autosave: @@ -455,7 +455,7 @@ if ($filesMode === 'add'): ?> - -
- +
+ +

Note Contextuelle :

+ + + +
+ +
+ +
+
- -
-
diff --git a/justfile b/justfile index 9b1db6a..0465e6f 100644 --- a/justfile +++ b/justfile @@ -76,6 +76,8 @@ deploy-code: --exclude 'storage/backups/' \ --exclude 'storage/logs/' \ --exclude 'var/' \ + --exclude 'composer.json' \ + --exclude 'composer.lock' \ app/ xamxam:/var/www/xamxam/ # Deploy nginx config + fix permissions + reload (single server-side run) rsync -v nginx/xamxam.conf xamxam:/tmp/xamxam.conf @@ -442,6 +444,12 @@ query: backup: @sqlite3 app/storage/xamxam.db .dump > app/storage/backup_$(date +%Y%m%d_%H%M%S).sql +[group('database')] +fix-finality-types: + # Rename finality types from old forms to canonical names + # Approfondi → Approfondie, Didactique → Enseignement, Spécialisé → Spécialisée + @php scripts/fix-finality-types.php + [group('database')] backup-snapshot: # Hot backup using SQLite's .backup API (WAL-safe), then gzip. diff --git a/scripts/fix-finality-types.php b/scripts/fix-finality-types.php new file mode 100755 index 0000000..286a0f0 --- /dev/null +++ b/scripts/fix-finality-types.php @@ -0,0 +1,71 @@ +#!/usr/bin/env php + 1) { + $dbPath = $argv[1]; +} elseif (file_exists($root . '/app/storage/xamxam.db')) { + $dbPath = $root . '/app/storage/xamxam.db'; +} elseif (file_exists($root . '/storage/xamxam.db')) { + $dbPath = $root . '/storage/xamxam.db'; +} else { + die("Database not found. Pass path as argument.\n"); +} + +if (!file_exists($dbPath)) { + die("Database not found: $dbPath\n"); +} + +$pdo = new PDO('sqlite:' . $dbPath); +$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + +$renames = [ + 'Approfondi' => 'Approfondie', + 'Didactique' => 'Enseignement', + 'Spécialisé' => 'Spécialisée', +]; + +foreach ($renames as $old => $new) { + // Check if old name exists + $oldId = $pdo->query("SELECT id FROM finality_types WHERE name = '$old'")->fetchColumn(); + if (!$oldId) { + echo " [skip] '$old' not found\n"; + continue; + } + + // Get or create canonical row + $newId = $pdo->query("SELECT id FROM finality_types WHERE name = '$new'")->fetchColumn(); + if (!$newId) { + $pdo->exec("INSERT INTO finality_types (name) VALUES ('$new')"); + $newId = $pdo->lastInsertId(); + } + + // Relink theses from old to new + $updated = $pdo->exec(" + UPDATE theses SET finality_id = $newId + WHERE finality_id = $oldId + "); + echo " Relinked $updated thesis(es) from '$old' → '$new'\n"; + + // Delete old row + $pdo->exec("DELETE FROM finality_types WHERE id = $oldId"); + echo " Deleted '$old' (id=$oldId)\n"; +} + +echo "Done.\n";