'datetime_to_fin', THALIM_NL_CAT_COLLOQUES => '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'; /** * Horizon d'affichage des annonces à venir, en mois. * * Sans plafond, ouvrir la newsletter d'un mois ancien remonterait tout * l'historique postérieur (la totalité du site pour un mois de 1999) : * requête et rendu ingérables. 12 mois couvrent largement les annonces * saisies en avance (séances de la rentrée, colloques annoncés un an à * l'avance) tout en bornant la liste. */ private const FUTURE_HORIZON_MONTHS = 12; /** * Bornes du bloc « à venir » : [début, fin] en timestamps. * * Le bloc commence à la fin du mois de la newsletter, ou à aujourd'hui si * ce mois est déjà passé — rouvrir une vieille newsletter ne doit pas * proposer une année de contenus entre-temps devenus du passé. */ private static function future_bounds(int $month_end): array { $from = max($month_end, strtotime('today')); return [ $from, strtotime('+' . self::FUTURE_HORIZON_MONTHS . ' months', $from) ?: $from ]; } /** * Categories to exclude from the newsletter UI, résolues par slug * (fallback sur les IDs historiques si un slug est introuvable). */ private static function excluded_cats(): array { static $ids = null; if ($ids === null) { $map = [ 'vie-du-labo-intranet' => 9, // Vie du labo (intranet) 'seance-de-seminaire' => 12, // Séance de séminaire 'newsletter' => 20, // Newsletter 'non-classe' => 31, // Non classé ]; $ids = []; foreach ($map as $slug => $fallback) { $term = get_term_by('slug', $slug, 'category'); $ids[] = $term ? (int) $term->term_id : $fallback; } } return $ids; } /** * 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) { // Seminars: list individually-selectable séances grouped by seminar. if ($cat_id === THALIM_NL_CAT_SEMINAIRES) { $seances = $this->query_seminar_seances($month_start, $month_end, self::future_bounds($month_end)); if (!empty($seances)) { $result[$cat_id] = $seances; } continue; } $window_type = self::get_window_type($cat_id); $posts = $this->query_category($cat_id, $window_type, $month_start, $month_end, self::future_bounds($month_end)); if (!empty($posts)) { $result[$cat_id] = $posts; } } // Marque les items dont la date d'événement dépasse le mois : l'UI les // présente à part et ne les coche pas par défaut. foreach ($result as $cat_id => $items) { foreach ($items as $i => $item) { $ts = self::event_start_ts($item); $result[$cat_id][$i]['is_future'] = ($ts > $month_end); } } return $result; } /** * Timestamp d'événement d'un item, dans l'ordre de priorité du thème : * date_de_debut > datetime > post_date. */ public static function event_start_ts(array $item): int { foreach (['date_debut', 'datetime', 'post_date'] as $key) { $raw = $item[$key] ?? ''; if ($raw && !str_starts_with($raw, '0000-00-00')) { $ts = strtotime($raw); if ($ts) { return $ts; } } } return 0; } /** * Réintègre dans les données du mois les items d'une sélection déjà * enregistrée qui n'y figureraient plus. * * Les fenêtres d'éligibilité peuvent évoluer (elles ont changé le * 2026-08-27 en passant de la date de publication à la date d'événement) : * sans ce filet, rouvrir puis réenregistrer une ancienne newsletter la * amputerait silencieusement des items devenus hors fenêtre — le * formulaire ne soumet que ce qui est affiché. * * @param array $month_data Données du mois, par catégorie. * @param array $sections Sélection enregistrée : cat_id => [post_id, …]. */ public function merge_selected_items(array $month_data, array $sections): array { foreach ($sections as $cat_id => $ids) { $cat_id = (int) $cat_id; $present = array_map( static fn($item) => (int) $item['id'], $month_data[$cat_id] ?? [] ); foreach (array_diff(array_map('intval', (array) $ids), $present) as $post_id) { $post = get_post($post_id); if (!$post || $post->post_status !== 'publish') { continue; } if ($cat_id === THALIM_NL_CAT_SEMINAIRES) { $seminar_id = self::get_seminar_id_for_seance($post_id); if (!$seminar_id) { continue; } $item = $this->build_seance_data( $post_id, $seminar_id, get_the_title($seminar_id), get_permalink($seminar_id) ); } else { $item = $this->build_post_data($post_id, $post->post_title, $post->post_date); } // Item repêché : jamais dans le bloc « à venir », il fait // partie de la sélection du mois telle qu'elle a été validée. $item['is_future'] = false; $month_data[$cat_id][] = $item; } } return $month_data; } /** * Données d'une séance, avec son séminaire parent (pour le regroupement). */ private function build_seance_data(int $sid, int $seminar_id, string $seminar_title, string $seminar_permalink): array { $s_post = get_post($sid); return [ 'id' => $sid, 'title' => get_the_title($sid), // Links straight to the séance anchor on the seminar page. 'permalink' => $seminar_permalink . '#seance-' . $sid, 'datetime' => '', 'date_debut' => get_post_meta($sid, 'date_de_debut', true) ?: '', 'date_fin' => get_post_meta($sid, 'date_de_fin', true) ?: '', 'post_date' => $s_post ? $s_post->post_date : '', 'heure_de_debut' => substr(get_post_meta($sid, 'heure_de_debut', true) ?: '', 0, 5), 'heure_de_fin' => substr(get_post_meta($sid, 'heure_de_fin', true) ?: '', 0, 5), 'lieu' => get_post_meta($sid, 'lieu', true) ?: '', 'membres' => $this->get_post_membres($sid), 'autrepersonnes' => get_post_meta($sid, 'autrepersonnes', true) ?: '', 'seminar_id' => $seminar_id, 'seminar_title' => $seminar_title, 'seminar_permalink' => $seminar_permalink, ]; } /** * Libellé de date d'un contenu pour la newsletter. * * Reprend la convention du thème (date_de_debut > datetime > post_date) et * les libellés de thalim_get_agenda_card_data() : « Le X de H1 à H2 », * « Du X au Y », « Jusqu'au X », « X à H ». Le plugin garde sa propre * implémentation plutôt que d'appeler le thème, pour rester autonome. * * Renvoie '' si aucune date exploitable — au caller de retomber sur * post_date s'il le souhaite. */ public static function event_date_label(int $post_id, string $format = 'j F Y'): string { $ts_debut = self::valid_ts(get_post_meta($post_id, 'date_de_debut', true) ?: ''); $ts_fin = self::valid_ts(get_post_meta($post_id, 'date_de_fin', true) ?: ''); $ts_dt = self::valid_ts(get_post_meta($post_id, 'datetime', true) ?: ''); $fmt_debut = $ts_debut ? date_i18n($format, $ts_debut) : ''; $fmt_fin = $ts_fin ? date_i18n($format, $ts_fin) : ''; $fmt_dt = $ts_dt ? date_i18n($format, $ts_dt) : ''; $h_debut = substr(get_post_meta($post_id, 'heure_de_debut', true) ?: '', 0, 5); $h_fin = substr(get_post_meta($post_id, 'heure_de_fin', true) ?: '', 0, 5); if ($fmt_debut || $fmt_fin) { // Même jour de début et de fin : une seule date, éventuellement horaire. if ($ts_debut && $ts_fin && date('Y-m-d', $ts_debut) === date('Y-m-d', $ts_fin)) { if ($h_debut && $h_fin) { return 'Le ' . $fmt_debut . ' de ' . $h_debut . ' à ' . $h_fin; } return $h_debut ? $fmt_debut . ' à ' . $h_debut : $fmt_debut; } if ($fmt_debut && $fmt_fin) { return 'Du ' . $fmt_debut . ' au ' . $fmt_fin; } if ($fmt_debut) { return $h_debut ? $fmt_debut . ' à ' . $h_debut : $fmt_debut; } // Seule une date de fin : cas des appels à contribution (date limite). return "Jusqu'au " . $fmt_fin; } if ($fmt_dt) { return $h_debut ? $fmt_dt . ' à ' . $h_debut : $fmt_dt; } return ''; } /** * Timestamp d'une valeur de date Pods, 0 si vide ou invalide (0000-00-00). */ private static function valid_ts(string $raw): int { if (!$raw || str_starts_with($raw, '0000-00-00')) { return 0; } return strtotime($raw) ?: 0; } /** * Build the flat list of selectable séances for the seminar category. * * We walk every published seminar (cat 11), read its `seances` meta * (array of séance post IDs), and keep every séance dont la date_de_debut * tombe dans le mois, ou dans la fenêtre « à venir » (les séances de la * rentrée doivent pouvoir entrer dans la newsletter de juillet). * Each returned item carries its parent seminar (id/title/permalink) so * the UI and the exporter can group séances under their seminar. * * @return array Flat list of séance items (see shape below). */ private function query_seminar_seances(int $month_start, int $month_end, array $future): array { global $wpdb; $seminar_ids = $wpdb->get_col($wpdb->prepare( "SELECT DISTINCT p.ID 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 WHERE p.post_type = 'post' AND p.post_status = 'publish'", THALIM_NL_CAT_SEMINAIRES )); $items = []; foreach ($seminar_ids as $seminar_id) { $seminar_id = (int) $seminar_id; $seminar_title = get_the_title($seminar_id); $seminar_permalink = get_permalink($seminar_id); foreach (get_post_meta($seminar_id, 'seances', false) 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; $in_month = ($ts >= $month_start && $ts <= $month_end); $in_future = ($ts >= $future[0] && $ts <= $future[1]); if (!$ts || (!$in_month && !$in_future)) { continue; } $items[] = $this->build_seance_data($sid, $seminar_id, $seminar_title, $seminar_permalink); } } // Sort by séance date: seminars naturally order by their earliest séance // when grouped by first appearance (see render / export grouping). usort($items, fn($a, $b) => strcmp($a['date_debut'], $b['date_debut'])); return $items; } /** * Resolve the parent seminar (cat 11) that lists a given séance in its * `seances` meta. Mirrors the reverse lookup used by the theme's * #seance-{ID} redirect. Returns 0 when none is found. */ public static function get_seminar_id_for_seance(int $seance_id): int { global $wpdb; $parent_id = $wpdb->get_var($wpdb->prepare( "SELECT pm.post_id FROM {$wpdb->postmeta} pm JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key = 'seances' AND pm.meta_value = %s AND p.post_status = 'publish' LIMIT 1", (string) $seance_id )); return (int) $parent_id; } /** * 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 $future): array { global $wpdb; // Fin de fenêtre (UNIX_TIMESTAMP) : seul critère de filtrage restant. switch ($window_type) { case 'datetime_to_fin': $end_expr = $this->sql_datetime_to_fin_end(); break; case 'debut_minus35_to_fin': $end_expr = $this->sql_debut_to_fin_end(); break; case 'datetime_plus3m': $end_expr = $this->sql_datetime_plus3m_end(); break; case 'datetime_plus35d': $end_expr = $this->sql_datetime_plus35d_end(); break; default: return []; } // Date d'événement (date_de_debut > datetime > post_date), et non date // de publication : sert au tri et au plafond « à venir ». $order_expr = $this->sql_event_date_order(); $sql = $wpdb->prepare( "SELECT DISTINCT p.ID, p.post_title, p.post_date FROM {$wpdb->posts} p 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 {$end_expr} >= %d AND ( UNIX_TIMESTAMP({$order_expr}) <= %d OR ( UNIX_TIMESTAMP({$order_expr}) >= %d AND UNIX_TIMESTAMP({$order_expr}) <= %d ) ) ORDER BY {$order_expr} ASC", $cat_id, $month_start, $month_end, $future[0], $future[1] ); $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); } return $posts; } // ------------------------------------------------------------------------- // SQL window expression helpers (return raw SQL strings, not prepared) // ------------------------------------------------------------------------- /** * Date d'événement servant au tri : date_de_debut, sinon datetime, sinon * post_date — même ordre de priorité que le thème (post-card-helpers.php, * thalim_get_agenda_card_data()). Renvoie une chaîne date comparable. */ private function sql_event_date_order(): string { return "CASE WHEN pm_deb.meta_value IS NOT NULL AND pm_deb.meta_value != '' AND LEFT(pm_deb.meta_value, 4) != '0000' THEN pm_deb.meta_value WHEN pm_dt.meta_value IS NOT NULL AND pm_dt.meta_value != '' AND LEFT(pm_dt.meta_value, 4) != '0000' THEN pm_dt.meta_value ELSE p.post_date END"; } /** * 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_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 de (date d'événement) + 3 mois. * * La base est la date d'événement, pas la date de publication : sinon un * contenu annoncé longtemps à l'avance sort de la fenêtre avant d'avoir * eu lieu, et manque dans la newsletter de son propre mois. Pour les * ouvrages et articles, qui n'ont pas de date_de_debut, l'expression * retombe sur `datetime` — comportement inchangé. */ private function sql_datetime_plus3m_end(): string { return "UNIX_TIMESTAMP(DATE_ADD(" . $this->sql_event_date_order() . ", INTERVAL 3 MONTH))"; } /** * UNIX_TIMESTAMP de (date d'événement) + 35 jours. Même raison que * ci-dessus : la fenêtre suit l'événement, pas sa date de publication. */ private function sql_datetime_plus35d_end(): string { return "(UNIX_TIMESTAMP(" . $this->sql_event_date_order() . ") + 3024000)"; } // ------------------------------------------------------------------------- // Post data builder // ------------------------------------------------------------------------- private function build_post_data(int $post_id, string $post_title, string $post_date): array { return [ '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) ?: '', ]; } /** * 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; } }