Annonces à venir proposées, et dates d'événement au lieu des dates de publication
Retour du commanditaire après le premier envoi réel (juillet 2026) : des annonces à venir n'étaient pas proposées à la coche, et les dates affichées étaient les dates de publication. - Fenêtre « à venir » de 12 mois (FUTURE_HORIZON_MONTHS), démarrant à la fin du mois de la newsletter ou à aujourd'hui si ce mois est passé, en remplacement du plafond « début de fenêtre <= fin du mois ». Idem pour les séances (SEANCE_WINDOW_MARGIN_DAYS de 5 jours supprimée). Ces items sont regroupés dans un bloc « À venir », décochés par défaut. - 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 la même chose. - Tri par date d'événement (sql_event_date_order) et non par date de publication. - Fenêtres de fin « +35 j » et « +3 mois » recalées sur la date d'événement : 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. - merge_selected_items() : 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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011w7RzAHv4r1Yahnm2NwDDG
This commit is contained in:
@@ -23,7 +23,7 @@ Le workflow :
|
||||
|
||||
1. Sélection d'un mois (sélecteur année-mois)
|
||||
2. Chargement AJAX (`thalim_nl_load_month`) des contenus éligibles, regroupés par catégorie parente
|
||||
3. Cases à cocher pour inclure ou exclure chaque publication / séance. **Tout est coché par défaut** pour un mois sans newsletter existante (à l'ouverture d'une newsletter déjà enregistrée, c'est la sélection sauvegardée qui est restaurée)
|
||||
3. Cases à cocher pour inclure ou exclure chaque publication / séance. Pour un mois sans newsletter existante, **tout est coché par défaut sauf le bloc « À venir »** (voir plus bas). À l'ouverture d'une newsletter déjà enregistrée, c'est la sélection sauvegardée qui est restaurée — y compris les items qui ne seraient plus dans la fenêtre du mois, réintégrés par `merge_selected_items()` pour qu'un réenregistrement ne les perde pas
|
||||
4. **Réordonnancement par glisser-déposer** : chaque item porte une poignée (`⋮`). Pour les catégories normales, on réordonne les annonces dans leur liste. Pour les séminaires, la poignée est sur le **titre du séminaire** : on réordonne les **séminaires entre eux** (les séances, elles, restent toujours triées par ordre chronologique). L'ordre choisi est repris tel quel dans le rendu HTML après sauvegarde — l'ordre de soumission des cases suit l'ordre du DOM, et l'export rend chaque section dans l'ordre stocké
|
||||
5. Champs **intro**, **conclusion**, **URL d'inscription**, **URL de désinscription**
|
||||
6. Sauvegarde : crée un post WordPress dans la catégorie **Newsletter** (`20`) avec le HTML email complet en `post_content`
|
||||
@@ -33,24 +33,31 @@ Une liste des newsletters déjà sauvegardées permet de revenir éditer un mois
|
||||
|
||||
> Les catégories **Vie du labo (intranet)** (`9`), **Séance de séminaire** (`12`), **Newsletter** (`20`) et **Non classé** (`31`) sont exclues de l'UI (`EXCLUDED_CATS` dans `includes/class-post-query.php`). Les séances (cat 12) restent listées, mais imbriquées sous leur séminaire — voir plus bas.
|
||||
|
||||
## Fenêtres d'éligibilité par catégorie
|
||||
## Éligibilité : date d'événement, pas date de publication
|
||||
|
||||
Les contenus pertinents d'un mois donné ne sont pas seulement « les posts publiés ce mois-ci » — chaque catégorie a sa propre logique de fenêtre temporelle (cf. `WINDOW_TYPES` dans `includes/class-post-query.php`) :
|
||||
Partout où il est question de « la date » d'un contenu, c'est la **date de l'événement annoncé** qui fait foi, dans l'ordre de priorité du thème : `date_de_debut` > `datetime` > `post_date`. Cet ordre gouverne l'éligibilité, le tri à l'intérieur de chaque section, et la date affichée dans le mail.
|
||||
|
||||
| Catégorie | Fenêtre |
|
||||
Un contenu est proposé pour le mois M s'il remplit l'une des deux conditions :
|
||||
|
||||
1. **Contenu du mois** — sa date d'événement est antérieure ou égale à la fin de M, et sa fenêtre de fin (ci-dessous) ne s'est pas achevée avant le début de M ;
|
||||
2. **Contenu à venir** — sa date d'événement tombe entre la fin de M (ou aujourd'hui si M est déjà passé) et `FUTURE_HORIZON_MONTHS` (12 mois). Ces items sont regroupés dans un bloc **« À venir »**, **décochés par défaut**.
|
||||
|
||||
La fenêtre de fin, qui détermine combien de temps un contenu reste proposé après son événement, dépend de la catégorie (`SPECIAL_WINDOW_TYPES` dans `includes/class-post-query.php`) :
|
||||
|
||||
| Catégorie | Fin de fenêtre |
|
||||
| -------------------------------------------------- | ---------------------- |
|
||||
| Appels (`8`), Soutenances (`14`) | `datetime_to_fin` (du `datetime` à `date_de_fin`) |
|
||||
| Colloques (`10`), Communications (`13`) | `debut_minus35_to_fin` (de `date_de_debut - 35j` à `date_de_fin`) |
|
||||
| Ouvrages (`15`), Articles (`16`) | `datetime_plus3m` (du `datetime` à `datetime + 3 mois`) |
|
||||
| **Toutes les autres** | `datetime_plus35d` (du `datetime` à `datetime + 35 jours`) |
|
||||
| Appels (`8`), Soutenances (`14`) | `date_de_fin` (date limite / date de soutenance) |
|
||||
| Colloques (`10`), Communications (`13`) | `date_de_fin`, sinon `date_de_debut` |
|
||||
| Ouvrages (`15`), Articles (`16`) | date d'événement + 3 mois |
|
||||
| **Toutes les autres** | date d'événement + 35 jours |
|
||||
|
||||
Quand `datetime` (ou `date_de_debut`) est vide, le `post_date` sert de fallback. Cette logique permet par ex. à un appel à communication d'apparaître dans toutes les newsletters jusqu'à sa date de fin.
|
||||
Un appel à contribution reste donc proposé dans toutes les newsletters jusqu'à sa date limite, et un événement annoncé six mois à l'avance apparaît à la fois dans le bloc « À venir » des newsletters intermédiaires et dans la liste principale de la newsletter de son propre mois.
|
||||
|
||||
### Cas particulier : Séminaires (`11`) → sélection par séance
|
||||
|
||||
Le séminaire n'est **pas** sélectionnable en tant que tel. À la place, le plugin liste ses **séances** (cat 12) individuellement, chacune avec sa propre case à cocher, regroupées sous le titre (non cliquable) de leur séminaire parent.
|
||||
|
||||
- **Éligibilité** : une séance apparaît si sa `date_de_debut` tombe dans `[1er du mois, dernier jour du mois + 5 jours]` (marge `SEANCE_WINDOW_MARGIN_DAYS` dans `class-post-query.php`). Un séminaire sans séance dans cette fenêtre n'apparaît pas.
|
||||
- **Éligibilité** : une séance apparaît si sa `date_de_debut` tombe dans le mois, ou dans la fenêtre « à venir » (jusqu'à 12 mois). Un séminaire sans séance dans ces fenêtres n'apparaît pas. Les séances à venir sont décochées par défaut et signalées visuellement.
|
||||
- **Découverte** : on parcourt les séminaires publiés (cat 11) et on lit leur meta `seances` (tableau d'IDs de séances). Le lien parent→séance vit donc sur le séminaire.
|
||||
- **Sélection stockée** : `_newsletter_sections[11]` contient des **IDs de séances**, plus des IDs de séminaires.
|
||||
- **Rendu HTML** (`class-html-exporter.php`) : les séances cochées sont regroupées par séminaire parent (lookup inverse via `Thalim_NL_Post_Query::get_seminar_id_for_seance()`, même requête que la redirection `#seance-{ID}` du thème). Le titre du séminaire est affiché **une seule fois**, suivi de la liste des séances sélectionnées (date · heure · lieu, lien vers `#seance-{id}`).
|
||||
@@ -73,6 +80,23 @@ La liste des catégories éligibles n'est **pas** codée en dur dans le plugin
|
||||
|
||||
> IDs vérifiés en DB le 2026-03-20. À mettre à jour en cas de migration ou de réorganisation des taxonomies.
|
||||
|
||||
## Dates affichées dans le mail
|
||||
|
||||
Le libellé est construit par `Thalim_NL_Post_Query::event_date_label()`, qui reprend les règles du thème (`thalim_get_agenda_card_data()`) :
|
||||
|
||||
| Champs renseignés | Rendu |
|
||||
| --- | --- |
|
||||
| début et fin le même jour, avec horaires | `Le 9 octobre 2026 de 14:00 à 17:00` |
|
||||
| début et fin sur plusieurs jours | `Du 9 octobre 2026 au 11 octobre 2026` |
|
||||
| début seul (+ heure éventuelle) | `9 octobre 2026 à 14:00` |
|
||||
| fin seule — cas des appels à contribution | `Jusqu'au 1 septembre 2026` |
|
||||
| `datetime` seul | `9 octobre 2026` |
|
||||
| aucune date | date de publication, en dernier recours |
|
||||
|
||||
Les ouvrages (`15`) font exception : année seule, comme les cartes du site.
|
||||
|
||||
La même fonction alimente la pastille de date de la liste à cocher — ce que voit le rédacteur est ce qui partira dans le mail.
|
||||
|
||||
## Format HTML email
|
||||
|
||||
`includes/class-html-exporter.php` génère un HTML compatible clients mail :
|
||||
|
||||
@@ -304,3 +304,30 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Annonces à venir (au-delà du mois affiché) ---- */
|
||||
.thalim-nl-future {
|
||||
margin-top: 10px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px dashed #dcdcde;
|
||||
}
|
||||
|
||||
.thalim-nl-future-title {
|
||||
margin: 0 0 2px 0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #787c82;
|
||||
}
|
||||
|
||||
.thalim-nl-future-title span {
|
||||
font-weight: normal;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.thalim-nl-post-row.is-future .thalim-nl-post-title {
|
||||
color: #50575e;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,13 @@
|
||||
makeSortable($body, '> .thalim-nl-post-row');
|
||||
}
|
||||
});
|
||||
|
||||
// Le bloc « À venir » est une zone de tri à part, pour que ses lignes
|
||||
// ne puissent pas se mélanger à celles du mois (l'ordre du DOM est
|
||||
// l'ordre de soumission, donc l'ordre de rendu dans le mail).
|
||||
$root.find('.thalim-nl-future').each(function () {
|
||||
makeSortable($(this), '> .thalim-nl-post-row');
|
||||
});
|
||||
}
|
||||
|
||||
// A click on the drag handle must not toggle its enclosing checkbox label.
|
||||
|
||||
@@ -53,6 +53,9 @@ class Thalim_NL_Admin_Page {
|
||||
// Query posts for current month
|
||||
$query = new Thalim_NL_Post_Query();
|
||||
$month_data = $query->get_posts_for_month($year_month);
|
||||
if ($existing) {
|
||||
$month_data = $query->merge_selected_items($month_data, $existing_sections);
|
||||
}
|
||||
|
||||
// Past newsletters
|
||||
$past_newsletters = get_posts([
|
||||
@@ -304,12 +307,12 @@ class Thalim_NL_Admin_Page {
|
||||
</a>
|
||||
</div>
|
||||
<?php foreach ($sem['seances'] as $seance): ?>
|
||||
<label class="thalim-nl-post-row thalim-nl-seance-row">
|
||||
<label class="thalim-nl-post-row thalim-nl-seance-row<?php echo !empty($seance['is_future']) ? ' is-future' : ''; ?>">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="nl_sections[<?php echo (int) $cat_id; ?>][]"
|
||||
value="<?php echo (int) $seance['id']; ?>"
|
||||
<?php checked($check_all || in_array((int) $seance['id'], $checked_in_section)); ?>
|
||||
<?php checked($this->is_item_checked($seance, $check_all, $checked_in_section)); ?>
|
||||
>
|
||||
<span class="thalim-nl-post-title"><?php echo esc_html($seance['title']); ?></span>
|
||||
<span class="thalim-nl-post-date"><?php echo esc_html($this->format_seance_hint($seance)); ?></span>
|
||||
@@ -318,23 +321,33 @@ class Thalim_NL_Admin_Page {
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php else: ?>
|
||||
<?php foreach ($posts as $post): ?>
|
||||
<label class="thalim-nl-post-row">
|
||||
<span class="thalim-nl-drag-handle dashicons dashicons-menu" title="Glisser pour réordonner" aria-hidden="true"></span>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="nl_sections[<?php echo (int) $cat_id; ?>][]"
|
||||
value="<?php echo (int) $post['id']; ?>"
|
||||
<?php checked($check_all || in_array((int) $post['id'], $checked_in_section)); ?>
|
||||
>
|
||||
<span class="thalim-nl-post-title"><?php echo esc_html($post['title']); ?></span>
|
||||
<a href="<?php echo esc_url($post['permalink']); ?>" target="_blank" rel="noopener"
|
||||
class="thalim-nl-post-view" title="Voir sur le site">
|
||||
<span class="dashicons dashicons-visibility"></span>
|
||||
</a>
|
||||
<span class="thalim-nl-post-date"><?php echo esc_html($this->format_post_date_hint($post)); ?></span>
|
||||
</label>
|
||||
<?php
|
||||
// Les annonces dont la date d'événement dépasse le mois sont
|
||||
// regroupées à part et décochées par défaut : elles restent
|
||||
// disponibles à la coche sans encombrer la sélection du mois.
|
||||
$posts_du_mois = [];
|
||||
$posts_a_venir = [];
|
||||
foreach ($posts as $post) {
|
||||
if (!empty($post['is_future'])) {
|
||||
$posts_a_venir[] = $post;
|
||||
} else {
|
||||
$posts_du_mois[] = $post;
|
||||
}
|
||||
}
|
||||
?>
|
||||
<?php foreach ($posts_du_mois as $post): ?>
|
||||
<?php echo $this->render_post_row((int) $cat_id, $post, $check_all, $checked_in_section); ?>
|
||||
<?php endforeach; ?>
|
||||
<?php if (!empty($posts_a_venir)): ?>
|
||||
<div class="thalim-nl-future">
|
||||
<p class="thalim-nl-future-title">
|
||||
À venir <span>(au-delà du mois — à cocher au besoin)</span>
|
||||
</p>
|
||||
<?php foreach ($posts_a_venir as $post): ?>
|
||||
<?php echo $this->render_post_row((int) $cat_id, $post, $check_all, $checked_in_section); ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</details>
|
||||
@@ -350,17 +363,59 @@ class Thalim_NL_Admin_Page {
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Un item est-il coché à l'ouverture ?
|
||||
* - newsletter déjà enregistrée : la sélection sauvegardée fait foi ;
|
||||
* - mois neuf : tout est coché sauf ce qui dépasse le mois (`is_future`).
|
||||
*/
|
||||
private function is_item_checked(array $item, bool $check_all, array $checked_in_section): bool {
|
||||
if ($check_all) {
|
||||
return empty($item['is_future']);
|
||||
}
|
||||
// Normalisation : la sélection vient d'un json_decode, tolérer les chaînes.
|
||||
return in_array((int) $item['id'], array_map('intval', $checked_in_section), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Une ligne d'annonce cochable (hors séances, qui ont leur propre gabarit).
|
||||
*/
|
||||
private function render_post_row(int $cat_id, array $post, bool $check_all, array $checked_in_section): string {
|
||||
ob_start();
|
||||
?>
|
||||
<label class="thalim-nl-post-row<?php echo !empty($post['is_future']) ? ' is-future' : ''; ?>">
|
||||
<span class="thalim-nl-drag-handle dashicons dashicons-menu" title="Glisser pour réordonner" aria-hidden="true"></span>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="nl_sections[<?php echo $cat_id; ?>][]"
|
||||
value="<?php echo (int) $post['id']; ?>"
|
||||
<?php checked($this->is_item_checked($post, $check_all, $checked_in_section)); ?>
|
||||
>
|
||||
<span class="thalim-nl-post-title"><?php echo esc_html($post['title']); ?></span>
|
||||
<a href="<?php echo esc_url($post['permalink']); ?>" target="_blank" rel="noopener"
|
||||
class="thalim-nl-post-view" title="Voir sur le site">
|
||||
<span class="dashicons dashicons-visibility"></span>
|
||||
</a>
|
||||
<span class="thalim-nl-post-date"><?php echo esc_html($this->format_post_date_hint($post)); ?></span>
|
||||
</label>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Small date hint shown next to each post title in the checklist.
|
||||
*/
|
||||
private function format_post_date_hint(array $post): string {
|
||||
$candidates = [$post['date_debut'], $post['datetime'], $post['post_date']];
|
||||
foreach ($candidates as $raw) {
|
||||
if ($raw && !str_starts_with($raw, '0000-00-00')) {
|
||||
$ts = strtotime($raw);
|
||||
if ($ts) {
|
||||
return date_i18n('j M Y', $ts);
|
||||
}
|
||||
// Même libellé que l'export HTML (« Du X au Y », « Jusqu'au X »…) :
|
||||
// ce que voit le rédacteur dans la liste est ce qui partira dans le mail.
|
||||
$label = Thalim_NL_Post_Query::event_date_label((int) $post['id'], 'j M Y');
|
||||
if ($label !== '') {
|
||||
return $label;
|
||||
}
|
||||
$raw = $post['post_date'] ?? '';
|
||||
if ($raw && !str_starts_with($raw, '0000-00-00')) {
|
||||
$ts = strtotime($raw);
|
||||
if ($ts) {
|
||||
return date_i18n('j M Y', $ts);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
@@ -605,6 +660,10 @@ class Thalim_NL_Admin_Page {
|
||||
$unsubscribe_url = get_post_meta($existing->ID, '_newsletter_unsubscribe_url', true) ?: '';
|
||||
}
|
||||
|
||||
if ($existing) {
|
||||
$month_data = $query->merge_selected_items($month_data, $existing_sections);
|
||||
}
|
||||
|
||||
$html = $this->render_sections_html($month_data, $existing ? $existing_sections : null);
|
||||
|
||||
wp_send_json_success([
|
||||
|
||||
@@ -540,28 +540,27 @@ body { margin: 0 !important; padding: 0 !important; }
|
||||
// ── Date helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private function format_date_for_category(int $post_id, int $cat_id): string {
|
||||
$window = Thalim_NL_Post_Query::get_window_type($cat_id);
|
||||
|
||||
// Ouvrages : année seule, comme l'affichage des cartes côté front.
|
||||
$date_format = ($cat_id === THALIM_NL_CAT_OUVRAGES) ? 'Y' : 'j F Y';
|
||||
|
||||
if ($window === 'debut_minus35_to_fin') {
|
||||
$raw_debut = get_post_meta($post_id, 'date_de_debut', true) ?: '';
|
||||
$raw_fin = get_post_meta($post_id, 'date_de_fin', true) ?: '';
|
||||
$debut_str = $this->format_raw_date($raw_debut, $date_format);
|
||||
$fin_str = $this->format_raw_date($raw_fin, $date_format);
|
||||
if ($debut_str && $fin_str && $debut_str !== $fin_str) {
|
||||
return $debut_str . '–' . $fin_str;
|
||||
if ($cat_id === THALIM_NL_CAT_OUVRAGES) {
|
||||
$raw_dt = get_post_meta($post_id, 'datetime', true) ?: '';
|
||||
$year = $this->format_raw_date($raw_dt, 'Y');
|
||||
if ($year) {
|
||||
return $year;
|
||||
}
|
||||
return $debut_str ?: $fin_str;
|
||||
$post = get_post($post_id);
|
||||
return $post ? $this->format_raw_date($post->post_date, 'Y') : '';
|
||||
}
|
||||
|
||||
$raw_dt = get_post_meta($post_id, 'datetime', true) ?: '';
|
||||
if ($raw_dt && !str_starts_with($raw_dt, '0000-00-00')) {
|
||||
return $this->format_raw_date($raw_dt, $date_format);
|
||||
// Date de l'événement annoncé (date_de_debut > datetime > date_de_fin),
|
||||
// et non date de publication : « Du X au Y », « Jusqu'au X », « X à H ».
|
||||
$label = Thalim_NL_Post_Query::event_date_label($post_id);
|
||||
if ($label !== '') {
|
||||
return $label;
|
||||
}
|
||||
|
||||
// Aucune date saisie : dernier recours, la date de publication.
|
||||
$post = get_post($post_id);
|
||||
return $post ? $this->format_raw_date($post->post_date, $date_format) : '';
|
||||
return $post ? $this->format_raw_date($post->post_date) : '';
|
||||
}
|
||||
|
||||
private function format_raw_date(string $raw, string $format = 'j F Y'): string {
|
||||
|
||||
+242
-85
@@ -11,12 +11,18 @@ class Thalim_NL_Post_Query {
|
||||
|
||||
/**
|
||||
* Window type per category ID (special cases).
|
||||
* - datetime_to_fin : window = [datetime ∥ post_date, date_de_fin ∥ datetime ∥ post_date]
|
||||
* - debut_minus35_to_fin: window = [date_de_debut - 35d ∥ post_date - 35d, date_de_fin ∥ date_de_debut ∥ post_date]
|
||||
* - datetime_plus3m : window = [datetime ∥ post_date, datetime ∥ post_date + 3 months]
|
||||
*
|
||||
* All other categories use 'datetime_plus35d':
|
||||
* window = [datetime ∥ post_date, datetime ∥ post_date + 35 days]
|
||||
* La *fin* de fenêtre filtre le passé : un contenu est retenu pour le mois M
|
||||
* dès lors qu'il ne s'est pas terminé avant M. Côté futur, le plafond n'est
|
||||
* plus la fin du mois mais FUTURE_HORIZON_MONTHS (demande du 2026-08-27 :
|
||||
* des annonces d'octobre devaient pouvoir entrer dans la newsletter de
|
||||
* juillet). Les items dont la date d'événement dépasse le mois sont marqués
|
||||
* `is_future` et présentés à part dans l'UI, décochés par défaut.
|
||||
*
|
||||
* - datetime_to_fin : fin = date_de_fin ∥ datetime ∥ post_date
|
||||
* - debut_minus35_to_fin: fin = date_de_fin ∥ date_de_debut ∥ post_date
|
||||
* - datetime_plus3m : fin = (datetime ∥ post_date) + 3 mois
|
||||
* - datetime_plus35d : fin = (datetime ∥ post_date) + 35 jours (défaut)
|
||||
*/
|
||||
private const SPECIAL_WINDOW_TYPES = [
|
||||
THALIM_NL_CAT_APPELS => 'datetime_to_fin',
|
||||
@@ -30,12 +36,27 @@ class Thalim_NL_Post_Query {
|
||||
private const DEFAULT_WINDOW_TYPE = 'datetime_plus35d';
|
||||
|
||||
/**
|
||||
* Séminaires (cat 11) are not selected as whole posts: instead, each of
|
||||
* their séances (cat 12) is individually selectable. A séance is eligible
|
||||
* when its date_de_debut falls within the newsletter month, extended by
|
||||
* this margin (in days) past the end of the month.
|
||||
* Horizon d'affichage des annonces à venir, en mois.
|
||||
*
|
||||
* Sans plafond, ouvrir la newsletter d'un mois ancien remonterait tout
|
||||
* l'historique postérieur (la totalité du site pour un mois de 1999) :
|
||||
* requête et rendu ingérables. 12 mois couvrent largement les annonces
|
||||
* saisies en avance (séances de la rentrée, colloques annoncés un an à
|
||||
* l'avance) tout en bornant la liste.
|
||||
*/
|
||||
private const SEANCE_WINDOW_MARGIN_DAYS = 5;
|
||||
private const FUTURE_HORIZON_MONTHS = 12;
|
||||
|
||||
/**
|
||||
* Bornes du bloc « à venir » : [début, fin] en timestamps.
|
||||
*
|
||||
* Le bloc commence à la fin du mois de la newsletter, ou à aujourd'hui si
|
||||
* ce mois est déjà passé — rouvrir une vieille newsletter ne doit pas
|
||||
* proposer une année de contenus entre-temps devenus du passé.
|
||||
*/
|
||||
private static function future_bounds(int $month_end): array {
|
||||
$from = max($month_end, strtotime('today'));
|
||||
return [ $from, strtotime('+' . self::FUTURE_HORIZON_MONTHS . ' months', $from) ?: $from ];
|
||||
}
|
||||
|
||||
/**
|
||||
* Categories to exclude from the newsletter UI, résolues par slug
|
||||
@@ -143,7 +164,7 @@ class Thalim_NL_Post_Query {
|
||||
foreach (self::get_all_eligible_cat_ids() as $cat_id) {
|
||||
// Seminars: list individually-selectable séances grouped by seminar.
|
||||
if ($cat_id === THALIM_NL_CAT_SEMINAIRES) {
|
||||
$seances = $this->query_seminar_seances($month_start, $month_end);
|
||||
$seances = $this->query_seminar_seances($month_start, $month_end, self::future_bounds($month_end));
|
||||
if (!empty($seances)) {
|
||||
$result[$cat_id] = $seances;
|
||||
}
|
||||
@@ -151,30 +172,192 @@ class Thalim_NL_Post_Query {
|
||||
}
|
||||
|
||||
$window_type = self::get_window_type($cat_id);
|
||||
$posts = $this->query_category($cat_id, $window_type, $month_start, $month_end);
|
||||
$posts = $this->query_category($cat_id, $window_type, $month_start, $month_end, self::future_bounds($month_end));
|
||||
if (!empty($posts)) {
|
||||
$result[$cat_id] = $posts;
|
||||
}
|
||||
}
|
||||
|
||||
// Marque les items dont la date d'événement dépasse le mois : l'UI les
|
||||
// présente à part et ne les coche pas par défaut.
|
||||
foreach ($result as $cat_id => $items) {
|
||||
foreach ($items as $i => $item) {
|
||||
$ts = self::event_start_ts($item);
|
||||
$result[$cat_id][$i]['is_future'] = ($ts > $month_end);
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Timestamp d'événement d'un item, dans l'ordre de priorité du thème :
|
||||
* date_de_debut > datetime > post_date.
|
||||
*/
|
||||
public static function event_start_ts(array $item): int {
|
||||
foreach (['date_debut', 'datetime', 'post_date'] as $key) {
|
||||
$raw = $item[$key] ?? '';
|
||||
if ($raw && !str_starts_with($raw, '0000-00-00')) {
|
||||
$ts = strtotime($raw);
|
||||
if ($ts) {
|
||||
return $ts;
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Réintègre dans les données du mois les items d'une sélection déjà
|
||||
* enregistrée qui n'y figureraient plus.
|
||||
*
|
||||
* Les fenêtres d'éligibilité peuvent évoluer (elles ont changé le
|
||||
* 2026-08-27 en passant de la date de publication à la date d'événement) :
|
||||
* sans ce filet, rouvrir puis réenregistrer une ancienne newsletter la
|
||||
* amputerait silencieusement des items devenus hors fenêtre — le
|
||||
* formulaire ne soumet que ce qui est affiché.
|
||||
*
|
||||
* @param array $month_data Données du mois, par catégorie.
|
||||
* @param array $sections Sélection enregistrée : cat_id => [post_id, …].
|
||||
*/
|
||||
public function merge_selected_items(array $month_data, array $sections): array {
|
||||
foreach ($sections as $cat_id => $ids) {
|
||||
$cat_id = (int) $cat_id;
|
||||
$present = array_map(
|
||||
static fn($item) => (int) $item['id'],
|
||||
$month_data[$cat_id] ?? []
|
||||
);
|
||||
|
||||
foreach (array_diff(array_map('intval', (array) $ids), $present) as $post_id) {
|
||||
$post = get_post($post_id);
|
||||
if (!$post || $post->post_status !== 'publish') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($cat_id === THALIM_NL_CAT_SEMINAIRES) {
|
||||
$seminar_id = self::get_seminar_id_for_seance($post_id);
|
||||
if (!$seminar_id) {
|
||||
continue;
|
||||
}
|
||||
$item = $this->build_seance_data(
|
||||
$post_id,
|
||||
$seminar_id,
|
||||
get_the_title($seminar_id),
|
||||
get_permalink($seminar_id)
|
||||
);
|
||||
} else {
|
||||
$item = $this->build_post_data($post_id, $post->post_title, $post->post_date);
|
||||
}
|
||||
|
||||
// Item repêché : jamais dans le bloc « à venir », il fait
|
||||
// partie de la sélection du mois telle qu'elle a été validée.
|
||||
$item['is_future'] = false;
|
||||
$month_data[$cat_id][] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
return $month_data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Données d'une séance, avec son séminaire parent (pour le regroupement).
|
||||
*/
|
||||
private function build_seance_data(int $sid, int $seminar_id, string $seminar_title, string $seminar_permalink): array {
|
||||
$s_post = get_post($sid);
|
||||
|
||||
return [
|
||||
'id' => $sid,
|
||||
'title' => get_the_title($sid),
|
||||
// Links straight to the séance anchor on the seminar page.
|
||||
'permalink' => $seminar_permalink . '#seance-' . $sid,
|
||||
'datetime' => '',
|
||||
'date_debut' => get_post_meta($sid, 'date_de_debut', true) ?: '',
|
||||
'date_fin' => get_post_meta($sid, 'date_de_fin', true) ?: '',
|
||||
'post_date' => $s_post ? $s_post->post_date : '',
|
||||
'heure_de_debut' => substr(get_post_meta($sid, 'heure_de_debut', true) ?: '', 0, 5),
|
||||
'heure_de_fin' => substr(get_post_meta($sid, 'heure_de_fin', true) ?: '', 0, 5),
|
||||
'lieu' => get_post_meta($sid, 'lieu', true) ?: '',
|
||||
'membres' => $this->get_post_membres($sid),
|
||||
'autrepersonnes' => get_post_meta($sid, 'autrepersonnes', true) ?: '',
|
||||
'seminar_id' => $seminar_id,
|
||||
'seminar_title' => $seminar_title,
|
||||
'seminar_permalink' => $seminar_permalink,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Libellé de date d'un contenu pour la newsletter.
|
||||
*
|
||||
* Reprend la convention du thème (date_de_debut > datetime > post_date) et
|
||||
* les libellés de thalim_get_agenda_card_data() : « Le X de H1 à H2 »,
|
||||
* « Du X au Y », « Jusqu'au X », « X à H ». Le plugin garde sa propre
|
||||
* implémentation plutôt que d'appeler le thème, pour rester autonome.
|
||||
*
|
||||
* Renvoie '' si aucune date exploitable — au caller de retomber sur
|
||||
* post_date s'il le souhaite.
|
||||
*/
|
||||
public static function event_date_label(int $post_id, string $format = 'j F Y'): string {
|
||||
$ts_debut = self::valid_ts(get_post_meta($post_id, 'date_de_debut', true) ?: '');
|
||||
$ts_fin = self::valid_ts(get_post_meta($post_id, 'date_de_fin', true) ?: '');
|
||||
$ts_dt = self::valid_ts(get_post_meta($post_id, 'datetime', true) ?: '');
|
||||
|
||||
$fmt_debut = $ts_debut ? date_i18n($format, $ts_debut) : '';
|
||||
$fmt_fin = $ts_fin ? date_i18n($format, $ts_fin) : '';
|
||||
$fmt_dt = $ts_dt ? date_i18n($format, $ts_dt) : '';
|
||||
|
||||
$h_debut = substr(get_post_meta($post_id, 'heure_de_debut', true) ?: '', 0, 5);
|
||||
$h_fin = substr(get_post_meta($post_id, 'heure_de_fin', true) ?: '', 0, 5);
|
||||
|
||||
if ($fmt_debut || $fmt_fin) {
|
||||
// Même jour de début et de fin : une seule date, éventuellement horaire.
|
||||
if ($ts_debut && $ts_fin && date('Y-m-d', $ts_debut) === date('Y-m-d', $ts_fin)) {
|
||||
if ($h_debut && $h_fin) {
|
||||
return 'Le ' . $fmt_debut . ' de ' . $h_debut . ' à ' . $h_fin;
|
||||
}
|
||||
return $h_debut ? $fmt_debut . ' à ' . $h_debut : $fmt_debut;
|
||||
}
|
||||
if ($fmt_debut && $fmt_fin) {
|
||||
return 'Du ' . $fmt_debut . ' au ' . $fmt_fin;
|
||||
}
|
||||
if ($fmt_debut) {
|
||||
return $h_debut ? $fmt_debut . ' à ' . $h_debut : $fmt_debut;
|
||||
}
|
||||
// Seule une date de fin : cas des appels à contribution (date limite).
|
||||
return "Jusqu'au " . $fmt_fin;
|
||||
}
|
||||
|
||||
if ($fmt_dt) {
|
||||
return $h_debut ? $fmt_dt . ' à ' . $h_debut : $fmt_dt;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Timestamp d'une valeur de date Pods, 0 si vide ou invalide (0000-00-00).
|
||||
*/
|
||||
private static function valid_ts(string $raw): int {
|
||||
if (!$raw || str_starts_with($raw, '0000-00-00')) {
|
||||
return 0;
|
||||
}
|
||||
return strtotime($raw) ?: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the flat list of selectable séances for the seminar category.
|
||||
*
|
||||
* We walk every published seminar (cat 11), read its `seances` meta
|
||||
* (array of séance post IDs), and keep the séances whose date_de_debut
|
||||
* falls within [month_start, month_end + SEANCE_WINDOW_MARGIN_DAYS].
|
||||
* (array of séance post IDs), and keep every séance dont la date_de_debut
|
||||
* tombe dans le mois, ou dans la fenêtre « à venir » (les séances de la
|
||||
* rentrée doivent pouvoir entrer dans la newsletter de juillet).
|
||||
* Each returned item carries its parent seminar (id/title/permalink) so
|
||||
* the UI and the exporter can group séances under their seminar.
|
||||
*
|
||||
* @return array Flat list of séance items (see shape below).
|
||||
*/
|
||||
private function query_seminar_seances(int $month_start, int $month_end): array {
|
||||
private function query_seminar_seances(int $month_start, int $month_end, array $future): array {
|
||||
global $wpdb;
|
||||
|
||||
$window_end = $month_end + (self::SEANCE_WINDOW_MARGIN_DAYS * DAY_IN_SECONDS);
|
||||
|
||||
$seminar_ids = $wpdb->get_col($wpdb->prepare(
|
||||
"SELECT DISTINCT p.ID
|
||||
FROM {$wpdb->posts} p
|
||||
@@ -200,28 +383,13 @@ class Thalim_NL_Post_Query {
|
||||
|
||||
$raw_debut = get_post_meta($sid, 'date_de_debut', true) ?: '';
|
||||
$ts = $raw_debut ? strtotime($raw_debut) : false;
|
||||
if (!$ts || $ts < $month_start || $ts > $window_end) {
|
||||
$in_month = ($ts >= $month_start && $ts <= $month_end);
|
||||
$in_future = ($ts >= $future[0] && $ts <= $future[1]);
|
||||
if (!$ts || (!$in_month && !$in_future)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'id' => $sid,
|
||||
'title' => get_the_title($sid),
|
||||
// Links straight to the séance anchor on the seminar page.
|
||||
'permalink' => $seminar_permalink . '#seance-' . $sid,
|
||||
'datetime' => '',
|
||||
'date_debut' => $raw_debut,
|
||||
'date_fin' => get_post_meta($sid, 'date_de_fin', true) ?: '',
|
||||
'post_date' => $s_post->post_date,
|
||||
'heure_de_debut' => substr(get_post_meta($sid, 'heure_de_debut', true) ?: '', 0, 5),
|
||||
'heure_de_fin' => substr(get_post_meta($sid, 'heure_de_fin', true) ?: '', 0, 5),
|
||||
'lieu' => get_post_meta($sid, 'lieu', true) ?: '',
|
||||
'membres' => $this->get_post_membres($sid),
|
||||
'autrepersonnes' => get_post_meta($sid, 'autrepersonnes', true) ?: '',
|
||||
'seminar_id' => $seminar_id,
|
||||
'seminar_title' => $seminar_title,
|
||||
'seminar_permalink' => $seminar_permalink,
|
||||
];
|
||||
$items[] = $this->build_seance_data($sid, $seminar_id, $seminar_title, $seminar_permalink);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,39 +424,35 @@ class Thalim_NL_Post_Query {
|
||||
/**
|
||||
* Query a single category with the appropriate window expression.
|
||||
*/
|
||||
private function query_category(int $cat_id, string $window_type, int $month_start, int $month_end): array {
|
||||
private function query_category(int $cat_id, string $window_type, int $month_start, int $month_end, array $future): array {
|
||||
global $wpdb;
|
||||
|
||||
// Build window SQL expressions (UNIX_TIMESTAMP values for comparison)
|
||||
// Fin de fenêtre (UNIX_TIMESTAMP) : seul critère de filtrage restant.
|
||||
switch ($window_type) {
|
||||
case 'datetime_to_fin':
|
||||
$start_expr = $this->sql_datetime_start();
|
||||
$end_expr = $this->sql_datetime_to_fin_end();
|
||||
$order_expr = "COALESCE(NULLIF(pm_dt.meta_value, ''), p.post_date)";
|
||||
$end_expr = $this->sql_datetime_to_fin_end();
|
||||
break;
|
||||
|
||||
case 'debut_minus35_to_fin':
|
||||
$start_expr = $this->sql_debut_minus35_start();
|
||||
$end_expr = $this->sql_debut_to_fin_end();
|
||||
$order_expr = "COALESCE(NULLIF(pm_deb.meta_value, ''), p.post_date)";
|
||||
$end_expr = $this->sql_debut_to_fin_end();
|
||||
break;
|
||||
|
||||
case 'datetime_plus3m':
|
||||
$start_expr = $this->sql_datetime_start();
|
||||
$end_expr = $this->sql_datetime_plus3m_end();
|
||||
$order_expr = "COALESCE(NULLIF(pm_dt.meta_value, ''), p.post_date)";
|
||||
$end_expr = $this->sql_datetime_plus3m_end();
|
||||
break;
|
||||
|
||||
case 'datetime_plus35d':
|
||||
$start_expr = $this->sql_datetime_start();
|
||||
$end_expr = $this->sql_datetime_plus35d_end();
|
||||
$order_expr = "COALESCE(NULLIF(pm_dt.meta_value, ''), p.post_date)";
|
||||
$end_expr = $this->sql_datetime_plus35d_end();
|
||||
break;
|
||||
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
|
||||
// Date d'événement (date_de_debut > datetime > post_date), et non date
|
||||
// de publication : sert au tri et au plafond « à venir ».
|
||||
$order_expr = $this->sql_event_date_order();
|
||||
|
||||
$sql = $wpdb->prepare(
|
||||
"SELECT DISTINCT p.ID, p.post_title, p.post_date
|
||||
FROM {$wpdb->posts} p
|
||||
@@ -300,12 +464,17 @@ class Thalim_NL_Post_Query {
|
||||
LEFT JOIN {$wpdb->postmeta} pm_fin ON pm_fin.post_id = p.ID AND pm_fin.meta_key = 'date_de_fin'
|
||||
WHERE p.post_type = 'post'
|
||||
AND p.post_status = 'publish'
|
||||
AND {$start_expr} <= %d
|
||||
AND {$end_expr} >= %d
|
||||
AND (
|
||||
UNIX_TIMESTAMP({$order_expr}) <= %d
|
||||
OR ( UNIX_TIMESTAMP({$order_expr}) >= %d AND UNIX_TIMESTAMP({$order_expr}) <= %d )
|
||||
)
|
||||
ORDER BY {$order_expr} ASC",
|
||||
$cat_id,
|
||||
$month_start,
|
||||
$month_end,
|
||||
$month_start
|
||||
$future[0],
|
||||
$future[1]
|
||||
);
|
||||
|
||||
$rows = $wpdb->get_results($sql);
|
||||
@@ -325,16 +494,22 @@ class Thalim_NL_Post_Query {
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* UNIX_TIMESTAMP of: datetime meta if valid, else post_date
|
||||
* Date d'événement servant au tri : date_de_debut, sinon datetime, sinon
|
||||
* post_date — même ordre de priorité que le thème (post-card-helpers.php,
|
||||
* thalim_get_agenda_card_data()). Renvoie une chaîne date comparable.
|
||||
*/
|
||||
private function sql_datetime_start(): string {
|
||||
return "UNIX_TIMESTAMP(CASE
|
||||
private function sql_event_date_order(): string {
|
||||
return "CASE
|
||||
WHEN pm_deb.meta_value IS NOT NULL
|
||||
AND pm_deb.meta_value != ''
|
||||
AND LEFT(pm_deb.meta_value, 4) != '0000'
|
||||
THEN pm_deb.meta_value
|
||||
WHEN pm_dt.meta_value IS NOT NULL
|
||||
AND pm_dt.meta_value != ''
|
||||
AND LEFT(pm_dt.meta_value, 4) != '0000'
|
||||
THEN pm_dt.meta_value
|
||||
ELSE p.post_date
|
||||
END)";
|
||||
END";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -354,19 +529,6 @@ class Thalim_NL_Post_Query {
|
||||
END)";
|
||||
}
|
||||
|
||||
/**
|
||||
* UNIX_TIMESTAMP of (date_de_debut if valid, else post_date) minus 35 days (3024000 seconds)
|
||||
*/
|
||||
private function sql_debut_minus35_start(): string {
|
||||
return "(UNIX_TIMESTAMP(CASE
|
||||
WHEN pm_deb.meta_value IS NOT NULL
|
||||
AND pm_deb.meta_value != ''
|
||||
AND LEFT(pm_deb.meta_value, 4) != '0000'
|
||||
THEN pm_deb.meta_value
|
||||
ELSE p.post_date
|
||||
END) - 3024000)";
|
||||
}
|
||||
|
||||
/**
|
||||
* UNIX_TIMESTAMP of: date_de_fin if valid, else date_de_debut if valid, else post_date
|
||||
*/
|
||||
@@ -385,29 +547,24 @@ class Thalim_NL_Post_Query {
|
||||
}
|
||||
|
||||
/**
|
||||
* UNIX_TIMESTAMP of (datetime if valid, else post_date) + 3 months
|
||||
* UNIX_TIMESTAMP de (date d'événement) + 3 mois.
|
||||
*
|
||||
* La base est la date d'événement, pas la date de publication : sinon un
|
||||
* contenu annoncé longtemps à l'avance sort de la fenêtre avant d'avoir
|
||||
* eu lieu, et manque dans la newsletter de son propre mois. Pour les
|
||||
* ouvrages et articles, qui n'ont pas de date_de_debut, l'expression
|
||||
* retombe sur `datetime` — comportement inchangé.
|
||||
*/
|
||||
private function sql_datetime_plus3m_end(): string {
|
||||
return "UNIX_TIMESTAMP(DATE_ADD(CASE
|
||||
WHEN pm_dt.meta_value IS NOT NULL
|
||||
AND pm_dt.meta_value != ''
|
||||
AND LEFT(pm_dt.meta_value, 4) != '0000'
|
||||
THEN pm_dt.meta_value
|
||||
ELSE p.post_date
|
||||
END, INTERVAL 3 MONTH))";
|
||||
return "UNIX_TIMESTAMP(DATE_ADD(" . $this->sql_event_date_order() . ", INTERVAL 3 MONTH))";
|
||||
}
|
||||
|
||||
/**
|
||||
* UNIX_TIMESTAMP of (datetime if valid, else post_date) + 35 days
|
||||
* UNIX_TIMESTAMP de (date d'événement) + 35 jours. Même raison que
|
||||
* ci-dessus : la fenêtre suit l'événement, pas sa date de publication.
|
||||
*/
|
||||
private function sql_datetime_plus35d_end(): string {
|
||||
return "(UNIX_TIMESTAMP(CASE
|
||||
WHEN pm_dt.meta_value IS NOT NULL
|
||||
AND pm_dt.meta_value != ''
|
||||
AND LEFT(pm_dt.meta_value, 4) != '0000'
|
||||
THEN pm_dt.meta_value
|
||||
ELSE p.post_date
|
||||
END) + 3024000)";
|
||||
return "(UNIX_TIMESTAMP(" . $this->sql_event_date_order() . ") + 3024000)";
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user