Notes des deux correctifs du 2026-08-27 + CLAUDE.md

- NEWSLETTER-RETOURS-2026-08-27.md : retour du commanditaire sur la newsletter
  de juillet (annonces à venir absentes, dates de publication affichées à la
  place des dates d'événement), vérification dans le code et implémentation.
- NOTE-SLUG-NUMERIQUE-404-2026-08-27.md : annonces publiées répondant 404
  (slug numérique incompatible avec /%category%/%postname%/), diagnostic et
  implémentation.
- CLAUDE.md : nouveau module inc/post-slug-guard.php, piège du titre vide dans
  post-title-required.php, et bascule de la newsletter sur la date d'événement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w7RzAHv4r1Yahnm2NwDDG
This commit is contained in:
2026-08-27 17:48:18 +02:00
co-authored by Claude Opus 5
parent a1d862328c
commit e64a44a275
3 changed files with 442 additions and 2 deletions
+3 -2
View File
@@ -37,7 +37,7 @@ Timber/Twig. Chaque `*.php` charge un Twig de `templates/`. `base.twig` est le l
### Plugins maison
- **`thalim-hal-importer/`** — import publications HAL (structure 254015). Admin : Outils → HAL Import. Voir le README du plugin pour le mapping doc types → catégories (10 types : ART, COUV, OUV, COMM, ISSUE, PROCEEDINGS, THESE, HDR, SON, VIDEO). Les catégories sont résolues **par slug** et les IDs Pods **par nom** ; toute écriture de relation Pods passe par `includes/class-pods-storage.php` (dépendance dure à Pods 3.x documentée dans le fichier — ne pas écrire `wp_podsrel` ailleurs).
- **`thalim-newsletter/`** — composition et export HTML des digests mensuels. Admin : Outils → Newsletter. Les constantes `THALIM_NL_CAT_*` sont résolues par slug au chargement (transient 1 j, fallback IDs historiques) ; le pod/champ `categorie` est résolu par nom dans `class-admin-page.php`.
- **`thalim-newsletter/`** — composition et export HTML des digests mensuels. Admin : Outils → Newsletter. Les constantes `THALIM_NL_CAT_*` sont résolues par slug au chargement (transient 1 j, fallback IDs historiques) ; le pod/champ `categorie` est résolu par nom dans `class-admin-page.php`. Éligibilité, tri et affichage reposent sur la **date d'événement** (`date_de_debut` > `datetime` > `post_date`), jamais sur la date de publication ; les contenus au-delà du mois sont proposés dans un bloc « À venir » (horizon 12 mois), décochés par défaut. Voir le README du plugin.
### Plugins WP requis
@@ -91,7 +91,8 @@ Les annonces ont une `date_de_debut` / `date_de_fin` (champ date Pods) ou un `da
| `post-card-helpers.php` | `thalim_get_card_data($post_id)` / `thalim_get_cards_data($posts)` : données pour `partials/post-card.twig`. Inclut la résolution catégorie parente pour le code couleur (`parent_slug`), la première image (medium), la `card_event_date`, et la redirection `#seance-{ID}` pour les séances de séminaire |
| `pods-conditional-required.php` | Patch : Pods n'évalue pas sa propre logique conditionnelle côté serveur lors de la validation des champs *required*. On la rejoue dans `pods_api_pre_save_pod_item_post` et on désactive `required` sur les champs masqués |
| `pods-save-error-handler.php` | Quand Pods déclenche un `wp_die()` à la sauvegarde admin, intercepter (`pods_error_die`), stocker tous les `pods_meta_*` dans un transient, rediriger vers `post.php?action=edit`, annuler le statut si le post passait à `publish`, et restaurer les champs côté React via `get_post_metadata` + JS dans `admin_footer`. **Mécanisme partagé** avec : |
| `post-title-required.php` | Force un titre non-vide à la sauvegarde, réutilise le même mécanisme transient/restore |
| `post-title-required.php` | Force un titre non-vide à la sauvegarde, réutilise le même mécanisme transient/restore. **Attention** : le post est bien écrit avec son titre vide avant d'être annulé — c'est là que le cœur fige un slug numérique, d'où le vidage de `post_name` dans `pods-save-error-handler.php` |
| `post-slug-guard.php` | Suffixe les slugs purement numériques des `post`/`page` (`1917``1917-2`). Avec la structure `/%category%/%postname%/`, un dernier segment numérique est lu comme un numéro de page par la règle `(.+?)/([^/]+)(?:/([0-9]+))?/?$` : l'article répond 404 tout en restant listé partout. Ne touche pas aux `nav_menu_item` ni aux `attachment`, dont les slugs numériques sont normaux |
| `admin-users-filter.php` | Dropdown « Statut » sur `users.php`, filtre via meta `role_1`/`role_2`/`role_3` |
### Avatars (chaîne de fallback)
+202
View File
@@ -0,0 +1,202 @@
# Newsletter — retour commanditaire du 27/08/2026
> Note de travail rédigée le 2026-08-27, **avant toute modification du code**.
> Contexte : la newsletter de juillet 2026 est la première réellement envoyée.
> Le commanditaire remonte deux bugs à corriger.
> Plugin concerné : `wp-data/wp-content/plugins/thalim-newsletter/`
> (état au commit `021f322`).
## 1. Le mail, en clair
Deux demandes :
1. **Les annonces futures n'apparaissent pas dans la liste à cocher du backoffice.**
Pour la NL de juillet, ils ont dû créer les newsletters d'août, septembre et
octobre uniquement pour aller y récupérer des portions de HTML et les recoller
à la main dans celle de juillet (ex. : la séance du séminaire Thalim du
9 octobre). Demande : **lister toutes les annonces à venir, sans limite
d'horizon**. Les filtres sur le passé, eux, leur conviennent.
2. **Les dates affichées sont les dates de publication, pas les dates de
l'événement annoncé.** Deux exemples cités :
- l'appel à contribution « La reconnaissance des modernes… » affiche
« 7 juillet » (sa date de publication) au lieu de « Jusqu'au 1 septembre 2026 » ;
- les événements culturels affichent la date de publication et non la date
prévue (ex. la visite insolite du 8 octobre).
Les deux points sont **confirmés dans le code** (vérifications ci-dessous, faites
sur le dump de dev daté du 2026-07-03).
## 2. Demande 1 — horizon futur
### Ce que fait le code
Chaque catégorie a une « fenêtre » `[début, fin]`
(`includes/class-post-query.php:21-38`). Un item n'est retenu pour le mois M que si :
```
début_fenêtre <= fin_du_mois ET fin_fenêtre >= début_du_mois
```
(`includes/class-post-query.php:303-304`)
C'est la condition **`début_fenêtre <= fin_du_mois`** qui coupe tout le futur.
| Catégories | Fenêtre | Horizon futur max (NL de juillet) |
|---|---|---|
| Colloques (10), Communications (13) | `date_de_debut 35 j → date_de_fin` | ~**35 jours** |
| Séances de séminaire (sous 11) | `[1er du mois, fin du mois + 5 j]` | **5 jours** (`SEANCE_WINDOW_MARGIN_DAYS`, l. 38 et 203) |
| Appels (8), Soutenances (14) | `datetime → date_de_fin` | dépend de `datetime`, **vide en pratique** → retombe sur `post_date` |
| Ouvrages (15), Articles (16) | `datetime → +3 mois` | idem, piloté par la publication |
| **Toutes les autres** (dont Événements culturels 18, Médias 19, Captations 23…) | `datetime → +35 j` | idem, piloté par la publication |
La séance du 9 octobre citée dans le mail correspond exactement au cas
`SEANCE_WINDOW_MARGIN_DAYS = 5` : elle ne pouvait pas sortir dans la NL de juillet.
### Nuance importante
Pour les catégories où `datetime` n'est jamais renseigné (18, 19, 14…), la fenêtre
est en réalité calculée sur la **date de publication**. Conséquence : un événement
d'octobre publié en juin apparaît bien dans la NL de juin/juillet (mais avec la
mauvaise date), alors que le même événement publié en septembre n'apparaîtra jamais
dans celle de juillet. L'éligibilité est donc incohérente des deux côtés.
### Volume
Au 3 juillet 2026 (date du dump), seules **5 annonces** avaient une date
d'événement dans le futur. Ouvrir tout le futur ne fait donc pas exploser la liste,
mais le nombre grimpera en pleine saison (rentrée).
## 3. Demande 2 — dates affichées
### Ce que fait le code
`format_date_for_category()` (`includes/class-html-exporter.php:542`) ne lit
`date_de_debut` / `date_de_fin` **que** pour les catégories en fenêtre
`debut_minus35_to_fin` (colloques et communications). Pour toutes les autres, il lit
`datetime`, sinon `post_date`.
### Ce que dit la base (posts publiés depuis 2025)
| Catégorie | ont `datetime` | ont `date_de_debut` | ont `date_de_fin` |
|---|---|---|---|
| Événements culturels (18) | **0 / 73** | 69 / 73 | 67 / 73 |
| Soutenances (14) | 0 / 17 | 17 / 17 | 17 / 17 |
| Médias (19) | 0 / 48 | 21 / 48 | 21 / 48 |
| Appels à contribution (8) | 0 / 18 | 1 / 18 | 1 / 18 |
Donc pour les événements culturels, c'est **systématiquement** la date de
publication qui sort. Idem pour les soutenances. Sur les appels récents, `datetime`
vaut souvent littéralement `0000-00-00`, et la date limite est bien stockée dans
`date_de_fin` — champ utilisé pour l'éligibilité mais **jamais affiché**, et sans
préfixe « Jusqu'au ».
### Trois conséquences supplémentaires, non mentionnées dans le mail
- **Le tri interne à chaque section** utilise la même date fausse
(`ORDER BY`, `includes/class-post-query.php:305`) : les événements culturels sont
classés par date de publication.
- **Le backoffice affiche la bonne date, l'email la mauvaise** : la pastille de la
liste à cocher (`includes/class-admin-page.php:356`) lit bien `date_de_debut` en
premier. D'où la surprise à la lecture du mail réellement envoyé.
- **Le thème a déjà la logique attendue** : `thalim_get_agenda_card_data()`
(`wp-data/wp-content/themes/thalim/inc/ajax.php:180-215`) produit exactement
« Le X de H1 à H2 » / « Du X au Y » / « Jusqu'au X », sur la convention
`date_de_debut > datetime > post_date`. Le plugin ne la réutilise pas : il a
réimplémenté une version partielle.
## 4. Modifications à faire
1. **Requête — supprimer le plafond futur.**
Garder le critère de fin (ne pas ressortir ce qui est terminé avant le mois),
mais accepter n'importe quelle date de début à venir : lever la condition
`début_fenêtre <= fin_du_mois`, et pour les séances remplacer
`[début du mois, fin du mois + 5 j]` par `[début du mois, +∞[`.
2. **Dates — aligner sur la convention du thème.**
`date_de_debut > datetime > post_date` pour **toutes** les catégories, et
reprendre les libellés de `thalim_get_agenda_card_data()` : « Du … au … »,
« Jusqu'au … » quand seule `date_de_fin` existe (cas des appels), heures quand
elles sont renseignées. Le plus propre est de **factoriser** cette logique
plutôt que de la dupliquer une troisième fois (thème + exporter + hint admin).
3. **Tri** — trier par date d'événement réelle, pas par `datetime` / `post_date`.
## 5. Points à arbitrer avec le commanditaire
- **Cochage par défaut.** Aujourd'hui, sur un mois neuf, *tout est coché*
(`render_sections_html()`, `$check_all`). Si on injecte tout le futur, la NL de
juillet partirait pré-cochée avec des événements de mars suivant.
Proposition : passé et mois courant cochés par défaut, futur au-delà du mois
listé mais **décoché**, dans un sous-bloc « À venir » visuellement distinct.
C'est ce qui change le plus leur geste quotidien → à valider avant de coder.
- **« Jusqu'au » sur les appels** suppose que `date_de_fin` soit saisie côté
rédaction. Sur les appels publiés depuis janvier 2026, un seul sur sept l'a.
Sans ce champ, aucune date correcte n'est récupérable et on retombera sur la date
de publication → à leur signaler comme consigne de saisie.
## 6. Repères techniques
- Dump de dev utilisé pour les vérifications : contenu jusqu'au **2026-07-03**
(le post « La reconnaissance des modernes… » n'y est donc pas ; l'analyse porte
sur la logique, pas sur ce post précis).
- Newsletters enregistrées en base de dev : avril, mai, juin 2026.
- Conteneur de base : `thalim-dev-db-1` (`docker compose up -d db`).
- Catégories vérifiées en base le 2026-08-27 : les IDs historiques du README
(8, 10, 11, 12, 13, 14, 15, 16, 18, 19, 20) sont toujours exacts.
À noter : `Séance de séminaire` (12) a pour parent `Séminaires` (11), pas 0.
## 7. Au passage (hors périmètre du retour)
- `PARENT_COLORS` (`includes/class-html-exporter.php:35-41`) est encore indexé sur
des term_ids en dur (1, 3, 4, 5, 6), contrairement à la convention du projet
(résolution par slug). À traiter si on repasse dans ce fichier.
## 8. Implémentation — 2026-08-27
Faite dans `thalim-plugin-newsletter`.
**Les trois modifications demandées :**
1. **Horizon futur ouvert** — le plafond « début de fenêtre ≤ fin du mois » est
remplacé par une fenêtre « à venir » de **12 mois**
(`FUTURE_HORIZON_MONTHS`), qui démarre à la fin du mois de la newsletter ou
à aujourd'hui si ce mois est déjà passé. Les séances suivent la même règle
(la marge de 5 jours, `SEANCE_WINDOW_MARGIN_DAYS`, est supprimée).
2. **Dates**`Thalim_NL_Post_Query::event_date_label()` centralise les
libellés du thème (« Le X de H1 à H2 », « Du X au Y », « Jusqu'au X »,
« X à H ») sur la convention `date_de_debut > datetime > post_date`. Utilisée
par l'export **et** par la pastille de la liste à cocher, qui affichent donc
désormais la même chose. Les ouvrages restent en année seule.
3. **Tri**`sql_event_date_order()` remplace le tri par date de publication
dans toutes les catégories.
**Écarts et ajouts par rapport à la note, décidés en cours d'implémentation :**
- **Horizon borné plutôt qu'ouvert.** Un futur strictement illimité faisait
remonter tout l'historique postérieur au mois demandé : ouvrir la newsletter
de janvier 1999 saturait les 128 Mo de PHP (fatal). Le double plafond
(12 mois, et jamais avant aujourd'hui) borne la liste sans rien retirer des
cas réels — la séance du 13 novembre 2026 entre bien dans la newsletter de
juillet 2026.
- **Fenêtres de fin recalées sur la date d'événement.** Les fenêtres « +35 j »
et « +3 mois » partaient de la date de publication : un événement annoncé plus
de 35 jours à l'avance sortait de la fenêtre avant d'avoir eu lieu et manquait
dans la newsletter de son propre mois. Vérifié sur quatre événements culturels
annoncés 36 à 137 jours à l'avance : tous absents avant, tous présents après.
C'est probablement l'autre moitié du « certains items du futur
n'apparaissaient pas » du commanditaire.
- **`merge_selected_items()`** — filet de sécurité : une sélection déjà
enregistrée est réinjectée dans la liste même si ses items sont sortis de la
fenêtre. Sans ça, rouvrir puis réenregistrer une ancienne newsletter
l'amputait silencieusement (le formulaire ne soumet que ce qui est affiché).
Vérifié : la newsletter de juin restitue ses 54 items cochés.
**Point à valider par le commanditaire** (l'arbitrage du §5 n'avait pas été
tranché) : sur un mois neuf, tout est coché **sauf** le bloc « À venir », qui
est listé, visuellement distinct et décoché. Les séances à venir suivent la même
règle sans bloc séparé (elles restent groupées sous leur séminaire).
**Non traité** : la saisie de `date_de_fin` sur les appels à contribution reste
une consigne éditoriale — sans ce champ, aucun « Jusqu'au … » n'est possible.
+237
View File
@@ -0,0 +1,237 @@
# Annonces en 404 : slugs numériques et permaliens `/%category%/%postname%/`
> Note rédigée le 2026-08-27, **avant toute modification du code**.
> Origine : signalement du commanditaire — une annonce publiée, visible dans les
> listes, renvoie un 404 au clic.
> Cas de référence en prod : « Charte d'usage des moyens informatiques du CNRS »,
> permalien `https://thalim.cnrs.fr/le-laboratoire/vie-du-labo-intranet/44210/`.
> Deuxième cas confirmé en prod : l'annonce dont le titre est « 1917 ».
## 1. Symptôme
L'annonce est bien publiée. Elle apparaît normalement dans les index (listes de
catégorie, agenda, recherche) parce que ces écrans manipulent l'objet post. Mais
son **permalien n'est pas routable** : au clic, WordPress rend le 404 du thème
(`templates/404.twig`). L'URL courte `?p={ID}` ne sauve pas la situation — elle
fait un 301 vers ce même permalien cassé.
## 2. Diagnostic
La structure de permaliens du site est `/%category%/%postname%/`. La règle de
réécriture qui sert les articles est :
```
(.+?)/([^/]+)(?:/([0-9]+))?/?$ => index.php?category_name=$1&name=$2&page=$3
```
Le dernier groupe est un **numéro de page** (pagination interne d'un article via
`<!--nextpage-->`). Conséquence : quand le slug d'un article est purement
numérique, il est avalé comme numéro de page, et le segment précédent — qui est
en réalité la sous-catégorie — est pris pour le slug de l'article.
Reproduit en local sur l'article ID 30497 (titre « 1917 », slug `1917`) :
```
URL demandée : /manifestations-scientifiques/communications/1917/ → HTTP 404
règle matchée : (.+?)/([^/]+)(?:/([0-9]+))?/?$
query_vars : {"category_name":"manifestations-scientifiques",
"name":"communications", ← pris pour le slug de l'article
"page":"1917"} ← pris pour un numéro de page
```
WordPress cherche donc un article nommé `communications` dans la catégorie
`manifestations-scientifiques`, page 1917. Il n'existe pas → 404. Le cas
`/le-laboratoire/vie-du-labo-intranet/44210/` est exactement le même.
## 3. Deux causes distinctes, un seul symptôme
### Cause A — titre vide au moment de la première publication (cas « Charte d'usage »)
Cœur de WordPress, `wp-includes/post.php:4954` :
```php
if ( empty( $data['post_name'] ) && ! in_array( $data['post_status'], array( 'draft', 'pending', 'auto-draft' ), true ) ) {
$data['post_name'] = wp_unique_post_slug( sanitize_title( $data['post_title'], $post_id ), );
}
```
`sanitize_title( $titre, $fallback )` renvoie le `$fallback` — donc **l'ID du
post** — quand le titre est vide. Le slug est figé à cet instant, et n'est jamais
régénéré ensuite (la condition exige `post_name` vide) : corriger le titre et
republier ne change rien.
Le chemin qui produit ça est notre propre garde-fou. `inc/post-title-required.php`
le dit dans son en-tête : « the post saves (with empty title), the status is
reverted to draft if needed ». Séquence :
1. clic sur **Publier** avec le champ Titre vide ;
2. `wp_insert_post` écrit la ligne en `publish`**slug figé à l'ID** ;
3. `save_post` (priorité 5) détecte le titre vide, pose le transient de restauration ;
4. `redirect_post_location` (`inc/pods-save-error-handler.php:56-73`) remet le
statut à `draft` et renvoie vers l'écran d'édition avec le message
« Le champ Titre est obligatoire » ;
5. l'utilisateur saisit le titre et republie — le slug numérique reste.
Le même scénario existe via la validation Pods (`inc/pods-save-error-handler.php`)
si un champ obligatoire manque **et** que le titre est vide à ce premier
enregistrement.
À noter : ce n'est **pas** un doublon de titre. Un doublon produirait
`charte-dusage-…-2`, jamais l'ID.
### Cause B — titre légitimement numérique (cas « 1917 »)
Aucun bug ici : le titre « 1917 » donne le slug `1917`, qui est correct du point
de vue de WordPress mais irroutable avec cette structure de permaliens. Cette
cause est indépendante de la cause A et survivra à sa correction. Elle touche
aussi tout contenu entré par import SQL direct ou par l'importateur HAL.
## 4. Correctif proposé — deux volets
Les volets A et B empêchent le problème de réapparaître. Les deux articles déjà
touchés se corrigent à la main (§5) : leur nombre étant connu et limité à deux,
un rattrapage automatique au routage n'est pas justifié.
### Volet A — interdire la naissance de slugs purement numériques
Nouveau module `inc/post-slug-guard.php`, chargé depuis `functions.php` juste
après `post-title-required.php`.
```php
/**
* Un slug purement numérique est irroutable avec la structure
* /%category%/%postname%/ : le dernier segment est avalé comme numéro de page
* par la règle (.+?)/([^/]+)(?:/([0-9]+))?/?$. On suffixe donc ces slugs,
* en suivant la convention du cœur (-2, -3, …).
*
* Portée volontairement limitée à post/page : nav_menu_item et attachment
* ont légitimement des slugs numériques (38 et 4 occurrences en base de dev)
* et ne passent pas par cette règle de réécriture.
*/
add_filter( 'wp_unique_post_slug', function ( $slug, $post_id, $post_status, $post_type ) {
if ( ! in_array( $post_type, [ 'post', 'page' ], true ) || ! ctype_digit( (string) $slug ) ) {
return $slug;
}
global $wpdb;
$suffix = 2;
do {
$alt = $slug . '-' . $suffix;
$exists = (int) $wpdb->get_var( $wpdb->prepare(
"SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = %s AND ID != %d LIMIT 1",
$alt, $post_type, $post_id
) );
$suffix++;
} while ( $exists );
return $alt;
}, 10, 4 );
```
Effet : un futur article intitulé « 1917 » obtient `1917-2`, routable.
Limite connue : un import SQL direct contourne `wp_insert_post`, donc ce filtre —
après toute reprise de données massive (migration, importateur HAL), rejouer la
requête d'inventaire du §5.
### Volet B — ne pas figer un slug issu d'un titre vide
Dans `inc/pods-save-error-handler.php`, bloc d'annulation du statut (l. 63-72),
vider `post_name` **uniquement s'il est numérique** — c'est-à-dire s'il a été
généré faute de titre. Un slug saisi à la main sur un brouillon est ainsi préservé.
```php
$update = [ 'post_status' => $original ?: 'draft' ];
$formats = [ '%s' ];
// Slug figé à l'ID faute de titre : le vider pour qu'il soit régénéré
// depuis le vrai titre à la prochaine publication.
if ( ctype_digit( (string) $post->post_name ) ) {
$update['post_name'] = '';
$formats[] = '%s';
}
$wpdb->update( $wpdb->posts, $update, [ 'ID' => $post_id ], $formats, [ '%d' ] );
```
Effet : le scénario « Charte d'usage » ne peut plus se produire — au moment où
l'utilisateur corrige son titre et republie, WordPress régénère un slug propre.
## 5. Remise en état des deux articles existants
Les volets A et B n'agissent qu'à la création du slug : ils ne réparent pas les
deux articles déjà en 404. Il faut donc éditer chacun d'eux et corriger le
permalien à la main :
| Article | Slug actuel | Slug à poser |
|---|---|---|
| Charte d'usage des moyens informatiques du CNRS | `44210` | `charte-dusage-des-moyens-informatiques-du-cnrs` |
| 1917 | `1917` | `1917-2` (ou tout slug non purement numérique) |
Les anciennes URL numériques étant déjà en 404, ce changement ne casse aucun lien
existant. À faire **après** le déploiement du volet A, sans quoi rien ne garantit
qu'un futur enregistrement ne régénère pas un slug numérique.
Inventaire des contenus concernés, **exécuté en production le 2026-08-27** :
```sql
SELECT ID, post_type, post_status, post_name, post_title
FROM wp_posts
WHERE post_type IN ('post','page')
AND post_name REGEXP '^[0-9]+$'
ORDER BY post_type, ID;
```
Résultat : **2 articles seulement**, les deux déjà connus — « Charte d'usage des
moyens informatiques du CNRS » (slug `44210`) et « 1917 » (slug `1917`). Le
périmètre de rattrapage est donc entièrement circonscrit. Les `nav_menu_item` et
`attachment` à slug numérique sont normaux et hors périmètre (le volet A ne les
touche pas).
## 6. Éléments pour la réponse au commanditaire
- L'annonce est bien publiée, rien n'est perdu ; c'est son **adresse** qui est
invalide, d'où l'incohérence « visible dans la liste, 404 au clic ».
- Cause : l'annonce a été publiée une première fois alors que le champ Titre
était encore vide. WordPress a alors fabriqué son adresse à partir de son
numéro interne, et ne la régénère plus ensuite, même après correction du titre.
- Ce n'est pas lié à un doublon de titre.
- Deux annonces sont concernées sur tout le site, la leur et une autre : elles
sont réparées à la main en corrigeant leur permalien, et redeviennent
accessibles immédiatement.
- Un correctif côté code est prévu pour que le cas ne se reproduise plus, sans
intervention de leur part.
- Consigne utile en attendant ce correctif : **saisir le titre avant le premier
enregistrement**.
## 7. Contexte technique
- WordPress 7.0, structure de permaliens `/%category%/%postname%/`.
- Règle de réécriture concernée : rang 111 sur 123
(`(.+?)/([^/]+)(?:/([0-9]+))?/?$`).
- Vérifications faites sur l'environnement de dev local (dump au 2026-07-03),
conteneurs `thalim-dev-db-1` et `wordpress`.
- Constat annexe repéré au passage : il existe des articles publiés partageant le
même `post_name`, ce qui est impossible via l'API WordPress — trace d'imports
SQL directs (migration / importateur HAL). Sans rapport avec ce 404, mais un
lien peut y renvoyer vers le mauvais article. À traiter séparément.
## 8. Implémentation — 2026-08-27
Faite dans `thalim-theme`, conforme aux volets A et B ci-dessus.
- **Volet A** — nouveau module `inc/post-slug-guard.php`, chargé depuis
`functions.php` après `post-title-required.php`. Filtre `wp_unique_post_slug`
limité à `post`/`page`, plus le helper pur `thalim_slug_needs_guard()`.
Vérifié : `1917``1917-2`, `44210``44210-2`, `9999``9999-2` ;
`charte-dusage-…` inchangé ; `99999` en `nav_menu_item` et en `attachment`
inchangé.
- **Volet B** — dans `inc/pods-save-error-handler.php`, le bloc d'annulation de
statut vide aussi `post_name`. **Écart assumé avec la note** : la condition
n'est pas « slug numérique » mais « **titre vide** ». Le volet A transforme en
effet `44210` en `44210-2`, qui n'est plus numérique — la condition d'origine
n'aurait donc jamais déclenché une fois les deux volets en place. Tester le
titre vide vise directement la cause et reste juste dans tous les cas.
Vérifié de bout en bout : publication sans titre → slug `44021-2` ; annulation
du statut → `draft` + slug vidé ; saisie du titre puis publication → slug
`charte-d-usage-des-moyens-informatiques-du-cnrs`, permalien routable.
Le post de test a été supprimé définitivement.
- **Non fait** : la remise en état des deux articles existants (§5), qui reste à
faire à la main en prod après déploiement.