Initial commit
This commit is contained in:
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# --- OS / éditeur ---
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.swp
|
||||
*~
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# --- Archives ---
|
||||
*.tar.gz
|
||||
*.tgz
|
||||
*.zip
|
||||
93
README.md
Normal file
93
README.md
Normal file
@@ -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
|
||||
```
|
||||
263
assets/admin.css
Normal file
263
assets/admin.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
136
assets/admin.js
Normal file
136
assets/admin.js
Normal file
@@ -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('<p class="thalim-nl-empty">Erreur lors du chargement.</p>');
|
||||
})
|
||||
.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));
|
||||
550
includes/class-admin-page.php
Normal file
550
includes/class-admin-page.php
Normal file
@@ -0,0 +1,550 @@
|
||||
<?php
|
||||
/**
|
||||
* Admin Page Class — Tools > 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));
|
||||
});
|
||||
|
||||
?>
|
||||
<div class="wrap thalim-nl-wrap">
|
||||
<h1>Newsletter THALIM</h1>
|
||||
|
||||
<?php if ($save_message): ?>
|
||||
<div class="notice notice-<?php echo esc_attr($save_message[0]); ?> is-dismissible">
|
||||
<p><?php echo esc_html($save_message[1]); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="thalim-nl-layout">
|
||||
|
||||
<!-- ============================================================
|
||||
SECTION 1 — COMPOSE
|
||||
============================================================ -->
|
||||
<div class="thalim-nl-compose">
|
||||
<h2>Composer</h2>
|
||||
|
||||
<form method="post" id="thalim-nl-form">
|
||||
<?php wp_nonce_field('thalim_nl_save'); ?>
|
||||
<input type="hidden" name="thalim_nl_save" value="1">
|
||||
|
||||
<div class="thalim-nl-month-row">
|
||||
<label for="thalim-nl-month"><strong>Mois :</strong></label>
|
||||
<input
|
||||
type="month"
|
||||
id="thalim-nl-month"
|
||||
name="nl_month"
|
||||
value="<?php echo esc_attr($year_month); ?>"
|
||||
>
|
||||
<span class="thalim-nl-spinner spinner"></span>
|
||||
</div>
|
||||
|
||||
<div class="thalim-nl-intro">
|
||||
<label><strong>Introduction :</strong></label>
|
||||
<?php
|
||||
wp_editor($intro_content, 'thalim_nl_intro', [
|
||||
'textarea_name' => '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,
|
||||
]);
|
||||
?>
|
||||
</div>
|
||||
|
||||
<div id="thalim-nl-sections-wrap">
|
||||
<?php echo $this->render_sections_html($month_data, $existing_sections); ?>
|
||||
</div>
|
||||
|
||||
<div class="thalim-nl-conclusion">
|
||||
<label><strong>Conclusion :</strong></label>
|
||||
<?php
|
||||
wp_editor($conclusion_content, 'thalim_nl_conclusion', [
|
||||
'textarea_name' => '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,
|
||||
]);
|
||||
?>
|
||||
</div>
|
||||
|
||||
<div class="thalim-nl-links-row">
|
||||
<div class="thalim-nl-link-field">
|
||||
<label for="thalim-nl-subscribe"><strong>Lien d'abonnement :</strong></label>
|
||||
<input type="url" id="thalim-nl-subscribe" name="nl_subscribe_url"
|
||||
value="<?php echo esc_attr($subscribe_url); ?>"
|
||||
placeholder="https://…" class="regular-text">
|
||||
</div>
|
||||
<div class="thalim-nl-link-field">
|
||||
<label for="thalim-nl-unsubscribe"><strong>Lien de désabonnement :</strong></label>
|
||||
<input type="url" id="thalim-nl-unsubscribe" name="nl_unsubscribe_url"
|
||||
value="<?php echo esc_attr($unsubscribe_url); ?>"
|
||||
placeholder="https://…" class="regular-text">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="submit">
|
||||
<button type="submit" class="button button-primary">Enregistrer</button>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================
|
||||
SECTION 2 — PAST NEWSLETTERS
|
||||
============================================================ -->
|
||||
<div class="thalim-nl-past">
|
||||
<h2>Newsletters enregistrées</h2>
|
||||
|
||||
<?php if (empty($past_newsletters)): ?>
|
||||
<p>Aucune newsletter enregistrée.</p>
|
||||
<?php else: ?>
|
||||
<table class="widefat thalim-nl-past-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mois</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($past_newsletters as $nl): ?>
|
||||
<?php
|
||||
$nl_month = get_post_meta($nl->ID, '_newsletter_month', true);
|
||||
$label = $this->format_month_label($nl_month);
|
||||
$edit_url = get_edit_post_link($nl->ID);
|
||||
?>
|
||||
<tr>
|
||||
<td><?php echo esc_html($label); ?></td>
|
||||
<td>
|
||||
<a href="<?php echo esc_url($edit_url); ?>" class="button button-small">Modifier</a>
|
||||
<button
|
||||
type="button"
|
||||
class="button button-small thalim-nl-preview"
|
||||
data-post-id="<?php echo esc_attr($nl->ID); ?>"
|
||||
>Prévisualisation</button>
|
||||
<button
|
||||
type="button"
|
||||
class="button button-small thalim-nl-download"
|
||||
data-post-id="<?php echo esc_attr($nl->ID); ?>"
|
||||
data-filename="newsletter-THALIM-<?php echo esc_attr($this->format_month_label($nl_month)); ?>.html"
|
||||
>Télécharger</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
</div><!-- .thalim-nl-layout -->
|
||||
</div><!-- .wrap -->
|
||||
<?php
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Sections HTML (shared between initial render and AJAX)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function render_sections_html(array $month_data, array $checked_ids = []): string {
|
||||
$groups = Thalim_NL_Post_Query::get_eligible_categories();
|
||||
|
||||
ob_start();
|
||||
$has_content = false;
|
||||
|
||||
foreach ($groups as $parent_id => $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;
|
||||
?>
|
||||
<div class="thalim-nl-group">
|
||||
<h3 class="thalim-nl-group-title"><?php echo esc_html($group['name']); ?></h3>
|
||||
<?php foreach ($cats_in_group as $cat_id => $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);
|
||||
?>
|
||||
<details class="thalim-nl-section" <?php if ($has_checked) echo 'open'; ?>>
|
||||
<summary class="thalim-nl-section-summary">
|
||||
<?php echo esc_html($label); ?>
|
||||
<span class="thalim-nl-count">(<?php echo $count; ?>)</span>
|
||||
</summary>
|
||||
<div class="thalim-nl-section-body">
|
||||
<?php foreach ($posts as $post): ?>
|
||||
<label class="thalim-nl-post-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="nl_sections[<?php echo (int) $cat_id; ?>][]"
|
||||
value="<?php echo (int) $post['id']; ?>"
|
||||
<?php checked(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 if (!empty($post['seances'])): ?>
|
||||
<ul class="thalim-nl-seances-list">
|
||||
<?php foreach ($post['seances'] as $seance): ?>
|
||||
<li class="thalim-nl-seance-item">
|
||||
<span class="thalim-nl-seance-title"><?php echo esc_html($seance['title']); ?></span>
|
||||
<span class="thalim-nl-seance-meta">
|
||||
<?php
|
||||
$parts = [];
|
||||
if ($seance['date_debut']) {
|
||||
$ts = strtotime($seance['date_debut']);
|
||||
$parts[] = $ts ? date_i18n('j M Y', $ts) : '';
|
||||
}
|
||||
if ($seance['heure_de_debut']) {
|
||||
$time = $seance['heure_de_debut'];
|
||||
if ($seance['heure_de_fin']) $time .= '–' . $seance['heure_de_fin'];
|
||||
$parts[] = $time;
|
||||
}
|
||||
if ($seance['lieu']) {
|
||||
$parts[] = $seance['lieu'];
|
||||
}
|
||||
echo esc_html(implode(' · ', array_filter($parts)));
|
||||
?>
|
||||
</span>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</details>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
$html = ob_get_clean();
|
||||
if (!$has_content) {
|
||||
$html = '<p class="thalim-nl-empty">Aucun contenu trouvé pour ce mois.</p>';
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
610
includes/class-html-exporter.php
Normal file
610
includes/class-html-exporter.php
Normal file
@@ -0,0 +1,610 @@
|
||||
<?php
|
||||
/**
|
||||
* HTML Exporter — generates a complete, email-optimised HTML document
|
||||
* from a saved newsletter draft post.
|
||||
*
|
||||
* Layout: table-based (600 px), inline styles throughout.
|
||||
* Fonts : Gelasio (Google Fonts @import, fallback Georgia) for headings,
|
||||
* Arial/Helvetica for body.
|
||||
* Colors: taken directly from the THALIM theme (_variables.scss).
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Thalim_NL_HTML_Exporter {
|
||||
|
||||
// ── Theme colours (from _variables.scss) ──────────────────────────────────
|
||||
const COLOR_YELLOW = '#f7ff29';
|
||||
const COLOR_BG = '#eeeeee'; // $light-gray
|
||||
const COLOR_WHITE = '#ffffff';
|
||||
const COLOR_DARK = '#1a1a1a'; // $dark-gray
|
||||
const COLOR_MID = '#3e3e3e'; // $less-dark-gray
|
||||
const COLOR_RULE = '#cccccc'; // $less-light-gray
|
||||
|
||||
// Category accent colours (from theme _variables.scss)
|
||||
const COLOR_LABORATOIRE = '#e0775d'; // coral — parent 1
|
||||
const COLOR_MANIFESTATIONS = '#7cc0c6'; // teal — parent 3
|
||||
const COLOR_PUBLICATIONS = '#46ae51'; // green — parent 4
|
||||
const COLOR_MEDIATIONS = '#e05680'; // pink — parent 5
|
||||
const COLOR_RESSOURCES = '#bb8dd9'; // purple — parent 6
|
||||
|
||||
/** Parent category ID → accent colour */
|
||||
private const PARENT_COLORS = [
|
||||
1 => 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();
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="x-apple-disable-message-reformatting">
|
||||
<title><?php echo esc_html($site_name); ?> — Newsletter</title>
|
||||
<!--[if mso]>
|
||||
<noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript>
|
||||
<![endif]-->
|
||||
<style type="text/css">
|
||||
/* Google Fonts — Gelasio (supported by Apple Mail, Outlook Web, Gmail web) */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Gelasio&display=swap');
|
||||
|
||||
/* Email reset */
|
||||
body, table, td, p, a, li, blockquote {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
||||
img { -ms-interpolation-mode: bicubic; border: 0; display: block; outline: none; text-decoration: none; }
|
||||
body { margin: 0 !important; padding: 0 !important; }
|
||||
|
||||
/* Responsive */
|
||||
@media only screen and (max-width: 620px) {
|
||||
.email-wrapper { width: 100% !important; }
|
||||
.email-content { padding: 24px 20px !important; }
|
||||
.email-header { padding: 20px !important; }
|
||||
.logo-img { width: 90px !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:<?php echo self::COLOR_BG; ?>;">
|
||||
|
||||
<!-- Preheader (hidden) -->
|
||||
<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;">
|
||||
<?php echo esc_html($site_name); ?> — Newsletter
|
||||
‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌
|
||||
</div>
|
||||
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"
|
||||
style="background-color:<?php echo self::COLOR_BG; ?>;">
|
||||
<tr>
|
||||
<td align="center" style="padding:24px 12px;">
|
||||
|
||||
<!-- ── Main wrapper: 600px ── -->
|
||||
<table class="email-wrapper" role="presentation" border="0" cellpadding="0" cellspacing="0"
|
||||
width="600" style="background-color:<?php echo self::COLOR_WHITE; ?>;">
|
||||
|
||||
<!-- ═══════════ HEADER ═══════════ -->
|
||||
<tr>
|
||||
<td class="email-header" bgcolor="<?php echo self::COLOR_BG; ?>"
|
||||
style="background-color:<?php echo self::COLOR_BG; ?>;padding:28px 40px;text-align:center;">
|
||||
<a href="<?php echo esc_url($site_url); ?>" style="text-decoration:none;">
|
||||
<img class="logo-img" src="<?php echo esc_url($logo_url); ?>"
|
||||
width="110" alt="<?php echo esc_attr($site_name); ?>"
|
||||
style="display:block;margin:0 auto;width:110px;height:auto;">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Yellow accent bar -->
|
||||
<tr>
|
||||
<td height="5" style="height:5px;font-size:0;line-height:0;"
|
||||
bgcolor="<?php echo self::COLOR_YELLOW; ?>">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<?php if (!empty(trim($intro))): ?>
|
||||
<!-- ═══════════ INTRO ═══════════ -->
|
||||
<tr>
|
||||
<td class="email-content"
|
||||
style="<?php echo $cell_padding; ?><?php echo $body_style; ?>border-bottom:1px solid <?php echo self::COLOR_RULE; ?>;">
|
||||
<?php echo $this->process_rich_text($intro); ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php foreach ($section_blocks as $block): ?>
|
||||
<!-- ═══════════ SECTION: <?php echo esc_html($block['label']); ?> ═══════════ -->
|
||||
<tr>
|
||||
<td style="padding:0 40px 24px 40px;">
|
||||
|
||||
<!-- Section heading with gradient underline -->
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding-top:28px;padding-bottom:0;">
|
||||
<span style="font-family:<?php echo $font_serif; ?>;font-size:20px;font-weight:normal;color:<?php echo self::COLOR_DARK; ?>;letter-spacing:0.01em;">
|
||||
<?php echo esc_html($block['label']); ?>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding-top:6px;padding-bottom:16px;">
|
||||
<div style="height:3px;border-radius:2px;background:linear-gradient(to top, <?php echo esc_attr($block['color']); ?>, transparent);"></div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Post list -->
|
||||
<?php foreach ($block['items'] as $item): ?>
|
||||
<?php echo $item; ?>
|
||||
<?php endforeach; ?>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if (!empty(trim($conclusion))): ?>
|
||||
<!-- ═══════════ CONCLUSION ═══════════ -->
|
||||
<tr>
|
||||
<td class="email-content"
|
||||
style="<?php echo $cell_padding; ?><?php echo $body_style; ?>border-top:1px solid <?php echo self::COLOR_RULE; ?>;">
|
||||
<?php echo $this->process_rich_text($conclusion); ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- ═══════════ FOOTER ═══════════ -->
|
||||
<tr>
|
||||
<td bgcolor="<?php echo self::COLOR_BG; ?>"
|
||||
style="background-color:<?php echo self::COLOR_BG; ?>;padding:20px 40px;text-align:center;">
|
||||
<p style="margin:0 0 4px 0;font-family:<?php echo $font_sans; ?>;font-size:11px;
|
||||
text-transform:uppercase;letter-spacing:0.08em;color:<?php echo self::COLOR_MID; ?>;">
|
||||
<?php echo esc_html($site_name); ?>
|
||||
</p>
|
||||
<p style="margin:0;font-family:<?php echo $font_sans; ?>;font-size:11px;color:<?php echo self::COLOR_MID; ?>;">
|
||||
Théorie et Histoire des Arts et des Littératures de la Modernité
|
||||
</p>
|
||||
<p style="margin:8px 0 0 0;">
|
||||
<a href="<?php echo esc_url($site_url); ?>"
|
||||
style="font-family:<?php echo $font_sans; ?>;font-size:11px;
|
||||
color:<?php echo self::COLOR_MID; ?>;text-decoration:underline;">
|
||||
<?php echo esc_url($site_url); ?>
|
||||
</a>
|
||||
</p>
|
||||
<?php if ($subscribe_url || $unsubscribe_url): ?>
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0"
|
||||
style="margin:14px auto 0 auto;">
|
||||
<tr>
|
||||
<?php if ($subscribe_url): ?>
|
||||
<td style="padding:0 10px;">
|
||||
<a href="<?php echo esc_url($subscribe_url); ?>"
|
||||
style="font-family:<?php echo $font_sans; ?>;font-size:12px;
|
||||
color:<?php echo self::COLOR_MID; ?>;text-decoration:underline;">
|
||||
S'abonner
|
||||
</a>
|
||||
</td>
|
||||
<?php endif; ?>
|
||||
<?php if ($unsubscribe_url): ?>
|
||||
<td style="padding:0 10px;">
|
||||
<a href="<?php echo esc_url($unsubscribe_url); ?>"
|
||||
style="font-family:<?php echo $font_sans; ?>;font-size:12px;
|
||||
color:<?php echo self::COLOR_MID; ?>;text-decoration:underline;">
|
||||
Se désabonner
|
||||
</a>
|
||||
</td>
|
||||
<?php endif; ?>
|
||||
</tr>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
<!-- /Main wrapper -->
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
// ── Per-post block renderer ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Format a single post as an HTML table block for the email.
|
||||
* Optional image on the left, title in serif, meta below in sans-serif.
|
||||
*/
|
||||
public function format_post_line(int $post_id, int $cat_id): string {
|
||||
// Seminars: special rendering with séances
|
||||
if ($cat_id === THALIM_NL_CAT_SEMINAIRES) {
|
||||
return $this->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();
|
||||
?>
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"
|
||||
style="margin-bottom:16px;">
|
||||
<tr>
|
||||
<?php if ($image_url): ?>
|
||||
<td width="70" valign="top" style="width:70px;padding-right:14px;">
|
||||
<a href="<?php echo esc_url($url); ?>" style="text-decoration:none;">
|
||||
<img src="<?php echo esc_url($image_url); ?>" width="70" height="70" alt=""
|
||||
style="display:block;width:70px;height:70px;object-fit:cover;border-radius:4px;">
|
||||
</a>
|
||||
</td>
|
||||
<?php endif; ?>
|
||||
<td valign="top">
|
||||
<a href="<?php echo esc_url($url); ?>"
|
||||
style="font-family:<?php echo $font_serif; ?>;font-size:16px;line-height:1.35;color:<?php echo self::COLOR_DARK; ?>;text-decoration:none;">
|
||||
<?php echo esc_html($title); ?>
|
||||
</a>
|
||||
<?php if ($meta_html): ?>
|
||||
<div style="font-family:<?php echo $font_sans; ?>;font-size:13px;line-height:1.45;color:<?php echo self::COLOR_DARK; ?>;margin-top:4px;">
|
||||
<?php echo $meta_html; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a seminar post: title (no dates) + list of séances with date, time, location.
|
||||
* Each séance links to the seminar page.
|
||||
*/
|
||||
private function format_seminar_line(int $post_id): string {
|
||||
$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);
|
||||
|
||||
// Image
|
||||
$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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch séances within the newsletter month window
|
||||
$seance_ids = get_post_meta($post_id, 'seances', false);
|
||||
$seances = [];
|
||||
foreach ($seance_ids as $sid) {
|
||||
$sid = (int) $sid;
|
||||
$s_post = get_post($sid);
|
||||
if (!$s_post || $s_post->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();
|
||||
?>
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"
|
||||
style="margin-bottom:16px;">
|
||||
<tr>
|
||||
<?php if ($image_url): ?>
|
||||
<td width="70" valign="top" style="width:70px;padding-right:14px;">
|
||||
<a href="<?php echo esc_url($url); ?>" style="text-decoration:none;">
|
||||
<img src="<?php echo esc_url($image_url); ?>" width="70" height="70" alt=""
|
||||
style="display:block;width:70px;height:70px;object-fit:cover;border-radius:4px;">
|
||||
</a>
|
||||
</td>
|
||||
<?php endif; ?>
|
||||
<td valign="top">
|
||||
<a href="<?php echo esc_url($url); ?>"
|
||||
style="font-family:<?php echo $font_serif; ?>;font-size:16px;line-height:1.35;color:<?php echo self::COLOR_DARK; ?>;text-decoration:none;">
|
||||
<?php echo esc_html($title); ?>
|
||||
</a>
|
||||
<?php if (!empty($seances)): ?>
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"
|
||||
style="margin-top:8px;">
|
||||
<?php foreach ($seances as $seance):
|
||||
$seance_url = $url . '#seance-' . $seance['id'];
|
||||
?>
|
||||
<tr>
|
||||
<td style="padding:3px 0;font-family:<?php echo $font_sans; ?>;font-size:13px;line-height:1.45;color:<?php echo self::COLOR_DARK; ?>;">
|
||||
<?php
|
||||
$parts = [];
|
||||
if ($seance['date_debut']) {
|
||||
$ts = strtotime($seance['date_debut']);
|
||||
if ($ts) $parts[] = date_i18n('j F Y', $ts);
|
||||
}
|
||||
if ($seance['heure_de_debut']) {
|
||||
$time = $seance['heure_de_debut'];
|
||||
if ($seance['heure_de_fin']) $time .= '–' . $seance['heure_de_fin'];
|
||||
$parts[] = $time;
|
||||
}
|
||||
if ($seance['lieu']) {
|
||||
$parts[] = esc_html($seance['lieu']);
|
||||
}
|
||||
?>
|
||||
<a href="<?php echo esc_url($seance_url); ?>"
|
||||
style="color:<?php echo self::COLOR_DARK; ?>;text-decoration:none;">
|
||||
<?php echo esc_html($seance['title']); ?>
|
||||
</a>
|
||||
<?php if (!empty($parts)): ?>
|
||||
<br>
|
||||
<span style="color:<?php echo self::COLOR_MID; ?>;font-size:12px;">
|
||||
<?php echo implode(' · ', $parts); ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
|
||||
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);
|
||||
$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 <p>
|
||||
$html = preg_replace(
|
||||
'/<p([ >])/',
|
||||
'<p style="margin:0 0 14px 0;font-family:' . $font_sans . ';font-size:15px;line-height:1.6;color:' . $color_dark . ';"$1',
|
||||
$html
|
||||
);
|
||||
|
||||
// Style <a>
|
||||
$html = preg_replace(
|
||||
'/<a([ >])/',
|
||||
'<a style="color:' . $color_dark . ';text-decoration:underline;"$1',
|
||||
$html
|
||||
);
|
||||
|
||||
// Style <ul>
|
||||
$html = preg_replace(
|
||||
'/<ul([ >])/',
|
||||
'<ul style="margin:0 0 14px 0;padding-left:20px;font-family:' . $font_sans . ';font-size:15px;line-height:1.6;color:' . $color_dark . ';"$1',
|
||||
$html
|
||||
);
|
||||
|
||||
// Style <ol>
|
||||
$html = preg_replace(
|
||||
'/<ol([ >])/',
|
||||
'<ol style="margin:0 0 14px 0;padding-left:20px;font-family:' . $font_sans . ';font-size:15px;line-height:1.6;color:' . $color_dark . ';"$1',
|
||||
$html
|
||||
);
|
||||
|
||||
// Style <li>
|
||||
$html = preg_replace(
|
||||
'/<li([ >])/',
|
||||
'<li style="margin-bottom:6px;"$1',
|
||||
$html
|
||||
);
|
||||
|
||||
// Style <blockquote>
|
||||
$html = preg_replace(
|
||||
'/<blockquote([ >])/',
|
||||
'<blockquote style="margin:0 0 14px 20px;padding-left:14px;border-left:3px solid ' . $color_rule . ';color:' . $color_mid . ';"$1',
|
||||
$html
|
||||
);
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
372
includes/class-post-query.php
Normal file
372
includes/class-post-query.php
Normal file
@@ -0,0 +1,372 @@
|
||||
<?php
|
||||
/**
|
||||
* Post Query Class — fetches posts per category for a given month
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
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]
|
||||
*/
|
||||
private const SPECIAL_WINDOW_TYPES = [
|
||||
THALIM_NL_CAT_APPELS => '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;
|
||||
}
|
||||
}
|
||||
115
thalim-newsletter.php
Normal file
115
thalim-newsletter.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
/**
|
||||
* Plugin Name: THALIM Newsletter
|
||||
* Plugin URI: https://thalim.fr
|
||||
* Description: Compose and export monthly newsletter digests from site content
|
||||
* Version: 1.0.0
|
||||
* Author: THALIM Dev
|
||||
* Author URI: https://thalim.fr
|
||||
* License: GPL v2 or later
|
||||
* Text Domain: thalim-newsletter
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Plugin constants
|
||||
define('THALIM_NL_VERSION', '1.0.0');
|
||||
define('THALIM_NL_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
||||
define('THALIM_NL_PLUGIN_URL', plugin_dir_url(__FILE__));
|
||||
|
||||
// Category IDs (verified in DB 2026-03-20)
|
||||
define('THALIM_NL_CAT_APPELS', 8);
|
||||
define('THALIM_NL_CAT_COLLOQUES', 10);
|
||||
define('THALIM_NL_CAT_SEMINAIRES', 11);
|
||||
define('THALIM_NL_CAT_COMMS', 13);
|
||||
define('THALIM_NL_CAT_SOUTENANCES', 14);
|
||||
define('THALIM_NL_CAT_OUVRAGES', 15);
|
||||
define('THALIM_NL_CAT_ARTICLES', 16);
|
||||
define('THALIM_NL_CAT_NEWSLETTER', 20);
|
||||
|
||||
// Pods IDs (verified in DB 2026-03-20)
|
||||
define('THALIM_NL_POD_ID_POST', 8);
|
||||
define('THALIM_NL_FIELD_ID_CAT', 16);
|
||||
|
||||
/**
|
||||
* Main plugin class — singleton
|
||||
*/
|
||||
class Thalim_Newsletter {
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
public static function get_instance() {
|
||||
if (self::$instance === null) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
private function __construct() {
|
||||
$this->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();
|
||||
});
|
||||
Reference in New Issue
Block a user