commit f3970480c4ac66c3b11678fba0fa73fb4bc030a8
Author: Valentin Le Moign
Date: Tue May 12 23:34:00 2026 +0200
Initial commit
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9507787
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
+# --- OS / éditeur ---
+.DS_Store
+Thumbs.db
+*.swp
+*~
+.idea/
+.vscode/
+
+# --- Archives ---
+*.tar.gz
+*.tgz
+*.zip
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..85e4890
--- /dev/null
+++ b/README.md
@@ -0,0 +1,93 @@
+# thalim-newsletter
+
+Plugin WordPress qui compose les newsletters mensuelles du laboratoire THALIM à partir du contenu déjà publié sur le site, et les exporte en HTML prêt pour un envoi email.
+
+- **Version :** 1.0.0
+- **Auteur :** THALIM Dev
+- **Licence :** GPL v2 or later
+
+## Installation
+
+```bash
+cd wp-content/plugins
+git clone gitea@figureslibres.io:valentin_le_moign/thalim-plugin-newsletter.git thalim-newsletter
+```
+
+Puis activer depuis l'admin WordPress. Dans le cadre du projet THALIM, le clonage est automatisé par `bootstrap.sh` du repo [`thalim-stack`](https://figureslibres.io/valentin_le_moign/thalim-stack).
+
+## Utilisation
+
+Une fois activé, le plugin ajoute une page d'administration : **Outils → Newsletter** (capacité requise : `edit_others_posts`).
+
+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
+4. Champs **intro**, **conclusion**, **URL d'inscription**, **URL de désinscription**
+5. Sauvegarde : crée un post WordPress dans la catégorie **Newsletter** (`20`) avec le HTML email complet en `post_content`
+6. Bouton **Exporter en HTML** (`thalim_nl_export_html`) → téléchargement du fichier `newsletter-THALIM-{mois}.html`
+
+Une liste des newsletters déjà sauvegardées permet de revenir éditer un mois passé.
+
+## Fenêtres d'éligibilité par catégorie
+
+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`) :
+
+| Catégorie | Fenêtre |
+| -------------------------------------------------- | ---------------------- |
+| Appels (`8`), Soutenances (`14`) | `datetime_to_fin` (du `datetime` à `date_de_fin`) |
+| Colloques (`10`), Séminaires (`11`) | `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`) |
+
+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.
+
+## Catégories couvertes
+
+La liste des catégories éligibles n'est **pas** codée en dur dans le plugin — elle est calculée dynamiquement via `Thalim_NL_Post_Query::get_eligible_categories()` (toutes les catégories WordPress, groupées par parent). Les constantes en haut de `thalim-newsletter.php` (`THALIM_NL_CAT_APPELS = 8`, etc.) ne servent qu'à associer les fenêtres temporelles spéciales aux catégories concernées :
+
+| Constante | ID | Description |
+| ------------------------------- | --- | ----------------- |
+| `THALIM_NL_CAT_APPELS` | 8 | Appels |
+| `THALIM_NL_CAT_COLLOQUES` | 10 | Colloques |
+| `THALIM_NL_CAT_SEMINAIRES` | 11 | Séminaires |
+| `THALIM_NL_CAT_COMMS` | 13 | Communications |
+| `THALIM_NL_CAT_SOUTENANCES` | 14 | Soutenances |
+| `THALIM_NL_CAT_OUVRAGES` | 15 | Ouvrages |
+| `THALIM_NL_CAT_ARTICLES` | 16 | Articles |
+| `THALIM_NL_CAT_NEWSLETTER` | 20 | Newsletter (catégorie où sont sauvegardés les digests) |
+
+> IDs vérifiés en DB le 2026-03-20. À mettre à jour en cas de migration ou de réorganisation des taxonomies.
+
+## Format HTML email
+
+`includes/class-html-exporter.php` génère un HTML compatible clients mail :
+
+- Layout **table-based**, largeur 600 px
+- **Styles inline** sur tous les éléments
+- Polices : **Gelasio** (Google Fonts `@import`, fallback Georgia) pour les titres, Arial/Helvetica pour le corps
+- Media queries pour le rendu mobile
+- Preheader caché (texte d'aperçu dans les boîtes mail)
+
+Le HTML complet est généré à la sauvegarde et stocké tel quel dans `post_content` — l'export se contente de le renvoyer.
+
+## Prérequis
+
+- WordPress 6.0+
+- PHP 7.4+
+- Plugin **Pods** (le pod `post` et son champ catégorie pour la triple écriture)
+
+## Structure
+
+```
+.
+├── thalim-newsletter.php # point d'entrée, constantes, bootstrap
+├── assets/
+│ ├── admin.css # styles de la page admin
+│ └── admin.js # interactions (sélecteur mois, cases, export)
+└── includes/
+ ├── class-post-query.php # requêtes SQL custom + fenêtres temporelles par catégorie
+ ├── class-html-exporter.php # génération du HTML email (tables, inline styles)
+ └── class-admin-page.php # UI Tools > Newsletter + handlers AJAX + sauvegarde
+```
diff --git a/assets/admin.css b/assets/admin.css
new file mode 100644
index 0000000..119995b
--- /dev/null
+++ b/assets/admin.css
@@ -0,0 +1,263 @@
+/* ============================================================
+ THALIM Newsletter — Admin styles
+ All rules scoped to .thalim-nl-* or .wrap.thalim-nl-wrap
+============================================================ */
+
+.thalim-nl-layout {
+ display: grid;
+ grid-template-columns: 1fr 360px;
+ gap: 30px;
+ margin-top: 20px;
+ align-items: start;
+}
+
+/* ---- Month row ---- */
+.thalim-nl-month-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 16px;
+}
+
+.thalim-nl-month-row input[type="month"] {
+ font-size: 15px;
+ padding: 4px 8px;
+}
+
+.thalim-nl-spinner.spinner {
+ float: none;
+ margin: 0;
+ visibility: hidden;
+}
+
+.thalim-nl-spinner.spinner.is-active {
+ visibility: visible;
+}
+
+/* ---- Intro / Conclusion editors ---- */
+.thalim-nl-intro,
+.thalim-nl-conclusion {
+ margin-bottom: 20px;
+}
+
+.thalim-nl-intro label,
+.thalim-nl-conclusion label {
+ display: block;
+ margin-bottom: 6px;
+}
+
+.thalim-nl-conclusion {
+ margin-top: 20px;
+}
+
+/* ---- Category groups ---- */
+.thalim-nl-group {
+ margin-bottom: 20px;
+}
+
+.thalim-nl-group-title {
+ font-size: 13px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: #50575e;
+ margin: 0 0 8px 0;
+ padding-bottom: 4px;
+ border-bottom: 2px solid #dcdcde;
+}
+
+/* ---- Section dropdowns (details/summary) ---- */
+.thalim-nl-section {
+ border: 1px solid #ccd0d4;
+ border-radius: 4px;
+ margin-bottom: 6px;
+ background: #fff;
+}
+
+.thalim-nl-section-summary {
+ font-weight: 600;
+ font-size: 13px;
+ color: #1d2327;
+ padding: 8px 14px;
+ cursor: pointer;
+ list-style: none;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ user-select: none;
+}
+
+.thalim-nl-section-summary::-webkit-details-marker {
+ display: none;
+}
+
+.thalim-nl-section-summary::before {
+ content: '▸';
+ display: inline-block;
+ font-size: 11px;
+ color: #888;
+ transition: transform 0.15s ease;
+ flex-shrink: 0;
+ width: 12px;
+}
+
+.thalim-nl-section[open] > .thalim-nl-section-summary::before {
+ transform: rotate(90deg);
+}
+
+.thalim-nl-section-summary:hover {
+ background: #f6f7f7;
+}
+
+.thalim-nl-section-body {
+ padding: 2px 14px 10px 14px;
+ border-top: 1px solid #f0f0f0;
+}
+
+.thalim-nl-count {
+ font-weight: normal;
+ color: #666;
+ font-size: 12px;
+}
+
+.thalim-nl-post-row {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ padding: 4px 0;
+ cursor: pointer;
+ border-bottom: 1px solid #f0f0f0;
+}
+
+.thalim-nl-post-row:last-child {
+ border-bottom: none;
+}
+
+.thalim-nl-post-row input[type="checkbox"] {
+ flex-shrink: 0;
+ margin-top: 2px;
+}
+
+.thalim-nl-post-title {
+ flex: 1;
+ font-size: 13px;
+}
+
+.thalim-nl-post-view {
+ flex-shrink: 0;
+ color: #b4b9be;
+ text-decoration: none;
+ line-height: 1;
+}
+
+.thalim-nl-post-view:hover {
+ color: #2271b1;
+}
+
+.thalim-nl-post-view .dashicons {
+ font-size: 16px;
+ width: 16px;
+ height: 16px;
+}
+
+.thalim-nl-post-date {
+ font-size: 11px;
+ color: #888;
+ white-space: nowrap;
+}
+
+/* ---- Séances list under seminars ---- */
+.thalim-nl-seances-list {
+ margin: 2px 0 6px 28px;
+ padding: 0;
+ list-style: none;
+ border-left: 2px solid #dcdcde;
+ padding-left: 12px;
+}
+
+.thalim-nl-seance-item {
+ padding: 3px 0;
+ font-size: 12px;
+ color: #50575e;
+}
+
+.thalim-nl-seance-title {
+ font-weight: 500;
+}
+
+.thalim-nl-seance-meta {
+ display: block;
+ color: #888;
+ font-size: 11px;
+}
+
+/* ---- Empty state ---- */
+.thalim-nl-empty {
+ color: #666;
+ font-style: italic;
+ padding: 8px 0;
+}
+
+/* ---- Past newsletters table ---- */
+.thalim-nl-past {
+ background: #fff;
+ border: 1px solid #ccd0d4;
+ border-radius: 4px;
+ padding: 16px;
+}
+
+.thalim-nl-past h2 {
+ margin-top: 0;
+}
+
+.thalim-nl-past-table {
+ border-collapse: collapse;
+ width: 100%;
+}
+
+.thalim-nl-past-table th,
+.thalim-nl-past-table td {
+ padding: 8px 10px;
+ text-align: left;
+ border-bottom: 1px solid #f0f0f0;
+}
+
+.thalim-nl-past-table th {
+ font-weight: 600;
+ background: #f8f9fa;
+}
+
+.thalim-nl-past-table td:last-child {
+ white-space: nowrap;
+}
+
+.thalim-nl-past-table .button + .button {
+ margin-left: 6px;
+}
+
+/* ---- Subscribe / Unsubscribe link fields ---- */
+.thalim-nl-links-row {
+ display: flex;
+ gap: 20px;
+ margin: 20px 0;
+}
+
+.thalim-nl-link-field {
+ flex: 1;
+}
+
+.thalim-nl-link-field label {
+ display: block;
+ margin-bottom: 6px;
+}
+
+.thalim-nl-link-field input {
+ width: 100%;
+}
+
+/* ---- Responsive fallback ---- */
+@media (max-width: 1100px) {
+ .thalim-nl-layout {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/assets/admin.js b/assets/admin.js
new file mode 100644
index 0000000..23b241c
--- /dev/null
+++ b/assets/admin.js
@@ -0,0 +1,136 @@
+/* global thalimNL, tinymce */
+(function ($) {
+ 'use strict';
+
+ // -------------------------------------------------------------------------
+ // Month change → AJAX reload sections + update TinyMCE intro
+ // -------------------------------------------------------------------------
+ $('#thalim-nl-month').on('change', function () {
+ var month = $(this).val();
+ var $wrap = $('#thalim-nl-sections-wrap');
+ var $spin = $('.thalim-nl-spinner');
+
+ if (!month) return;
+
+ $spin.addClass('is-active');
+ $wrap.css('opacity', '0.5');
+
+ $.post(thalimNL.ajaxUrl, {
+ action: 'thalim_nl_load_month',
+ month: month,
+ _wpnonce: thalimNL.nonce,
+ })
+ .done(function (response) {
+ if (!response.success) return;
+
+ $wrap.html(response.data.html);
+
+ // Update TinyMCE intro — do NOT re-render wp_editor
+ var introEditor = typeof tinymce !== 'undefined' && tinymce.get('thalim_nl_intro');
+ if (introEditor) {
+ introEditor.setContent(response.data.intro || '');
+ } else {
+ $('#thalim_nl_intro').val(response.data.intro || '');
+ }
+
+ // Update TinyMCE conclusion
+ var conclusionEditor = typeof tinymce !== 'undefined' && tinymce.get('thalim_nl_conclusion');
+ if (conclusionEditor) {
+ conclusionEditor.setContent(response.data.conclusion || '');
+ } else {
+ $('#thalim_nl_conclusion').val(response.data.conclusion || '');
+ }
+
+ // Update subscribe/unsubscribe URLs
+ $('#thalim-nl-subscribe').val(response.data.subscribe_url || '');
+ $('#thalim-nl-unsubscribe').val(response.data.unsubscribe_url || '');
+ })
+ .fail(function () {
+ $wrap.html('Erreur lors du chargement.
');
+ })
+ .always(function () {
+ $spin.removeClass('is-active');
+ $wrap.css('opacity', '1');
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // Fetch newsletter HTML (shared by download & preview)
+ // -------------------------------------------------------------------------
+ function fetchNewsletterHTML(postId) {
+ return $.post(thalimNL.ajaxUrl, {
+ action: 'thalim_nl_export_html',
+ post_id: postId,
+ _wpnonce: thalimNL.nonce,
+ });
+ }
+
+ // -------------------------------------------------------------------------
+ // Download HTML file
+ // -------------------------------------------------------------------------
+ $(document).on('click', '.thalim-nl-download', function () {
+ var $btn = $(this);
+ var postId = $btn.data('post-id');
+ var filename = $btn.data('filename') || 'newsletter-THALIM.html';
+ var origText = $btn.text();
+
+ $btn.prop('disabled', true).text('…');
+
+ fetchNewsletterHTML(postId)
+ .done(function (response) {
+ if (!response.success) {
+ alert('Erreur : ' + (response.data || 'Impossible d\'exporter le HTML.'));
+ return;
+ }
+ var blob = new Blob([response.data.html], { type: 'text/html;charset=utf-8' });
+ var url = URL.createObjectURL(blob);
+ var a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ })
+ .fail(function () {
+ alert('Erreur réseau lors de l\'export.');
+ })
+ .always(function () {
+ $btn.prop('disabled', false).text(origText);
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // Preview in new window
+ // -------------------------------------------------------------------------
+ $(document).on('click', '.thalim-nl-preview', function () {
+ var $btn = $(this);
+ var postId = $btn.data('post-id');
+ var origText = $btn.text();
+
+ $btn.prop('disabled', true).text('…');
+
+ fetchNewsletterHTML(postId)
+ .done(function (response) {
+ if (!response.success) {
+ alert('Erreur : ' + (response.data || 'Impossible de charger la prévisualisation.'));
+ return;
+ }
+ var win = window.open('', '_blank');
+ if (win) {
+ win.document.open();
+ win.document.write(response.data.html);
+ win.document.close();
+ } else {
+ alert('Le navigateur a bloqué l\'ouverture de la fenêtre. Autorisez les pop-ups pour ce site.');
+ }
+ })
+ .fail(function () {
+ alert('Erreur réseau lors du chargement.');
+ })
+ .always(function () {
+ $btn.prop('disabled', false).text(origText);
+ });
+ });
+
+}(jQuery));
diff --git a/includes/class-admin-page.php b/includes/class-admin-page.php
new file mode 100644
index 0000000..e9b34d2
--- /dev/null
+++ b/includes/class-admin-page.php
@@ -0,0 +1,550 @@
+ Newsletter
+ */
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+class Thalim_NL_Admin_Page {
+
+ // -------------------------------------------------------------------------
+ // Main render
+ // -------------------------------------------------------------------------
+
+ public function render() {
+ if (!current_user_can('edit_others_posts')) {
+ wp_die('Unauthorized');
+ }
+
+ // Determine active month
+ $year_month = sanitize_text_field($_GET['nl_month'] ?? '');
+ if (!preg_match('/^\d{4}-\d{2}$/', $year_month)) {
+ $year_month = date('Y-m');
+ }
+
+ // Handle synchronous save
+ $save_message = null;
+ if (isset($_POST['thalim_nl_save']) && wp_verify_nonce($_POST['_wpnonce'] ?? '', 'thalim_nl_save')) {
+ $save_message = $this->handle_save_newsletter();
+ // Re-read month after save (may have been changed in form)
+ $posted_month = sanitize_text_field($_POST['nl_month'] ?? '');
+ if (preg_match('/^\d{4}-\d{2}$/', $posted_month)) {
+ $year_month = $posted_month;
+ }
+ }
+
+ // Load existing newsletter for this month (if any)
+ $existing = $this->get_newsletter_post_for_month($year_month);
+ $existing_sections = [];
+ $intro_content = '';
+ $conclusion_content = '';
+ $subscribe_url = '';
+ $unsubscribe_url = '';
+ if ($existing) {
+ $existing_sections = json_decode(get_post_meta($existing->ID, '_newsletter_sections', true), true) ?: [];
+ $intro_content = get_post_meta($existing->ID, '_newsletter_intro', true) ?: '';
+ $conclusion_content = get_post_meta($existing->ID, '_newsletter_conclusion', true) ?: '';
+ $subscribe_url = get_post_meta($existing->ID, '_newsletter_subscribe_url', true) ?: '';
+ $unsubscribe_url = get_post_meta($existing->ID, '_newsletter_unsubscribe_url', true) ?: '';
+ }
+
+ // Query posts for current month
+ $query = new Thalim_NL_Post_Query();
+ $month_data = $query->get_posts_for_month($year_month);
+
+ // Past newsletters
+ $past_newsletters = get_posts([
+ 'post_type' => 'post',
+ 'post_status' => ['draft', 'publish', 'pending'],
+ 'posts_per_page' => -1,
+ 'meta_key' => '_newsletter_month',
+ 'orderby' => 'meta_value',
+ 'order' => 'DESC',
+ 'lang' => '',
+ ]);
+ $past_newsletters = array_filter($past_newsletters, function ($p) {
+ return !empty(get_post_meta($p->ID, '_newsletter_month', true));
+ });
+
+ ?>
+
+
Newsletter THALIM
+
+
+
+
+
+
+
+
+
+
+
+
+
Newsletters enregistrées
+
+
+
Aucune newsletter enregistrée.
+
+
+
+
+ | Mois |
+ Actions |
+
+
+
+
+ ID, '_newsletter_month', true);
+ $label = $this->format_month_label($nl_month);
+ $edit_url = get_edit_post_link($nl->ID);
+ ?>
+
+ |
+
+ Modifier
+
+
+ |
+
+
+
+
+
+
+
+
+
+ $group) {
+ // Collect all cat IDs in this group: parent + children
+ $cats_in_group = [$parent_id => $group['name']];
+ foreach ($group['children'] as $cid => $cname) {
+ $cats_in_group[$cid] = $cname;
+ }
+
+ // Check if any cat in this group has posts
+ $group_has_posts = false;
+ foreach ($cats_in_group as $cid => $cname) {
+ if (!empty($month_data[$cid])) {
+ $group_has_posts = true;
+ break;
+ }
+ }
+
+ if (!$group_has_posts) {
+ continue;
+ }
+
+ $has_content = true;
+ ?>
+
+
+ $label):
+ $posts = $month_data[$cat_id] ?? [];
+ if (empty($posts)) {
+ continue;
+ }
+ $count = count($posts);
+ $checked_in_section = (array) ($checked_ids[$cat_id] ?? $checked_ids[(string) $cat_id] ?? []);
+ $has_checked = !empty($checked_in_section);
+ ?>
+
>
+
+
+ ()
+
+
+
+
+
+
+
+
+
+
+
+
+ Aucun contenu trouvé pour ce mois.
';
+ }
+ return $html;
+ }
+
+ /**
+ * 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);
+ }
+ }
+ }
+ return '';
+ }
+
+ // -------------------------------------------------------------------------
+ // Save
+ // -------------------------------------------------------------------------
+
+ private function handle_save_newsletter(): array {
+ $year_month = sanitize_text_field($_POST['nl_month'] ?? '');
+ if (!preg_match('/^\d{4}-\d{2}$/', $year_month)) {
+ return ['error', 'Mois invalide.'];
+ }
+
+ $intro = wp_kses_post(wp_unslash($_POST['nl_intro'] ?? ''));
+ $conclusion = wp_kses_post(wp_unslash($_POST['nl_conclusion'] ?? ''));
+ $subscribe_url = esc_url_raw(wp_unslash($_POST['nl_subscribe_url'] ?? ''));
+ $unsubscribe_url = esc_url_raw(wp_unslash($_POST['nl_unsubscribe_url'] ?? ''));
+
+ // Build sections: cat_id => [post_id, ...]
+ $raw_sections = $_POST['nl_sections'] ?? [];
+ $sections = [];
+ foreach ($raw_sections as $cat_id => $post_ids) {
+ $cat_id = (int) $cat_id;
+ $clean_ids = array_map('intval', (array) $post_ids);
+ $clean_ids = array_filter($clean_ids);
+ if (!empty($clean_ids)) {
+ $sections[$cat_id] = array_values($clean_ids);
+ }
+ }
+
+ $post_id = $this->save_newsletter_post($year_month, $intro, $conclusion, $sections, $subscribe_url, $unsubscribe_url);
+ if (is_wp_error($post_id)) {
+ return ['error', $post_id->get_error_message()];
+ }
+
+ $label = $this->format_month_label($year_month);
+ return ['success', "Newsletter «\u{00A0}{$label}\u{00A0}» enregistrée et publiée (ID\u{00A0}: {$post_id})."];
+ }
+
+ private function save_newsletter_post(string $year_month, string $intro, string $conclusion, array $sections, string $subscribe_url = '', string $unsubscribe_url = ''): int|WP_Error {
+ global $wpdb;
+
+ $label = 'Newsletter — ' . $this->format_month_label($year_month);
+ $existing = $this->get_newsletter_post_for_month($year_month);
+
+ // First pass: insert/update with placeholder content (meta must exist before exporter runs)
+ $post_args = [
+ 'post_title' => $label,
+ 'post_content' => '',
+ 'post_status' => 'publish',
+ 'post_type' => 'post',
+ ];
+
+ if ($existing) {
+ $post_args['ID'] = $existing->ID;
+ $post_id = wp_update_post($post_args, true);
+ } else {
+ $post_id = wp_insert_post($post_args, true);
+ }
+
+ if (is_wp_error($post_id)) {
+ return $post_id;
+ }
+
+ // Save all meta so the exporter can read them
+ update_post_meta($post_id, '_newsletter_month', $year_month);
+ update_post_meta($post_id, '_newsletter_intro', $intro);
+ update_post_meta($post_id, '_newsletter_conclusion', $conclusion);
+ update_post_meta($post_id, '_newsletter_sections', wp_json_encode($sections));
+ update_post_meta($post_id, '_newsletter_subscribe_url', $subscribe_url);
+ update_post_meta($post_id, '_newsletter_unsubscribe_url', $unsubscribe_url);
+
+ // Generate full rendered HTML and store as post_content
+ $exporter = new Thalim_NL_HTML_Exporter();
+ $full_html = $exporter->generate(get_post($post_id));
+ wp_update_post(['ID' => $post_id, 'post_content' => $full_html]);
+
+ $this->do_triple_storage_category($post_id, THALIM_NL_CAT_NEWSLETTER);
+
+ if (function_exists('pll_set_post_language')) {
+ pll_set_post_language($post_id, 'fr');
+ }
+
+ return $post_id;
+ }
+
+ /**
+ * Assign a category using the Pods triple-storage pattern (same as HAL importer).
+ */
+ private function do_triple_storage_category(int $post_id, int $cat_id): void {
+ global $wpdb;
+
+ // 1. Native WP category assignment
+ wp_set_post_categories($post_id, [$cat_id]);
+
+ // 2. Pods postmeta: single integer
+ update_post_meta($post_id, 'categorie', $cat_id);
+
+ // 3. Pods _pods_ meta: serialized array
+ update_post_meta($post_id, '_pods_categorie', [$cat_id]);
+
+ // 4. wp_podsrel row
+ $wpdb->delete(
+ $wpdb->prefix . 'podsrel',
+ [
+ 'pod_id' => THALIM_NL_POD_ID_POST,
+ 'field_id' => THALIM_NL_FIELD_ID_CAT,
+ 'item_id' => $post_id,
+ ],
+ ['%d', '%d', '%d']
+ );
+ $wpdb->insert(
+ $wpdb->prefix . 'podsrel',
+ [
+ 'pod_id' => THALIM_NL_POD_ID_POST,
+ 'field_id' => THALIM_NL_FIELD_ID_CAT,
+ 'item_id' => $post_id,
+ 'related_pod_id' => 0,
+ 'related_field_id' => 0,
+ 'related_item_id' => $cat_id,
+ 'weight' => 0,
+ ],
+ ['%d', '%d', '%d', '%d', '%d', '%d', '%d']
+ );
+ }
+
+ // -------------------------------------------------------------------------
+ // AJAX handlers
+ // -------------------------------------------------------------------------
+
+ public function handle_ajax_load_month(): void {
+ check_ajax_referer('thalim_newsletter_ajax', '_wpnonce');
+
+ if (!current_user_can('edit_others_posts')) {
+ wp_send_json_error('Unauthorized', 403);
+ }
+
+ $year_month = sanitize_text_field($_POST['month'] ?? '');
+ if (!preg_match('/^\d{4}-\d{2}$/', $year_month)) {
+ wp_send_json_error('Invalid month format');
+ }
+
+ $query = new Thalim_NL_Post_Query();
+ $month_data = $query->get_posts_for_month($year_month);
+
+ $existing = $this->get_newsletter_post_for_month($year_month);
+ $existing_sections = [];
+ $intro_content = '';
+ $conclusion_content = '';
+ $subscribe_url = '';
+ $unsubscribe_url = '';
+ $has_existing = false;
+
+ if ($existing) {
+ $has_existing = true;
+ $existing_sections = json_decode(get_post_meta($existing->ID, '_newsletter_sections', true), true) ?: [];
+ $intro_content = get_post_meta($existing->ID, '_newsletter_intro', true) ?: '';
+ $conclusion_content = get_post_meta($existing->ID, '_newsletter_conclusion', true) ?: '';
+ $subscribe_url = get_post_meta($existing->ID, '_newsletter_subscribe_url', true) ?: '';
+ $unsubscribe_url = get_post_meta($existing->ID, '_newsletter_unsubscribe_url', true) ?: '';
+ }
+
+ $html = $this->render_sections_html($month_data, $existing_sections);
+
+ wp_send_json_success([
+ 'html' => $html,
+ 'intro' => $intro_content,
+ 'conclusion' => $conclusion_content,
+ 'subscribe_url' => $subscribe_url,
+ 'unsubscribe_url' => $unsubscribe_url,
+ 'has_existing' => $has_existing,
+ ]);
+ }
+
+ public function handle_ajax_export_html(): void {
+ check_ajax_referer('thalim_newsletter_ajax', '_wpnonce');
+
+ if (!current_user_can('edit_others_posts')) {
+ wp_send_json_error('Unauthorized', 403);
+ }
+
+ $post_id = (int) ($_POST['post_id'] ?? 0);
+ if (!$post_id) {
+ wp_send_json_error('Missing post_id');
+ }
+
+ $post = get_post($post_id);
+ if (!$post) {
+ wp_send_json_error('Post not found');
+ }
+
+ // post_content already holds the full rendered HTML (set on save)
+ wp_send_json_success(['html' => $post->post_content]);
+ }
+
+ // -------------------------------------------------------------------------
+ // Helpers
+ // -------------------------------------------------------------------------
+
+ public function get_newsletter_post_for_month(string $year_month): ?WP_Post {
+ $posts = get_posts([
+ 'post_type' => 'post',
+ 'post_status' => ['draft', 'publish', 'pending'],
+ 'posts_per_page' => 1,
+ 'meta_key' => '_newsletter_month',
+ 'meta_value' => $year_month,
+ 'lang' => '', // Polylang: all languages
+ ]);
+ return $posts[0] ?? null;
+ }
+
+ private function format_month_label(string $year_month): string {
+ $ts = strtotime($year_month . '-01');
+ if (!$ts) {
+ return $year_month;
+ }
+ // e.g. "Mars 2026"
+ return ucfirst(date_i18n('F Y', $ts));
+ }
+}
diff --git a/includes/class-html-exporter.php b/includes/class-html-exporter.php
new file mode 100644
index 0000000..e07770d
--- /dev/null
+++ b/includes/class-html-exporter.php
@@ -0,0 +1,610 @@
+ self::COLOR_LABORATOIRE,
+ 3 => self::COLOR_MANIFESTATIONS,
+ 4 => self::COLOR_PUBLICATIONS,
+ 5 => self::COLOR_MEDIATIONS,
+ 6 => self::COLOR_RESSOURCES,
+ ];
+
+ // ── Public entry point ────────────────────────────────────────────────────
+
+ /**
+ * Generate a complete email-ready HTML document from a saved newsletter post.
+ */
+ /** Month window timestamps — set by generate(), used by format_seminar_line() */
+ private int $month_start = 0;
+ private int $month_end = 0;
+
+ public function generate(WP_Post $post): string {
+ $sections_json = get_post_meta($post->ID, '_newsletter_sections', true);
+ $sections = json_decode($sections_json, true) ?: [];
+
+ $intro = get_post_meta($post->ID, '_newsletter_intro', true) ?: '';
+ $conclusion = get_post_meta($post->ID, '_newsletter_conclusion', true) ?: '';
+ $subscribe_url = get_post_meta($post->ID, '_newsletter_subscribe_url', true) ?: '';
+ $unsubscribe_url = get_post_meta($post->ID, '_newsletter_unsubscribe_url', true) ?: '';
+
+ // Compute month window from stored newsletter month
+ $year_month = get_post_meta($post->ID, '_newsletter_month', true) ?: '';
+ if ($year_month) {
+ $this->month_start = strtotime($year_month . '-01 00:00:00') ?: 0;
+ $this->month_end = strtotime('last day of ' . $year_month . ' 23:59:59') ?: 0;
+ }
+
+ // Build ordered section blocks following the category hierarchy
+ $groups = Thalim_NL_Post_Query::get_eligible_categories();
+ $section_blocks = [];
+
+ foreach ($groups as $parent_id => $group) {
+ $color = self::PARENT_COLORS[$parent_id] ?? self::COLOR_DARK;
+
+ // All cat IDs in this group: parent + children
+ $cats_in_group = [$parent_id => $group['name']];
+ foreach ($group['children'] as $cid => $cname) {
+ $cats_in_group[$cid] = $cname;
+ }
+
+ foreach ($cats_in_group as $cat_id => $label) {
+ $post_ids = $sections[$cat_id] ?? $sections[(string) $cat_id] ?? [];
+ if (empty($post_ids)) {
+ continue;
+ }
+ $items = [];
+ foreach ($post_ids as $pid) {
+ $line = $this->format_post_line((int) $pid, $cat_id);
+ if ($line) {
+ $items[] = $line;
+ }
+ }
+ if (!empty($items)) {
+ $section_blocks[] = [
+ 'label' => $label,
+ 'color' => $color,
+ 'items' => $items,
+ ];
+ }
+ }
+ }
+
+ return $this->render_email_document($intro, $section_blocks, $conclusion, $subscribe_url, $unsubscribe_url);
+ }
+
+ // ── Email document builder ─────────────────────────────────────────────────
+
+ private function render_email_document(string $intro, array $section_blocks, string $conclusion, string $subscribe_url = '', string $unsubscribe_url = ''): string {
+ $logo_url = get_theme_file_uri('assets/images/thalim-logo.png');
+ $site_url = get_bloginfo('url');
+ $site_name = get_bloginfo('name');
+
+ // Font stacks (Gelasio via Google Fonts for clients that support it)
+ $font_serif = "Gelasio, Georgia, 'Times New Roman', serif";
+ $font_sans = "Arial, Helvetica, sans-serif";
+
+ // Shared cell style
+ $cell_padding = 'padding:32px 40px;';
+ $body_style = "font-family:{$font_sans};font-size:15px;line-height:1.6;color:" . self::COLOR_DARK . ";";
+
+ ob_start();
+ ?>
+
+
+
+
+
+
+
+ — Newsletter
+
+
+
+
+
+
+
+ — Newsletter
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+ |
+
+
+
+
+
+ |
+ process_rich_text($intro); ?>
+ |
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+ |
+
+
+ |
+
+ |
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+ |
+ process_rich_text($conclusion); ?>
+ |
+
+
+
+
+
+ |
+
+
+
+
+ Théorie et Histoire des Arts et des Littératures de la Modernité
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+ |
+
+
+
+
+ format_seminar_line($post_id);
+ }
+
+ $post = get_post($post_id);
+ if (!$post) {
+ return '';
+ }
+
+ $font_serif = "Gelasio, Georgia, 'Times New Roman', serif";
+ $font_sans = "Arial, Helvetica, sans-serif";
+
+ $url = get_permalink($post_id);
+ $title = get_the_title($post_id);
+
+ // Date
+ $date_str = $this->format_date_for_category($post_id, $cat_id);
+
+ // Authors
+ $author_parts = [];
+ foreach (get_post_meta($post_id, 'membres', false) as $uid) {
+ $user = get_userdata((int) $uid);
+ if ($user) {
+ $author_parts[] = $user->display_name;
+ }
+ }
+ $autrepersonnes = get_post_meta($post_id, 'autrepersonnes', true) ?: '';
+ if ($autrepersonnes) {
+ $author_parts[] = $autrepersonnes;
+ }
+ $authors_str = implode(', ', $author_parts);
+
+ // Image (first image from documents_joints)
+ $image_url = '';
+ $doc_ids = get_post_meta($post_id, 'documents_joints', false);
+ foreach ($doc_ids as $doc_id) {
+ $mime = get_post_mime_type($doc_id);
+ if ($mime && str_starts_with($mime, 'image/')) {
+ $src_data = wp_get_attachment_image_src($doc_id, 'thumbnail');
+ if ($src_data) {
+ $image_url = $src_data[0];
+ break;
+ }
+ }
+ }
+
+ // Meta line: date — authors
+ $meta_parts = [];
+ if ($date_str) {
+ $meta_parts[] = esc_html($date_str);
+ }
+ if ($authors_str) {
+ $meta_parts[] = esc_html($authors_str);
+ }
+ $meta_html = implode(' — ', $meta_parts);
+
+ // Build the post block as a table row
+ ob_start();
+ ?>
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+ post_status !== 'publish') continue;
+
+ $raw_debut = get_post_meta($sid, 'date_de_debut', true) ?: '';
+ $ts = $raw_debut ? strtotime($raw_debut) : false;
+
+ // Filter: only séances within the month window
+ if ($this->month_start && $this->month_end) {
+ if (!$ts || $ts < $this->month_start || $ts > $this->month_end) {
+ continue;
+ }
+ }
+
+ $seances[] = [
+ 'id' => $sid,
+ 'title' => get_the_title($sid),
+ 'date_debut' => $raw_debut,
+ 'date_fin' => get_post_meta($sid, 'date_de_fin', true) ?: '',
+ '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) ?: '',
+ ];
+ }
+ usort($seances, fn($a, $b) => strcmp($a['date_debut'], $b['date_debut']));
+
+ ob_start();
+ ?>
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+ |
+
+
+ format_raw_date($raw_debut);
+ $fin_str = $this->format_raw_date($raw_fin);
+ if ($debut_str && $fin_str && $debut_str !== $fin_str) {
+ return $debut_str . '–' . $fin_str;
+ }
+ return $debut_str ?: $fin_str;
+ }
+
+ $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);
+ }
+ $post = get_post($post_id);
+ return $post ? $this->format_raw_date($post->post_date) : '';
+ }
+
+ private function format_raw_date(string $raw): string {
+ if (!$raw || str_starts_with($raw, '0000-00-00')) {
+ return '';
+ }
+ $ts = strtotime($raw);
+ return $ts ? date_i18n('j F Y', $ts) : '';
+ }
+
+ // ── Rich-text processor (intro / conclusion) ──────────────────────────────
+
+ /**
+ * Convert stored WYSIWYG content to email-safe inline-styled HTML.
+ * Applies wpautop then inlines font/colour styles on common tags.
+ */
+ private function process_rich_text(string $content): string {
+ $font_serif = "Gelasio, Georgia, 'Times New Roman', serif";
+ $font_sans = "Arial, Helvetica, sans-serif";
+ $color_dark = self::COLOR_DARK;
+ $color_mid = self::COLOR_MID;
+ $color_rule = self::COLOR_RULE;
+
+ $html = wpautop($content);
+
+ // Style
+ $html = preg_replace(
+ '/
])/',
+ '
+ $html = preg_replace(
+ '/])/',
+ '
+ $html = preg_replace(
+ '/])/',
+ '
+ $html = preg_replace(
+ '/])/',
+ '
+ $html = preg_replace(
+ '/- ])/',
+ '
-
+ $html = preg_replace(
+ '/
])/',
+ ' 'datetime_to_fin',
+ THALIM_NL_CAT_COLLOQUES => 'debut_minus35_to_fin',
+ THALIM_NL_CAT_SEMINAIRES => 'debut_minus35_to_fin',
+ THALIM_NL_CAT_COMMS => 'debut_minus35_to_fin',
+ THALIM_NL_CAT_SOUTENANCES => 'datetime_to_fin',
+ THALIM_NL_CAT_OUVRAGES => 'datetime_plus3m',
+ THALIM_NL_CAT_ARTICLES => 'datetime_plus3m',
+ ];
+
+ private const DEFAULT_WINDOW_TYPE = 'datetime_plus35d';
+
+ /** Categories to exclude from the newsletter UI */
+ private const EXCLUDED_CATS = [
+ 12, // Séance de séminaire
+ 20, // Newsletter
+ 31, // Non classé
+ ];
+
+ /**
+ * Get all newsletter-eligible categories, grouped by parent.
+ * Returns [ parent_id => ['name' => string, 'children' => [cat_id => name, ...]], ... ]
+ */
+ public static function get_eligible_categories(): array {
+ $all_cats = get_categories([
+ 'taxonomy' => 'category',
+ 'hide_empty' => false,
+ 'orderby' => 'term_id',
+ 'order' => 'ASC',
+ ]);
+
+ $by_parent = [];
+ $parents = [];
+
+ foreach ($all_cats as $cat) {
+ if (in_array($cat->term_id, self::EXCLUDED_CATS, true)) {
+ continue;
+ }
+ if ($cat->parent == 0) {
+ $parents[$cat->term_id] = $cat->name;
+ }
+ }
+
+ foreach ($parents as $pid => $pname) {
+ $by_parent[$pid] = ['name' => $pname, 'children' => []];
+ }
+
+ foreach ($all_cats as $cat) {
+ if (in_array($cat->term_id, self::EXCLUDED_CATS, true)) {
+ continue;
+ }
+ if ($cat->parent == 0) {
+ continue; // parents handled above
+ }
+ $p = $cat->parent;
+ if (!isset($by_parent[$p])) {
+ continue;
+ }
+ $by_parent[$p]['children'][$cat->term_id] = $cat->name;
+ }
+
+ return $by_parent;
+ }
+
+ /**
+ * Flat list of all eligible category IDs (excluding EXCLUDED_CATS).
+ */
+ public static function get_all_eligible_cat_ids(): array {
+ $groups = self::get_eligible_categories();
+ $ids = [];
+ foreach ($groups as $pid => $group) {
+ $ids[] = $pid;
+ foreach ($group['children'] as $cid => $name) {
+ $ids[] = $cid;
+ }
+ }
+ return $ids;
+ }
+
+ /**
+ * Get the window type for a given category.
+ */
+ public static function get_window_type(int $cat_id): string {
+ return self::SPECIAL_WINDOW_TYPES[$cat_id] ?? self::DEFAULT_WINDOW_TYPE;
+ }
+
+ /**
+ * Get posts grouped by category for the given year-month (e.g. "2026-03").
+ *
+ * @param string $year_month Format: YYYY-MM
+ * @return array ['cat_id' => [post_data, ...], ...]
+ */
+ public function get_posts_for_month(string $year_month): array {
+ $month_start = strtotime($year_month . '-01 00:00:00');
+ if (!$month_start) {
+ return [];
+ }
+ $month_end = strtotime('last day of ' . $year_month . ' 23:59:59');
+
+ $result = [];
+ foreach (self::get_all_eligible_cat_ids() as $cat_id) {
+ $window_type = self::get_window_type($cat_id);
+ $posts = $this->query_category($cat_id, $window_type, $month_start, $month_end);
+ if (!empty($posts)) {
+ $result[$cat_id] = $posts;
+ }
+ }
+ return $result;
+ }
+
+ /**
+ * 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 {
+ global $wpdb;
+
+ // Build window SQL expressions (UNIX_TIMESTAMP values for comparison)
+ 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)";
+ 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)";
+ 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)";
+ 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)";
+ break;
+
+ default:
+ return [];
+ }
+
+ $sql = $wpdb->prepare(
+ "SELECT DISTINCT p.ID, p.post_title, p.post_date
+ FROM {$wpdb->posts} p
+ INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
+ INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
+ AND tt.taxonomy = 'category' AND tt.term_id = %d
+ LEFT JOIN {$wpdb->postmeta} pm_dt ON pm_dt.post_id = p.ID AND pm_dt.meta_key = 'datetime'
+ LEFT JOIN {$wpdb->postmeta} pm_deb ON pm_deb.post_id = p.ID AND pm_deb.meta_key = 'date_de_debut'
+ 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
+ ORDER BY {$order_expr} ASC",
+ $cat_id,
+ $month_end,
+ $month_start
+ );
+
+ $rows = $wpdb->get_results($sql);
+ if (!$rows) {
+ return [];
+ }
+
+ $posts = [];
+ foreach ($rows as $row) {
+ $posts[] = $this->build_post_data((int) $row->ID, $row->post_title, $row->post_date, $month_start, $month_end);
+ }
+ return $posts;
+ }
+
+ // -------------------------------------------------------------------------
+ // SQL window expression helpers (return raw SQL strings, not prepared)
+ // -------------------------------------------------------------------------
+
+ /**
+ * UNIX_TIMESTAMP of: datetime meta if valid, else post_date
+ */
+ private function sql_datetime_start(): 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)";
+ }
+
+ /**
+ * UNIX_TIMESTAMP of: date_de_fin if valid, else datetime if valid, else post_date
+ */
+ private function sql_datetime_to_fin_end(): string {
+ return "UNIX_TIMESTAMP(CASE
+ WHEN pm_fin.meta_value IS NOT NULL
+ AND pm_fin.meta_value != ''
+ AND LEFT(pm_fin.meta_value, 4) != '0000'
+ THEN pm_fin.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)";
+ }
+
+ /**
+ * 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
+ */
+ private function sql_debut_to_fin_end(): string {
+ return "UNIX_TIMESTAMP(CASE
+ WHEN pm_fin.meta_value IS NOT NULL
+ AND pm_fin.meta_value != ''
+ AND LEFT(pm_fin.meta_value, 4) != '0000'
+ THEN pm_fin.meta_value
+ 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)";
+ }
+
+ /**
+ * UNIX_TIMESTAMP of (datetime if valid, else post_date) + 3 months
+ */
+ 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))";
+ }
+
+ /**
+ * UNIX_TIMESTAMP of (datetime if valid, else post_date) + 35 days
+ */
+ 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)";
+ }
+
+ // -------------------------------------------------------------------------
+ // Post data builder
+ // -------------------------------------------------------------------------
+
+ private function build_post_data(int $post_id, string $post_title, string $post_date, int $month_start = 0, int $month_end = 0): array {
+ $data = [
+ 'id' => $post_id,
+ 'title' => $post_title,
+ 'permalink' => get_permalink($post_id),
+ 'datetime' => get_post_meta($post_id, 'datetime', true) ?: '',
+ 'date_debut' => get_post_meta($post_id, 'date_de_debut', true) ?: '',
+ 'date_fin' => get_post_meta($post_id, 'date_de_fin', true) ?: '',
+ 'post_date' => $post_date,
+ 'membres' => $this->get_post_membres($post_id),
+ 'autrepersonnes' => get_post_meta($post_id, 'autrepersonnes', true) ?: '',
+ 'seances' => [],
+ ];
+
+ // For seminars, fetch séances within the month window
+ if ($month_start && $month_end && has_category(THALIM_NL_CAT_SEMINAIRES, $post_id)) {
+ $data['seances'] = $this->get_seances_in_window($post_id, $month_start, $month_end);
+ }
+
+ return $data;
+ }
+
+ /**
+ * Fetch séances linked to a seminar that fall within the given time window.
+ */
+ private function get_seances_in_window(int $seminar_id, int $month_start, int $month_end): array {
+ $seance_ids = get_post_meta($seminar_id, 'seances', false);
+ if (empty($seance_ids)) {
+ return [];
+ }
+
+ $seances = [];
+ foreach ($seance_ids as $sid) {
+ $sid = (int) $sid;
+ $post = get_post($sid);
+ if (!$post || $post->post_status !== 'publish') {
+ continue;
+ }
+
+ $raw_debut = get_post_meta($sid, 'date_de_debut', true) ?: '';
+ $ts = $raw_debut ? strtotime($raw_debut) : false;
+ if (!$ts || $ts < $month_start || $ts > $month_end) {
+ continue;
+ }
+
+ $seances[] = [
+ 'id' => $sid,
+ 'title' => get_the_title($sid),
+ 'date_debut' => $raw_debut,
+ 'date_fin' => get_post_meta($sid, 'date_de_fin', true) ?: '',
+ '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) ?: '',
+ ];
+ }
+
+ // Sort by date_de_debut ascending
+ usort($seances, function ($a, $b) {
+ return strcmp($a['date_debut'], $b['date_debut']);
+ });
+
+ return $seances;
+ }
+
+ /**
+ * Resolve 'membres' postmeta rows to display names.
+ *
+ * @return string[]
+ */
+ public function get_post_membres(int $post_id): array {
+ $uids = get_post_meta($post_id, 'membres', false);
+ $names = [];
+ foreach ($uids as $uid) {
+ $user = get_userdata((int) $uid);
+ if ($user) {
+ $names[] = $user->display_name;
+ }
+ }
+ return $names;
+ }
+}
diff --git a/thalim-newsletter.php b/thalim-newsletter.php
new file mode 100644
index 0000000..7b25c70
--- /dev/null
+++ b/thalim-newsletter.php
@@ -0,0 +1,115 @@
+load_dependencies();
+ $this->init_hooks();
+ }
+
+ private function load_dependencies() {
+ require_once THALIM_NL_PLUGIN_DIR . 'includes/class-post-query.php';
+ require_once THALIM_NL_PLUGIN_DIR . 'includes/class-html-exporter.php';
+ require_once THALIM_NL_PLUGIN_DIR . 'includes/class-admin-page.php';
+ }
+
+ private function init_hooks() {
+ add_action('admin_menu', [$this, 'add_admin_menu']);
+ add_action('admin_enqueue_scripts', [$this, 'enqueue_assets']);
+ add_action('wp_ajax_thalim_nl_load_month', [new Thalim_NL_Admin_Page(), 'handle_ajax_load_month']);
+ add_action('wp_ajax_thalim_nl_export_html', [new Thalim_NL_Admin_Page(), 'handle_ajax_export_html']);
+ }
+
+ public function add_admin_menu() {
+ add_management_page(
+ 'Newsletter',
+ 'Newsletter',
+ 'edit_others_posts',
+ 'thalim-newsletter',
+ [$this, 'render_admin_page']
+ );
+ }
+
+ public function enqueue_assets($hook) {
+ if ($hook !== 'tools_page_thalim-newsletter') {
+ return;
+ }
+ wp_enqueue_style(
+ 'thalim-newsletter-admin',
+ THALIM_NL_PLUGIN_URL . 'assets/admin.css',
+ [],
+ THALIM_NL_VERSION
+ );
+ wp_enqueue_script(
+ 'thalim-newsletter-admin',
+ THALIM_NL_PLUGIN_URL . 'assets/admin.js',
+ ['jquery'],
+ THALIM_NL_VERSION,
+ true
+ );
+ wp_localize_script('thalim-newsletter-admin', 'thalimNL', [
+ 'ajaxUrl' => admin_url('admin-ajax.php'),
+ 'nonce' => wp_create_nonce('thalim_newsletter_ajax'),
+ ]);
+ }
+
+ public function render_admin_page() {
+ $page = new Thalim_NL_Admin_Page();
+ $page->render();
+ }
+}
+
+// Activation hook
+register_activation_hook(__FILE__, function () {
+ add_option('thalim_nl_version', THALIM_NL_VERSION);
+});
+
+// Initialize plugin
+add_action('plugins_loaded', function () {
+ Thalim_Newsletter::get_instance();
+});