From f3970480c4ac66c3b11678fba0fa73fb4bc030a8 Mon Sep 17 00:00:00 2001 From: Valentin Le Moign Date: Tue, 12 May 2026 23:34:00 +0200 Subject: [PATCH] Initial commit --- .gitignore | 12 + README.md | 93 +++++ assets/admin.css | 263 +++++++++++++ assets/admin.js | 136 +++++++ includes/class-admin-page.php | 550 ++++++++++++++++++++++++++++ includes/class-html-exporter.php | 610 +++++++++++++++++++++++++++++++ includes/class-post-query.php | 372 +++++++++++++++++++ thalim-newsletter.php | 115 ++++++ 8 files changed, 2151 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 assets/admin.css create mode 100644 assets/admin.js create mode 100644 includes/class-admin-page.php create mode 100644 includes/class-html-exporter.php create mode 100644 includes/class-post-query.php create mode 100644 thalim-newsletter.php 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

+ + +
+

+
+ + +
+ + +
+

Composer

+ +
+ + + +
+ + + +
+ +
+ + 'nl_intro', + 'media_buttons' => false, + 'tinymce' => [ + 'toolbar1' => 'bold,italic,bullist,numlist,blockquote,link,unlink', + 'toolbar2' => '', + ], + 'quicktags' => ['buttons' => 'strong,em,ul,ol,li,link,close'], + 'editor_height' => 200, + ]); + ?> +
+ +
+ render_sections_html($month_data, $existing_sections); ?> +
+ +
+ + 'nl_conclusion', + 'media_buttons' => false, + 'tinymce' => [ + 'toolbar1' => 'bold,italic,bullist,numlist,blockquote,link,unlink', + 'toolbar2' => '', + ], + 'quicktags' => ['buttons' => 'strong,em,ul,ol,li,link,close'], + 'editor_height' => 200, + ]); + ?> +
+ + + +

+ +

+
+
+ + +
+

Newsletters enregistrées

+ + +

Aucune newsletter enregistrée.

+ + + + + + + + + + + ID, '_newsletter_month', true); + $label = $this->format_month_label($nl_month); + $edit_url = get_edit_post_link($nl->ID); + ?> + + + + + + +
MoisActions
+ 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(); + ?> + + + + + + + +<?php echo esc_html($site_name); ?> — Newsletter + + + + + + +
+ — Newsletter +  ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ +
+ + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + 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( + '/])/', + '