From 03c9c3566ff33ad3dd2c192438f1bd0819177aca Mon Sep 17 00:00:00 2001
From: Pontoporeia
Date: Sun, 21 Jun 2026 13:33:55 +0200
Subject: [PATCH] Add SQLite indexes for contenus page language/tag queries +
WIP: Peertube orphans, dialogs, contact decoupling, context note, finality
types
---
TODO.md | 15 +-
.../applied/041_thesis_languages_index.sql | 16 +
.../admin/actions/cleanup-stats-fragment.php | 91 ++++++
app/public/admin/actions/cleanup-tmp.php | 49 +++
app/public/admin/actions/peertube-delete.php | 72 +++++
.../actions/peertube-orphans-fragment.php | 102 +++++++
app/public/admin/actions/peertube-orphans.php | 121 ++++++++
app/public/admin/actions/peertube-relink.php | 126 ++++++++
app/public/admin/add.php | 6 +
.../admin/fragments/peertube-browser.php | 112 +++++++
app/public/assets/css/admin.css | 115 ++++++-
app/public/assets/css/apropos.css | 21 +-
app/public/assets/css/base.css | 8 +-
app/public/assets/css/components/header.css | 10 +-
app/public/assets/css/components/search.css | 3 +-
app/public/assets/css/repertoire.css | 3 +-
app/public/assets/css/tfe.css | 19 ++
.../assets/js/app/file-upload-filepond.js | 106 +++++++
app/src/Controllers/ExportController.php | 6 +
.../Controllers/ThesisCreateController.php | 9 +-
app/src/Controllers/ThesisEditController.php | 4 +-
app/src/Database.php | 23 +-
app/src/DatabaseMigrations.php | 46 ++-
app/src/Form/FormBootstrap.php | 4 +-
app/src/PeerTubeService.php | 68 +++++
app/storage/schema.sql | 6 +-
app/templates/admin/index.php | 289 +-----------------
.../admin/partials/dialogs/bulk-confirm.php | 14 +
.../admin/partials/dialogs/bulk-delete.php | 14 +
.../admin/partials/dialogs/delete-thesis.php | 14 +
.../admin/partials/dialogs/import.php | 78 +++++
.../admin/partials/dialogs/no-selection.php | 13 +
.../admin/partials/dialogs/tmp-cleanup.php | 29 ++
.../partials/form/fichiers-fragment.php | 38 ++-
app/templates/partials/form/form.php | 10 +-
app/templates/public/tfe.php | 26 +-
justfile | 8 +
scripts/fix-finality-types.php | 71 +++++
38 files changed, 1432 insertions(+), 333 deletions(-)
create mode 100644 app/migrations/applied/041_thesis_languages_index.sql
create mode 100644 app/public/admin/actions/cleanup-stats-fragment.php
create mode 100644 app/public/admin/actions/peertube-delete.php
create mode 100644 app/public/admin/actions/peertube-orphans-fragment.php
create mode 100644 app/public/admin/actions/peertube-orphans.php
create mode 100644 app/public/admin/actions/peertube-relink.php
create mode 100644 app/public/admin/fragments/peertube-browser.php
create mode 100644 app/templates/admin/partials/dialogs/bulk-confirm.php
create mode 100644 app/templates/admin/partials/dialogs/bulk-delete.php
create mode 100644 app/templates/admin/partials/dialogs/delete-thesis.php
create mode 100644 app/templates/admin/partials/dialogs/import.php
create mode 100644 app/templates/admin/partials/dialogs/no-selection.php
create mode 100644 app/templates/admin/partials/dialogs/tmp-cleanup.php
create mode 100755 scripts/fix-finality-types.php
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
';
+ 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;
+}
+?>
+
+
+ = count($orphans) ?> vidéo(s) orpheline(s) sur la chaîne.
+ Cliquez pour relier à ce TFE.
+
+
+
+
+
+ >
+
+
+
+ = htmlspecialchars($v['name']) ?>
+ = !empty($v['createdAt']) ? substr($v['createdAt'], 0, 10) : '' ?>
+
+ (= htmlspecialchars($v['linkedTo']) ?>)
+
+
+
+
+
+
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): ?>
+ onclick="document.getElementById('tmp-cleanup-dialog').showModal(); htmx.trigger('#tmp-cleanup-stats','loadStats'); htmx.trigger('#peertube-orphans-wrapper','loadPeertube')">
Nettoyer (= $tmpTotalCount ?>)
@@ -97,289 +97,14 @@ document.addEventListener('htmx:afterSwap',()=>{document.querySelectorAll('input
-
+
+
+
+
-
-
-
-
-
Sélectionnez au moins un TFE avant d'effectuer une action groupée.
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
Supprimer définitivement TFE(s) ? Cette action est irréversible.
-
-
-
-
-
-
-
-
-
Supprimer « » ? Cette action est irréversible.
-
-
-
-
-
-
-
-
-
-
-
-
-
⚠ Erreurs :
-
-
- = htmlspecialchars($err) ?>
-
-
-
-
-
-
✓ = htmlspecialchars($importMessage) ?>
-
-
-
-
-
-
-
- Logs d'importation (= count($importResults) ?> entrées)
-
-
- = htmlspecialchars($r['msg']) ?>
-
-
-
-
-
-
-
-
-
-
- Logs d'importation (= count($importResults) ?> entrées)
-
-
- = htmlspecialchars($r['msg']) ?>
-
-
-
-
-
-
-
-
-
-
-
-
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 @@
+
+
+
+
+
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 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 « » ? 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 @@
+
+
+
+
+
+
+
+
⚠ Erreurs :
+
+
+ = htmlspecialchars($err) ?>
+
+
+
+
+
+
✓ = htmlspecialchars($importMessage) ?>
+
+
+
+
+
+
+
+ Logs d'importation (= count($importResults) ?> entrées)
+
+
+ = htmlspecialchars($r['msg']) ?>
+
+
+
+
+
+
+
+
+
+
+ Logs d'importation (= count($importResults) ?> entrées)
+
+
+ = htmlspecialchars($r['msg']) ?>
+
+
+
+
+
+
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 @@
+
+
+
+
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 @@
+
+
+
+
+
+
+
+ Fichiers temporaires
+
+
+
+
+
+
+
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: '= htmlspecialchars((string)($thesisId ?? $_GET['id'] ?? '')) ?>' };">
- 📂 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: '= htmlspecialchars((string)($thesisId ?? $_GET['id'] ?? '')) ?>' };">
- 📂 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: '= htmlspecialchars((string)($thesisId ?? $_GET['id'] ?? '')) ?>' };">
- 📂 Relier un fichier existant
+ Relier un fichier existant
+
+
+ Relier une vidéo PeerTube
+
+
@@ -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: '= htmlspecialchars((string)($thesisId ?? $_GET['id'] ?? '')) ?>' };">
- 📂 Relier un fichier existant
+ Relier un fichier existant
@@ -219,4 +231,22 @@ $websiteLabel = htmlspecialchars($_POST['website_label'] ?? '');
+
+
+
+
+
+
+
Chargement des vidéos orphelines…
+
+
+
+
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'): ?>
>
-
Valeur :
+
Valeur :
var hidden = document.getElementById('duration_value');
var intWrap = document.getElementById('duration-value-integer');
var intInput = document.getElementById('duration_value_int');
+ var intLabel = document.getElementById('duration-value-label');
var timeWrap = document.getElementById('duration-value-time');
var hInput = document.getElementById('duration_h');
var mInput = document.getElementById('duration_m');
var sInput = document.getElementById('duration_s');
+
+ var LABELS = { pages: 'Nombre :', mo: 'Taille :', 'durée': 'Durée :' };
if (!unit || !hidden) return;
function updateHidden() {
@@ -673,6 +676,7 @@ if ($filesMode === 'add'): ?>
} else {
timeWrap.style.display = 'none';
intWrap.style.display = '';
+ if (intLabel) intLabel.textContent = LABELS[unit.value] || 'Valeur :';
}
updateHidden();
}
diff --git a/app/templates/public/tfe.php b/app/templates/public/tfe.php
index 5153e44..1d5dddc 100644
--- a/app/templates/public/tfe.php
+++ b/app/templates/public/tfe.php
@@ -207,14 +207,8 @@
-
-
- Note contextuelle relative à soutenance :
- = nl2br(htmlspecialchars($data["context_note"])) ?>
-
-
-
+
Contact :
-
-
- = nl2br(htmlspecialchars($data["synopsis"])) ?>
+
+
+
Note Contextuelle : = nl2br(htmlspecialchars($data["context_note"])) ?>
+
+
+
+
+ = nl2br(htmlspecialchars($data["synopsis"])) ?>
+
+
+
+
-
-
-
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";