Refactoring : sécurité (XSS), découpage en modules inc/* et js/admin/*, IDs résolus par slug, perf (caches, cron Gravatar, assets auto-hébergés), tests

This commit is contained in:
2026-06-10 21:30:25 +02:00
parent e6b73df516
commit 9280c3b9ce
44 changed files with 3209 additions and 2907 deletions

233
inc/access-control.php Normal file
View File

@@ -0,0 +1,233 @@
<?php
/**
* Restrictions d'accès aux contenus et capacités des contributeurs :
* - liste admin des contributeurs limitée à leurs posts + posts où ils
* figurent en membres/autre_membres (et compteurs cohérents) ;
* - droit d'édition par post pour les membres listés (user_has_cap) ;
* - catégorie « Vie du labo » réservée aux connectés ;
* - redirections login/dashboard des non-admins.
*/
// Restrict Contributors to see only their own posts in admin,
// but also include posts where they appear in membres/autre_membres.
function restrict_contributor_posts( $query ) {
if ( ! is_admin() || ! $query->is_main_query() || current_user_can( 'edit_others_posts' ) ) {
return;
}
global $user_ID, $wpdb;
// Posts where the user is listed as membre or autre_membre.
$membre_ids = array_map( 'intval', (array) $wpdb->get_col(
$wpdb->prepare(
"SELECT DISTINCT post_id FROM {$wpdb->postmeta}
WHERE meta_key IN ('membres', 'autre_membres') AND meta_value = %s",
$user_ID
)
) );
if ( empty( $membre_ids ) ) {
// Fast path: no membre posts, use simple author filter.
$query->set( 'author', $user_ID );
return;
}
// Posts authored by this user.
$authored_ids = array_map( 'intval', (array) $wpdb->get_col(
$wpdb->prepare(
"SELECT ID FROM {$wpdb->posts} WHERE post_author = %d",
$user_ID
)
) );
$all_ids = array_unique( array_merge( $authored_ids, $membre_ids ) );
// post__in with [0] returns nothing when the combined set is empty.
$query->set( 'post__in', empty( $all_ids ) ? [ 0 ] : $all_ids );
}
add_action( 'pre_get_posts', 'restrict_contributor_posts' );
/**
* Let contributors listed as membres/autre_membres on a post edit it.
*
* user_has_cap modifies capabilities only for this single check —
* it does not permanently alter the user's capability set.
*/
function thalim_membres_can_edit_post( $allcaps, $caps, $args, $user ) {
// Editors and above already have edit_others_posts — nothing to do.
if ( ! empty( $allcaps['edit_others_posts'] ) ) {
return $allcaps;
}
if ( empty( $args[0] ) ) {
return $allcaps;
}
$cap = $args[0];
// Meta caps that carry a post ID in $args[2] (e.g. wp-admin/post.php load).
$meta_caps_with_id = [ 'edit_post', 'edit_page' ];
// Primitive caps called during the admin save flow *without* a
// post_id (e.g. wp-admin/includes/post.php:76 checks edit_others_posts
// directly when $post_author !== current user). We infer the post_id from
// the request so we can still authorize membres per-post.
//
// NOTE: publish_posts / publish_pages are intentionally NOT in this list —
// contributors listed in `membres` must be able to edit (incl. published)
// posts of the lab, but only editors/admins should be able to publish.
$primitive_caps_in_save_flow = [
'edit_others_posts',
'edit_others_pages',
'edit_published_posts',
'edit_published_pages',
];
$post_id = 0;
if ( in_array( $cap, $meta_caps_with_id, true ) && ! empty( $args[2] ) ) {
$post_id = (int) $args[2];
} elseif ( in_array( $cap, $primitive_caps_in_save_flow, true ) ) {
$post_id = (int) ( $_POST['post_ID'] ?? $_REQUEST['post'] ?? 0 );
}
if ( ! $post_id ) {
return $allcaps;
}
$user_id = $user->ID;
$membre_ids = array_map(
'intval',
array_merge(
(array) get_post_meta( $post_id, 'membres', false ),
(array) get_post_meta( $post_id, 'autre_membres', false )
)
);
if ( in_array( $user_id, $membre_ids, true ) ) {
// Grant every primitive cap mapped for this check
// (e.g. edit_others_posts, edit_published_posts).
foreach ( $caps as $c ) {
$allcaps[ $c ] = true;
}
}
return $allcaps;
}
add_filter( 'user_has_cap', 'thalim_membres_can_edit_post', 10, 4 );
// Prevent WP_Posts_List_Table from auto-redirecting contributors to the "Mine"
// view. The constructor sets $_GET['author'] = current_user_id() when the user
// lacks edit_others_posts and no other filter is active. Setting all_posts=1
// before the list table is constructed short-circuits that condition.
add_action( 'load-edit.php', function () {
if ( current_user_can( 'edit_others_posts' ) ) {
return;
}
if (
empty( $_REQUEST['post_status'] )
&& empty( $_REQUEST['all_posts'] )
&& empty( $_REQUEST['author'] )
&& empty( $_REQUEST['show_sticky'] )
) {
$_GET['all_posts'] = 1;
$_REQUEST['all_posts'] = 1;
}
} );
// Adjust post-status counts so contributors see only posts they can access
// (posts they authored + posts listed in membres/autre_membres), not all posts.
// The wp_count_posts filter runs even on cached values, so it won't pollute
// the shared cache.
add_filter( 'wp_count_posts', function ( $counts, $type, $perm ) {
if ( ! is_admin() || current_user_can( 'edit_others_posts' ) ) {
return $counts;
}
global $wpdb;
$user_id = get_current_user_id();
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT post_status, COUNT(*) AS num_posts
FROM {$wpdb->posts}
WHERE post_type = %s
AND ID IN (
SELECT ID FROM {$wpdb->posts}
WHERE post_author = %d AND post_type = %s
UNION
SELECT post_id FROM {$wpdb->postmeta}
WHERE meta_key IN ('membres', 'autre_membres') AND meta_value = %s
)
GROUP BY post_status",
$type,
$user_id,
$type,
(string) $user_id
),
ARRAY_A
);
$new_counts = array_fill_keys( get_post_stati(), 0 );
foreach ( $results as $row ) {
$new_counts[ $row['post_status'] ] = (int) $row['num_posts'];
}
return (object) $new_counts;
}, 10, 3 );
// ── "Vie du labo" — restricted to logged-in users ────
add_action( 'pre_get_posts', function( $query ) {
if ( is_user_logged_in() ) return;
$vie_du_labo = thalim_cat_id( 'vie-du-labo' );
if ( ! $vie_du_labo ) return;
$excluded = $query->get( 'category__not_in' );
if ( ! is_array( $excluded ) ) $excluded = $excluded ? [ $excluded ] : [];
if ( ! in_array( $vie_du_labo, $excluded ) ) {
$excluded[] = $vie_du_labo;
$query->set( 'category__not_in', $excluded );
}
} );
add_action( 'template_redirect', function() {
if ( ! is_user_logged_in() && is_category( thalim_cat_id( 'vie-du-labo' ) ) ) {
wp_safe_redirect( home_url( '/' ) );
exit;
}
} );
add_filter( 'wp_nav_menu_objects', function( $items, $args ) {
if ( is_user_logged_in() ) return $items;
$vie_du_labo = thalim_cat_id( 'vie-du-labo' );
return array_values( array_filter( $items, function( $item ) use ( $vie_du_labo ) {
if ( $item->object === 'category' && (int) $item->object_id === $vie_du_labo ) return false;
if ( strpos( $item->url, 'vie-du-labo' ) !== false ) return false;
return true;
} ) );
}, 10, 2 );
// Non-admins: hide dashboard and tools menu, redirect to posts list
add_action( 'admin_menu', function() {
if ( ! current_user_can( 'manage_options' ) ) {
remove_menu_page( 'tools.php' );
remove_menu_page( 'index.php' );
}
} );
// Redirect non-admins away from dashboard to their posts list
add_action( 'admin_init', function() {
if ( current_user_can( 'manage_options' ) ) return;
global $pagenow;
if ( $pagenow === 'index.php' ) {
wp_safe_redirect( admin_url( 'edit.php' ) );
exit;
}
} );
// After login, send non-admins to posts list instead of dashboard
add_filter( 'login_redirect', function( $redirect_to, $requested, $user ) {
if ( ! is_wp_error( $user ) && ! $user->has_cap( 'manage_options' ) ) {
return admin_url( 'edit.php' );
}
return $redirect_to;
}, 10, 3 );

165
inc/admin-tweaks.php Normal file
View File

@@ -0,0 +1,165 @@
<?php
/**
* Customisations de l'admin WP : synchro display_name, champs Pods sur
* user-new.php, colonnes et filtres des taxonomies, filtre catégorie exact,
* admin bar, rewrite rule /autres, mode visuel forcé.
*/
// Auto-sync display_name from first_name + last_name on every profile save
add_action( 'profile_update', 'thalim_sync_display_name' );
add_action( 'user_register', 'thalim_sync_display_name' );
add_action( 'pods_api_post_save_pod_item_user', function( $pieces ) {
$user_id = isset( $pieces['id'] ) ? intval( $pieces['id'] ) : 0;
if ( $user_id ) thalim_sync_display_name( $user_id );
} );
function thalim_sync_display_name( int $user_id ): void {
$first = get_user_meta( $user_id, 'first_name', true );
$last = get_user_meta( $user_id, 'last_name', true );
// sanitize_text_field: le nom est réaffiché un peu partout (cards, data-*),
// on neutralise ici tout balisage saisi dans prénom/nom.
$name = sanitize_text_field( trim( "$first $last" ) );
if ( ! $name ) return;
// Bypass the filter to avoid infinite loop
remove_action( 'profile_update', 'thalim_sync_display_name' );
wp_update_user( [ 'ID' => $user_id, 'display_name' => $name ] );
add_action( 'profile_update', 'thalim_sync_display_name' );
}
// Show Pods user meta fields on the Add New User page (user-new.php).
// Fires the same hook Pods listens to on profile.php / user-edit.php.
add_action('user_new_form', function($operation) {
if ($operation === 'add-existing-user') return;
$user = new WP_User(0);
do_action('show_user_profile', $user);
});
// Save Pods user meta fields when a new user is registered.
// Re-fires Pods' personal_options_update callback with the new user ID
// while $_POST still contains the submitted form data.
add_action('user_register', function($user_id) {
do_action('personal_options_update', $user_id);
}, 20);
// Remove the Pods autocomplete 30-item cap for the "étiquettes" (post_tag) pick field
// so all tags are available in the dropdown, not just the first 30 alphabetically.
add_filter( 'pods_form_ui_field_pick_autocomplete_limit', function( $limit, $name ) {
if ( 'etiquettes' === $name ) {
return -1;
}
return $limit;
}, 10, 2 );
// Admin "Programmes de recherche" taxonomy list:
// - Filter by "Type de programme" (select injected via JS into the search form).
// - Add a "Type de programme" column to the list table.
// Server-side filtering: read the GET param and add a meta_query on pre_get_terms.
add_action( 'pre_get_terms', function( $query ) {
if ( ! is_admin() ) {
return;
}
if ( ! in_array( 'programme_de_recherche', (array) $query->query_vars['taxonomy'], true ) ) {
return;
}
$type = isset( $_GET['type_de_programme'] ) ? sanitize_text_field( $_GET['type_de_programme'] ) : '';
if ( '' === $type ) {
return;
}
$query->query_vars['meta_query'] = [
[
'key' => 'type_de_programme',
'value' => $type,
'compare' => '=',
],
];
} );
// Column: add "Type de programme" to the list table, replacing "Description".
add_filter( 'manage_edit-programme_de_recherche_columns', function( $columns ) {
unset( $columns['description'] );
$columns['type_de_programme'] = __( 'Type de programme', 'thalim' );
return $columns;
} );
add_filter( 'manage_programme_de_recherche_custom_column', function( $output, $column_name, $term_id ) {
if ( 'type_de_programme' !== $column_name ) {
return $output;
}
return esc_html( get_term_meta( (int) $term_id, 'type_de_programme', true ) ?: '&mdash;' );
}, 10, 3 );
// Admin tag list: replace the "Description" column with the custom boolean field
// "ne_pas_afficher_dans_le_nuage" (Ne pas afficher dans le nuage de mots-clé).
add_filter( 'manage_edit-post_tag_columns', function( $columns ) {
unset( $columns['description'] );
$columns['nuage_exclus'] = __( 'Exclure du nuage', 'thalim' );
return $columns;
} );
add_filter( 'manage_post_tag_custom_column', function( $output, $column_name, $term_id ) {
if ( 'nuage_exclus' !== $column_name ) {
return $output;
}
$val = get_term_meta( (int) $term_id, 'ne_pas_afficher_dans_le_nuage', true );
return $val ? '&#10003;' : '&mdash;';
}, 10, 3 );
// In admin post list, filter by exact category only (exclude subcategories)
function thalim_exact_category_filter( $query ) {
if ( is_admin() && $query->is_main_query() ) {
$cat = $query->get( 'cat' );
if ( $cat ) {
$query->set( 'cat', '' );
$query->set( 'tax_query', [
[
'taxonomy' => 'category',
'field' => 'term_id',
'terms' => [ (int) $cat ],
'include_children' => false,
],
] );
}
}
}
add_action( 'pre_get_posts', 'thalim_exact_category_filter' );
// Rewrite rule for /category/{slug}/autres → posts directly in parent category
add_action('init', function() {
add_rewrite_rule(
'category/([^/]+)/autres/?$',
'index.php?category_name=$matches[1]&thalim_direct_posts=1',
'top'
);
});
add_filter('query_vars', function($vars) {
$vars[] = 'thalim_direct_posts';
return $vars;
});
// Admin bar customizations (front + back)
add_action('admin_bar_menu', function($wp_admin_bar) {
$wp_admin_bar->remove_node('wp-logo');
$wp_admin_bar->remove_node('customize');
foreach ($wp_admin_bar->get_nodes() as $node) {
if (empty($node->title) || stripos($node->title, 'article') === false) continue;
$node->title = preg_replace_callback('/article/i', function($m) {
$w = $m[0];
if ($w === strtoupper($w)) return 'ANNONCE';
if ($w[0] === strtoupper($w[0])) return 'Annonce';
return 'annonce';
}, $node->title);
$wp_admin_bar->add_node((array) $node);
}
}, 999);
add_action('wp_before_admin_bar_render', function() {
global $wp_admin_bar;
$wp_admin_bar->remove_node('wpforms-menu');
});
// Force visual (TinyMCE) editor for all users — our admin CSS hides the
// Visual/Code tabs, so if a user has "Disable the visual editor" checked
// in their profile they get stuck in code mode with no way to switch back.
add_filter( 'user_can_richedit', '__return_true' );

305
inc/ajax.php Normal file
View File

@@ -0,0 +1,305 @@
<?php
/**
* Handlers AJAX du scroll infini (grille + agenda) et données des cartes agenda.
*
* Les deux handlers partagent la lecture des filtres POST et la construction
* de la requête (thalim_ajax_read_filters / thalim_ajax_build_query_args) —
* seuls divergent l'include_children de la clause catégorie, l'exclusion des
* épinglés (grille) et le calcul du today_offset (agenda).
*/
/**
* Lit et assainit les filtres communs envoyés en POST par infiniteScroll.js
* et agendaView.js.
*/
function thalim_ajax_read_filters(): array {
return [
'page' => intval( $_POST['page'] ?? 1 ),
'category' => isset( $_POST['category'] ) ? intval( $_POST['category'] ) : 0,
'axe' => isset( $_POST['axe'] ) ? intval( $_POST['axe'] ) : 0,
'date_from' => isset( $_POST['date_from'] ) ? sanitize_text_field( $_POST['date_from'] ) : '',
'date_to' => isset( $_POST['date_to'] ) ? sanitize_text_field( $_POST['date_to'] ) : '',
'taxonomy' => isset( $_POST['taxonomy'] ) ? sanitize_key( $_POST['taxonomy'] ) : '',
'term' => isset( $_POST['term'] ) ? intval( $_POST['term'] ) : 0,
'filter_cat' => isset( $_POST['filter_cat'] ) ? intval( $_POST['filter_cat'] ) : 0,
'filter_autres' => isset( $_POST['filter_autres'] ) ? intval( $_POST['filter_autres'] ) : 0,
'exclude_cats' => isset( $_POST['exclude_cats'] ) ? sanitize_text_field( $_POST['exclude_cats'] ) : '',
'search' => isset( $_POST['search'] ) ? sanitize_text_field( $_POST['search'] ) : '',
];
}
/**
* Construit les arguments WP_Query communs aux deux handlers.
*
* @param array $f Filtres lus par thalim_ajax_read_filters().
* @param bool $include_children include_children de la clause catégorie principale.
*/
function thalim_ajax_build_query_args( array $f, bool $include_children ): array {
$query_args = [
'post_type' => 'post',
'post_status' => 'publish', // admin-ajax => is_admin() true: sans ça WP ajoute future/draft/pending et décale la pagination vs le front
'posts_per_page' => 12,
'paged' => $f['page'],
'orderby' => 'date',
'order' => 'DESC',
'thalim_event_date_order' => true,
];
if ( $f['search'] ) {
$query_args['s'] = $f['search'];
$query_args['relevanssi'] = true;
$query_args['orderby'] = 'relevance';
}
// Build tax_query — may combine category page filter, taxonomy term, and cat filter
$tax_clauses = [];
if ( $f['category'] ) {
$tax_clauses[] = [
'taxonomy' => 'category',
'field' => 'term_id',
'terms' => [ $f['category'] ],
'include_children' => $include_children,
];
}
if ( $f['taxonomy'] && $f['term'] ) {
$tax_clauses[] = [
'taxonomy' => $f['taxonomy'],
'field' => 'term_id',
'terms' => [ $f['term'] ],
];
// Exclure les séances de séminaire sur les pages de taxonomie
$tax_clauses[] = [
'taxonomy' => 'category',
'field' => 'term_id',
'terms' => [ thalim_cat_id( 'seance' ) ],
'operator' => 'NOT IN',
];
}
if ( $f['filter_cat'] ) {
$tax_clauses[] = [
'taxonomy' => 'category',
'field' => 'term_id',
'terms' => [ $f['filter_cat'] ],
'include_children' => ! $f['filter_autres'],
];
}
if ( $f['exclude_cats'] ) {
$ids = array_filter( array_map( 'intval', explode( ',', $f['exclude_cats'] ) ) );
if ( ! empty( $ids ) ) {
$tax_clauses[] = [
'taxonomy' => 'category',
'field' => 'term_id',
'terms' => $ids,
'operator' => 'NOT IN',
];
}
}
if ( ! empty( $tax_clauses ) ) {
$query_args['tax_query'] = count( $tax_clauses ) > 1
? array_merge( [ 'relation' => 'AND' ], $tax_clauses )
: $tax_clauses;
}
if ( $f['axe'] ) {
$query_args['meta_query'] = [[
'key' => 'axes_thematiques',
'value' => $f['axe'],
'type' => 'NUMERIC',
]];
}
if ( $f['date_from'] || $f['date_to'] ) {
$query_args['thalim_event_date_filter'] = [ 'from' => $f['date_from'], 'to' => $f['date_to'] ];
}
return $query_args;
}
// AJAX handler for infinite scroll on category pages
function thalim_load_more_posts() {
check_ajax_referer('load_more_posts', 'nonce');
$GLOBALS['thalim_lang_override'] = sanitize_key( $_POST['lang'] ?? 'fr' );
$f = thalim_ajax_read_filters();
$query_args = thalim_ajax_build_query_args( $f, false );
// Exclude pinned posts on category pages to avoid duplicates (they already appear at the top,
// pulled out of the main flow by category.php). Must mirror category.php exactly.
if ( $f['category'] ) {
$pinned_ids = thalim_get_active_pinned_ids( $f['category'] );
if ( ! empty( $pinned_ids ) ) {
$query_args['post__not_in'] = $pinned_ids;
}
}
$posts = Timber::get_posts($query_args);
if (empty($posts)) {
wp_send_json_success(['html' => '']);
return;
}
$cards = thalim_get_cards_data($posts);
$html = '';
foreach ($posts as $post) {
$html .= Timber::compile('partials/post-card.twig', [
'post' => $post,
'card' => $cards[$post->ID],
'show_category' => true,
'type_only' => $f['category'] > 0, // category pages: type chip only; everywhere else (taxonomy, search, annonces): type or category name
]);
}
wp_send_json_success(['html' => $html]);
}
add_action('wp_ajax_load_more_posts', 'thalim_load_more_posts');
add_action('wp_ajax_nopriv_load_more_posts', 'thalim_load_more_posts');
/**
* Build structured data for one agenda slider card.
*/
function thalim_get_agenda_card_data( $post_id, $lang = 'fr' ) {
$months_fr = ['jan.','fév.','mars','avr.','mai','juin','juil.','août','sept.','oct.','nov.','déc.'];
$months_en = ['Jan.','Feb.','Mar.','Apr.','May','Jun.','Jul.','Aug.','Sep.','Oct.','Nov.','Dec.'];
$months = $lang === 'en' ? $months_en : $months_fr;
$raw_debut = get_post_meta( $post_id, 'date_de_debut', true );
$raw_datetime = get_post_meta( $post_id, 'datetime', true );
if ( $raw_debut && ! str_starts_with( $raw_debut, '0000' ) ) {
$ts = strtotime( $raw_debut );
} elseif ( $raw_datetime && ! str_starts_with( $raw_datetime, '0000' ) ) {
$ts = strtotime( $raw_datetime );
} else {
$ts = get_post_timestamp( $post_id );
}
$raw_fin = get_post_meta( $post_id, 'date_de_fin', true );
$ts_debut = ( $raw_debut && ! str_starts_with( $raw_debut, '0000' ) ) ? strtotime( $raw_debut ) : 0;
$ts_fin = ( $raw_fin && ! str_starts_with( $raw_fin, '0000' ) ) ? strtotime( $raw_fin ) : 0;
// Build date_label — same rules as single.twig sidebar
$fmt_debut = $ts_debut ? thalim_format_date( $raw_debut, $lang ) : '';
$fmt_fin = $ts_fin ? thalim_format_date( $raw_fin, $lang ) : '';
$fmt_dt = ( $raw_datetime && ! str_starts_with( $raw_datetime, '0000' ) )
? thalim_format_date( $raw_datetime, $lang ) : '';
$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 ) {
if ( $ts_debut && $ts_fin && date( 'Y-m-d', $ts_debut ) === date( 'Y-m-d', $ts_fin ) ) {
// Same day
if ( $h_debut && $h_fin ) {
$date_label = ( $lang === 'en' ? 'On ' : 'Le ' ) . $fmt_debut
. ' ' . ( $lang === 'en' ? 'from ' : 'de ' ) . $h_debut
. ' ' . ( $lang === 'en' ? 'to ' : 'à ' ) . $h_fin;
} elseif ( $h_debut ) {
$date_label = $fmt_debut . ( $lang === 'en' ? ' at ' : ' à ' ) . $h_debut;
} else {
$date_label = $fmt_debut;
}
} elseif ( $fmt_debut && $fmt_fin ) {
$date_label = ( $lang === 'en' ? 'From ' : 'Du ' ) . $fmt_debut
. ' ' . ( $lang === 'en' ? 'to ' : 'au ' ) . $fmt_fin;
} elseif ( $fmt_debut ) {
$date_label = $h_debut
? $fmt_debut . ( $lang === 'en' ? ' at ' : ' à ' ) . $h_debut
: $fmt_debut;
} else {
$date_label = ( $lang === 'en' ? 'Until ' : "Jusqu'au " ) . $fmt_fin;
}
} elseif ( $fmt_dt ) {
$date_label = $h_debut
? $fmt_dt . ( $lang === 'en' ? ' at ' : ' à ' ) . $h_debut
: $fmt_dt;
} else {
$date_label = '';
}
$type_fields = [
'type_colloque_journee_d_etude', 'type_soutenance', 'type_evenement_culturel',
'type_media', 'type_captation', 'type_revue_collection', 'type_autre',
];
$type_label = '';
foreach ( $type_fields as $f ) {
$v = get_post_meta( $post_id, $f, true );
if ( $v ) { $type_label = thalim_bilingual( $v, $lang ); break; }
}
if ( ! $type_label ) {
foreach ( get_the_category( $post_id ) as $cat ) {
if ( $cat->parent ) { $type_label = thalim_cat_name( $cat, $lang ); break; }
}
}
$end_day = $end_month = $end_year = null;
if ( $ts_fin && date( 'Ymd', $ts_fin ) !== date( 'Ymd', $ts ) ) {
$end_day = (int) date( 'j', $ts_fin );
$end_month = $months[ (int) date( 'n', $ts_fin ) - 1 ];
$end_year = (int) date( 'Y', $ts_fin );
}
// Séance de séminaire: link to parent séminaire at #seance-{ID}
$link = get_permalink( $post_id );
if ( in_array( thalim_cat_id( 'seance' ), wp_list_pluck( get_the_category( $post_id ), 'term_id' ) ) ) {
$link = thalim_get_seance_link( $post_id );
}
return [
'day' => (int) date( 'j', $ts ),
'month' => $months[ (int) date( 'n', $ts ) - 1 ],
'year' => (int) date( 'Y', $ts ),
'end_day' => $end_day,
'end_month' => $end_month,
'end_year' => $end_year,
'type_label' => $type_label,
'date_label' => $date_label,
'lieu' => thalim_bilingual( get_post_meta( $post_id, 'lieu', true ) ?: '', $lang ),
'link' => $link,
];
}
function thalim_load_more_agenda() {
check_ajax_referer( 'load_more_posts', 'nonce' );
$lang = sanitize_key( $_POST['lang'] ?? 'fr' );
$GLOBALS['thalim_lang_override'] = $lang;
$f = thalim_ajax_read_filters();
$query_args = thalim_ajax_build_query_args( $f, ! empty( $_POST['include_children'] ) );
// On first page, count future events to find today's anchor position
$today_offset = 0;
if ( (int) $f['page'] === 1 ) {
$offset_args = $query_args;
$offset_args['posts_per_page'] = 1;
$offset_args['no_found_rows'] = false;
$offset_args['paged'] = 1;
$today_str = current_time( 'Y-m-d' );
$existing_filter = $offset_args['thalim_event_date_filter'] ?? [];
$offset_args['thalim_event_date_filter'] = array_merge(
$existing_filter,
[ 'from' => $today_str ]
);
$future_query = new \WP_Query( $offset_args );
$today_offset = (int) $future_query->found_posts;
}
$posts = Timber::get_posts( $query_args );
if ( empty( $posts ) ) {
$response = [ 'html' => '' ];
if ( (int) $f['page'] === 1 ) $response['today_offset'] = $today_offset;
wp_send_json_success( $response );
return;
}
$html = '';
foreach ( $posts as $post ) {
$data = thalim_get_agenda_card_data( $post->ID, $lang );
$html .= Timber::compile( 'partials/agenda-card.twig', array_merge( $data, [ 'post' => $post ] ) );
}
$response = [ 'html' => $html ];
if ( (int) $f['page'] === 1 ) $response['today_offset'] = $today_offset;
wp_send_json_success( $response );
}
add_action( 'wp_ajax_load_more_agenda', 'thalim_load_more_agenda' );
add_action( 'wp_ajax_nopriv_load_more_agenda', 'thalim_load_more_agenda' );

124
inc/archive-filters.php Normal file
View File

@@ -0,0 +1,124 @@
<?php
/**
* Helpers partagés des pages d'archives (category.php, taxonomy.php,
* search.php, page-annonces.php) : lecture des filtres GET, rubrique active,
* détection des posts « directs » et construction des listes de filtres.
*
* La construction des liens reste propre à chaque contrôleur (catégorie :
* navigation vers la page de catégorie ; taxonomie/recherche : paramètre
* filter_cat sur l'URL courante) — elle est injectée via $make_link.
*/
/**
* Catégories exclues des archives : séances de séminaire, non classé,
* et « Vie du labo » pour les visiteurs non connectés.
*/
function thalim_archive_excluded_cat_ids( bool $exclude_seances = true ): array {
$ids = [];
if ( $exclude_seances ) $ids[] = thalim_cat_id( 'seance' );
$ids[] = thalim_cat_id( 'non-classe' );
if ( ! is_user_logged_in() ) $ids[] = thalim_cat_id( 'vie-du-labo' );
return array_values( array_filter( $ids ) );
}
/**
* Lit et assainit les filtres communs passés en GET sur les archives.
*/
function thalim_archive_read_filters(): array {
return [
'axe' => isset( $_GET['axe'] ) ? intval( $_GET['axe'] ) : 0,
'date_from' => isset( $_GET['date_from'] ) ? sanitize_text_field( $_GET['date_from'] ) : '',
'date_to' => isset( $_GET['date_to'] ) ? sanitize_text_field( $_GET['date_to'] ) : '',
'cat_id' => isset( $_GET['filter_cat'] ) ? intval( $_GET['filter_cat'] ) : 0,
'filter_autres' => isset( $_GET['filter_autres'] ) ? 1 : 0,
];
}
/**
* Rubrique active déduite de la catégorie filtrée (parent si sous-catégorie).
*/
function thalim_archive_active_rubrique( int $active_cat_id ): int {
if ( ! $active_cat_id ) return 0;
$cat = get_category( $active_cat_id );
return ( $cat && ! is_wp_error( $cat ) && $cat->parent ) ? (int) $cat->parent : $active_cat_id;
}
/**
* Y a-t-il des posts publiés directement dans cette rubrique (sans
* sous-catégorie) ? $extra_tax_clauses permet d'ajouter la contrainte du
* terme de taxonomie courant (pages taxonomy.php).
*/
function thalim_rubrique_has_direct_posts( int $rubrique_id, array $extra_tax_clauses = [] ): bool {
$clauses = array_merge( [
[
'taxonomy' => 'category',
'field' => 'term_id',
'terms' => [ $rubrique_id ],
'include_children' => false,
],
], $extra_tax_clauses );
$tax_query = count( $clauses ) > 1
? array_merge( [ 'relation' => 'AND' ], $clauses )
: $clauses;
$direct_check = new WP_Query( [
'post_type' => 'post',
'posts_per_page' => 1,
'fields' => 'ids',
'no_found_rows' => true,
'tax_query' => $tax_query,
] );
return $direct_check->have_posts();
}
/**
* Rubriques (catégories racines) pour la barre de filtres.
*
* @param array $all_cats Résultat de get_categories (hide_empty false, exclusions faites).
* @param callable $make_link fn(WP_Term $cat): string — URL du lien de filtre.
*/
function thalim_archive_filter_parents( array $all_cats, callable $make_link ): array {
$out = [];
foreach ( $all_cats as $cat ) {
if ( $cat->parent == 0 ) {
$out[] = [
'id' => $cat->term_id,
'name' => thalim_cat_name( $cat ),
'slug' => $cat->slug,
'link' => $make_link( $cat ),
];
}
}
return $out;
}
/**
* Sous-catégories de la rubrique active pour la barre de filtres.
*/
function thalim_archive_filter_children( array $all_cats, int $rubrique_id, callable $make_link ): array {
$out = [];
if ( ! $rubrique_id ) return $out;
foreach ( $all_cats as $cat ) {
if ( $cat->parent == $rubrique_id ) {
$out[] = [
'id' => $cat->term_id,
'name' => thalim_cat_name( $cat ),
'slug' => $cat->slug,
'link' => $make_link( $cat ),
];
}
}
return $out;
}
/**
* Entrée « Autres » de la barre de filtres (posts directement dans la rubrique).
*/
function thalim_archive_autres_entry( string $link ): array {
$lang = thalim_current_language();
return [
'id' => 'autres',
'name' => $lang === 'en' ? 'Other' : 'Autres',
'slug' => 'autres',
'link' => $link,
];
}

284
inc/assets.php Normal file
View File

@@ -0,0 +1,284 @@
<?php
/**
* Enqueue des assets front (theme_enqueue_assets) et admin (enqueue_admin_js).
* Cache-busting par filemtime — pas de bundler.
*
* Les dépendances tierces (Swiper 12.2.0, Iconoir 7.11.0) sont auto-hébergées
* dans assets/vendor/ (fiabilité + RGPD — plus aucun appel CDN). Pour les
* mettre à jour : retélécharger les fichiers en épinglant la version exacte.
*/
function thalim_enqueue_swiper() {
wp_enqueue_style(
'swiper',
get_template_directory_uri() . '/assets/vendor/swiper/swiper-bundle.min.css',
[],
'12.2.0'
);
wp_enqueue_script(
'swiper',
get_template_directory_uri() . '/assets/vendor/swiper/swiper-bundle.min.js',
[],
'12.2.0',
true
);
}
function theme_enqueue_assets() {
wp_enqueue_style(
'main-styles',
get_template_directory_uri() . '/css/style.css',
[],
filemtime(get_template_directory() . '/css/style.css')
);
wp_enqueue_style(
'iconoir',
get_template_directory_uri() . '/assets/vendor/iconoir/iconoir.css',
[],
'7.11.0'
);
wp_enqueue_script(
'overlay',
get_template_directory_uri() . '/js/overlay.js',
[],
filemtime(get_template_directory() . '/js/overlay.js'),
true
);
if (is_front_page() || is_404()) {
wp_enqueue_script(
'animatedLogo',
get_template_directory_uri() . '/js/animatedLogo.js',
[],
filemtime(get_template_directory() . '/js/animatedLogo.js'),
true
);
wp_add_inline_script( 'animatedLogo', 'var themeDirURI = ' . wp_json_encode( get_template_directory_uri() ) . ';', 'before' );
}
if ( is_category() ) {
thalim_enqueue_swiper();
wp_enqueue_script( 'agendaView', get_template_directory_uri() . '/js/agendaView.js', ['swiper'], filemtime( get_template_directory() . '/js/agendaView.js' ), true );
wp_localize_script( 'agendaView', 'agendaViewData', [
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'load_more_posts' ),
'lang' => thalim_current_language(),
]);
}
if (is_front_page()) {
wp_enqueue_script(
'coloredWordsHero',
get_template_directory_uri() . '/js/coloredWordsHero.js',
[],
filemtime(get_template_directory() . '/js/coloredWordsHero.js'),
true
);
thalim_enqueue_swiper();
wp_enqueue_script(
'annoncesSwiper',
get_template_directory_uri() . '/js/annoncesSwiper.js',
['swiper'],
filemtime(get_template_directory() . '/js/annoncesSwiper.js'),
true
);
wp_enqueue_script(
'messageLabo',
get_template_directory_uri() . '/js/messageLabo.js',
[],
filemtime(get_template_directory() . '/js/messageLabo.js'),
true
);
wp_enqueue_script(
'keywordCloud',
get_template_directory_uri() . '/js/keywordCloud.js',
[],
filemtime(get_template_directory() . '/js/keywordCloud.js'),
true
);
wp_enqueue_script(
'quickLinks',
get_template_directory_uri() . '/js/quickLinks.js',
[],
filemtime(get_template_directory() . '/js/quickLinks.js'),
true
);
$kw_tags = get_terms([
'taxonomy' => 'post_tag',
'hide_empty' => true,
'orderby' => 'name',
'order' => 'ASC',
]);
if (!is_wp_error($kw_tags) && !empty($kw_tags)) {
$kw_lang = thalim_current_language();
$kw_tags = array_filter($kw_tags, function ($tag) {
return !get_term_meta($tag->term_id, 'ne_pas_afficher_dans_le_nuage', true);
});
wp_localize_script('keywordCloud', 'thalimTags', array_values(array_map(function ($tag) use ($kw_lang) {
return ['name' => html_entity_decode(thalim_bilingual($tag->name, $kw_lang), ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'url' => get_term_link($tag)];
}, $kw_tags)));
}
}
wp_enqueue_script(
'navAxesToggle',
get_template_directory_uri() . '/js/navAxesToggle.js',
[],
filemtime(get_template_directory() . '/js/navAxesToggle.js'),
true
);
wp_enqueue_script(
'stickyHeader',
get_template_directory_uri() . '/js/stickyHeader.js',
[],
filemtime(get_template_directory() . '/js/stickyHeader.js'),
true
);
wp_enqueue_script(
'frenchTypography',
get_template_directory_uri() . '/js/frenchTypography.js',
[],
filemtime(get_template_directory() . '/js/frenchTypography.js'),
true
);
if (is_page('membres')) {
wp_enqueue_script(
'membresFilters',
get_template_directory_uri() . '/js/membresFilters.js',
[],
filemtime(get_template_directory() . '/js/membresFilters.js'),
true
);
wp_enqueue_script(
'membresPopover',
get_template_directory_uri() . '/js/membresPopover.js',
[],
filemtime(get_template_directory() . '/js/membresPopover.js'),
true
);
}
if (is_single() || is_author() || is_page('membres') || is_page('le-laboratoire') || is_page('programmes-de-recherche')) {
wp_enqueue_script(
'seanceToggle',
get_template_directory_uri() . '/js/seanceToggle.js',
[],
filemtime(get_template_directory() . '/js/seanceToggle.js'),
true
);
}
if (is_single()) {
thalim_enqueue_swiper();
wp_enqueue_script(
'imageSwiper',
get_template_directory_uri() . '/js/imageSwiper.js',
['swiper'],
filemtime(get_template_directory() . '/js/imageSwiper.js'),
true
);
}
wp_enqueue_script(
'fitPostCardTitle',
get_template_directory_uri() . '/js/fitPostCardTitle.js',
['frenchTypography'],
filemtime(get_template_directory() . '/js/fitPostCardTitle.js'),
true
);
$is_archive_page = is_category() || is_tax() || is_tag() || is_page(['annonces', 'announcements']) || is_search();
if ($is_archive_page) {
wp_enqueue_script(
'infiniteScroll',
get_template_directory_uri() . '/js/infiniteScroll.js',
[],
filemtime(get_template_directory() . '/js/infiniteScroll.js'),
true
);
wp_localize_script('infiniteScroll', 'infiniteScrollData', [
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('load_more_posts'),
'lang' => thalim_current_language(),
]);
wp_enqueue_script(
'categoryFilters',
get_template_directory_uri() . '/js/categoryFilters.js',
[],
filemtime(get_template_directory() . '/js/categoryFilters.js'),
true
);
}
}
add_action('wp_enqueue_scripts', 'theme_enqueue_assets');
/**
* Scripts admin découpés par contexte de page (js/admin/*), enqueue
* conditionnel par écran. admin-base.js fournit le namespace partagé
* window.ThalimAdmin dont dépendent les autres.
*/
function enqueue_admin_js() {
wp_enqueue_style(
'adminDashboardStyles',
get_template_directory_uri() . '/css/admin.css',
[],
filemtime(get_template_directory() . '/css/admin.css')
);
$base_uri = get_template_directory_uri() . '/js/admin';
$base_dir = get_template_directory() . '/js/admin';
$enqueue = function ( $handle, $file, $deps ) use ( $base_uri, $base_dir ) {
wp_enqueue_script( $handle, "$base_uri/$file", $deps, filemtime( "$base_dir/$file" ), true );
};
// Toutes pages admin : socle partagé + rename « Article » → « Annonce »
$enqueue( 'thalim-admin-base', 'admin-base.js', [ 'jquery' ] );
$enqueue( 'thalim-admin-rename', 'admin-rename.js', [ 'thalim-admin-base' ] );
$screen = get_current_screen();
if ( ! $screen ) {
return;
}
// post.php / post-new.php (tous post types)
if ( 'post' === $screen->base ) {
$enqueue( 'thalim-admin-post-edit', 'admin-post-edit.js', [ 'jquery', 'thalim-admin-base' ] );
$axes_groups = thalim_get_axes_filter_groups();
if ( current_user_can( 'contributor' ) && ! current_user_can( 'edit_others_posts' ) ) {
$axes_groups = array_slice( $axes_groups, 0, 1 );
}
wp_localize_script( 'thalim-admin-post-edit', 'thalimAxesGroups', $axes_groups );
// Modale Pods de création de séance (iframe avec ?pods_modal)
if ( isset( $_GET['pods_modal'] ) ) {
$enqueue( 'thalim-admin-pods-modal', 'admin-pods-modal.js', [ 'jquery', 'thalim-admin-base' ] );
}
}
// profile.php / user-edit.php / user-new.php
if ( in_array( $screen->base, [ 'profile', 'user-edit', 'user' ], true ) ) {
$enqueue( 'thalim-admin-profile', 'admin-profile.js', [ 'jquery', 'thalim-admin-base' ] );
}
// edit-tags.php / term.php
if ( in_array( $screen->base, [ 'edit-tags', 'term' ], true ) ) {
$enqueue( 'thalim-admin-taxonomy-list', 'admin-taxonomy-list.js', [ 'jquery', 'thalim-admin-base' ] );
}
}
add_action( 'admin_enqueue_scripts', 'enqueue_admin_js' );

View File

@@ -116,20 +116,22 @@ function thalim_get_author_data($user_id) {
? thalim_bilingual(get_user_meta($user_id, 'affiliation_autre', true) ?: '', $lang)
: $v;
})(),
'bio' => wpautop( make_clickable( get_user_meta($user_id, 'biographie', true) ?: '' ) ),
'bio_en' => wpautop( make_clickable( get_user_meta($user_id, 'biographie_en', true) ?: '' ) ),
// wp_kses_post: ces champs sont éditables par les contributeurs (profil)
// et rendus en |raw dans author.twig → XSS stocké sans filtrage.
'bio' => wpautop( make_clickable( wp_kses_post( get_user_meta($user_id, 'biographie', true) ?: '' ) ) ),
'bio_en' => wpautop( make_clickable( wp_kses_post( get_user_meta($user_id, 'biographie_en', true) ?: '' ) ) ),
'domaines_tags' => $domaines_tags,
'domaines' => wpautop( make_clickable( get_user_meta($user_id, 'autres_domaines_de_recherches', true) ?: '' ) ),
'domaines_en' => wpautop( make_clickable( get_user_meta($user_id, 'autres_domaines_de_recherches_en', true) ?: '' ) ),
'recherches' => wpautop( get_user_meta($user_id, 'recherches_en_cours', true) ?: '' ),
'recherches_en' => wpautop( get_user_meta($user_id, 'recherches_en_cours_en', true) ?: '' ),
'domaines' => wpautop( make_clickable( wp_kses_post( get_user_meta($user_id, 'autres_domaines_de_recherches', true) ?: '' ) ) ),
'domaines_en' => wpautop( make_clickable( wp_kses_post( get_user_meta($user_id, 'autres_domaines_de_recherches_en', true) ?: '' ) ) ),
'recherches' => wpautop( wp_kses_post( get_user_meta($user_id, 'recherches_en_cours', true) ?: '' ) ),
'recherches_en' => wpautop( wp_kses_post( get_user_meta($user_id, 'recherches_en_cours_en', true) ?: '' ) ),
'axes' => $axes,
'titre_these' => thalim_bilingual(get_user_meta($user_id, 'titre_de_these', true) ?: '', $lang),
'date_soutenance' => get_user_meta($user_id, 'date_de_soutenance', true) ?: '',
'directeur_thalim'=> $directeur_thalim,
'autre_directeur' => get_user_meta($user_id, 'autre_directeur_de_these', true) ?: '',
'resume_these' => wpautop( get_user_meta($user_id, 'resume_de_la_these', true) ?: '' ),
'resume_these_en' => wpautop( get_user_meta($user_id, 'resume_de_la_these_en', true) ?: '' ),
'resume_these' => wpautop( wp_kses_post( get_user_meta($user_id, 'resume_de_la_these', true) ?: '' ) ),
'resume_these_en' => wpautop( wp_kses_post( get_user_meta($user_id, 'resume_de_la_these_en', true) ?: '' ) ),
'email' => $show_email ? $user->user_email : '',
'liens_externes' => $liens_externes,
'documents' => $documents,
@@ -148,7 +150,8 @@ function thalim_get_author_data($user_id) {
* Returns an array sorted by post count (descending).
*/
function thalim_get_author_posts_by_category($user_id) {
$excluded_cats = [12, 31]; // séances de séminaire, etc.
$seance_cat = thalim_cat_id('seance');
$excluded_cats = array_filter([ $seance_cat, thalim_cat_id('non-classe') ]);
$lang = thalim_current_language();
$posts = Timber::get_posts([
@@ -166,7 +169,6 @@ function thalim_get_author_posts_by_category($user_id) {
],
],
'thalim_event_date_order' => true,
'lang' => '',
]);
$groups = [];
@@ -205,29 +207,28 @@ function thalim_get_author_posts_by_category($user_id) {
$groups[$cat_id]['posts'][] = $post;
}
// Séances de séminaire — dedicated group. Posts in cat 12 where the member
// is listed in `membres`/`autre_membres`. Cards use the parent séminaire
// permalink with a #seance-{ID} hash (see thalim_get_card_data).
// Séances de séminaire — dedicated group. Posts in the séance category
// where the member is listed in `membres`/`autre_membres`. Cards use the
// parent séminaire permalink with a #seance-{ID} hash (see thalim_get_card_data).
$seances = Timber::get_posts([
'post_type' => 'post',
'posts_per_page' => -1,
'category__in' => [12],
'category__in' => [ $seance_cat ],
'meta_query' => [
'relation' => 'OR',
[ 'key' => 'membres', 'value' => $user_id ],
[ 'key' => 'autre_membres', 'value' => $user_id ],
],
'thalim_event_date_order' => true,
'lang' => '',
]);
if (count($seances) > 0) {
$seance_cat = get_term(12, 'category');
$groups[12] = [
'cat_id' => 12,
'cat_name' => $seance_cat && !is_wp_error($seance_cat)
? thalim_cat_name($seance_cat, $lang)
$seance_term = get_term($seance_cat, 'category');
$groups[$seance_cat] = [
'cat_id' => $seance_cat,
'cat_name' => $seance_term && !is_wp_error($seance_term)
? thalim_cat_name($seance_term, $lang)
: ($lang === 'en' ? 'Seminar sessions' : 'Séances de séminaire'),
'cat_url' => get_category_link(12),
'cat_url' => get_category_link($seance_cat),
'posts' => $seances,
];
}

93
inc/avatars.php Normal file
View File

@@ -0,0 +1,93 @@
<?php
/**
* Avatars utilisateurs — chaîne de fallback
* Simple Local Avatar → Gravatar (cache préchauffé par cron) → chaîne vide
* (initiales côté template).
*
* Le rendu ne fait JAMAIS de requête réseau : l'existence du Gravatar est
* vérifiée par un cron quotidien (thalim_warm_gravatar_cache) qui remplit
* les transients. En cas de cache manquant (nouvel utilisateur, transient
* expiré), on renvoie '' (initiales) et on planifie un réchauffage unitaire.
*/
/**
* Returns the avatar URL for a user:
* 1. Simple Local Avatar (media library upload) if set
* 2. Gravatar if the user has one (transient rempli par cron)
* 3. Empty string → templates fall back to initials/placeholder
*/
function thalim_get_user_avatar_url( int $user_id ): string {
// 1. Simple Local Avatar plugin
$meta = get_user_meta( $user_id, 'simple_local_avatar', true );
if ( is_array( $meta ) && ! empty( $meta['full'] ) ) {
// Use media_id for a dynamic URL that works across environments
if ( ! empty( $meta['media_id'] ) ) {
$url = wp_get_attachment_url( (int) $meta['media_id'] );
if ( $url ) return $url;
}
// Fallback: rewrite stored URL to current site domain
$pos = strpos( $meta['full'], '/wp-content/' );
if ( $pos !== false ) {
return rtrim( site_url(), '/' ) . substr( $meta['full'], $pos );
}
return $meta['full'];
}
// 2. Gravatar — uniquement depuis le cache (pas de HEAD dans le rendu)
$cached = get_transient( 'thalim_gravatar_' . $user_id );
if ( $cached !== false ) return $cached;
// Cache manquant : fallback initiales tout de suite, réchauffage en différé
if ( ! wp_next_scheduled( 'thalim_warm_gravatar_user', [ $user_id ] ) ) {
wp_schedule_single_event( time() + 60, 'thalim_warm_gravatar_user', [ $user_id ] );
}
return '';
}
/**
* Vérifie l'existence du Gravatar d'un utilisateur (HEAD avec d=404) et met
* le résultat (positif ou négatif) en transient. Appelé uniquement en cron.
*/
function thalim_refresh_gravatar_cache( int $user_id ): void {
$user = get_userdata( $user_id );
if ( ! $user ) return;
$hash = md5( strtolower( trim( $user->user_email ) ) );
$response = wp_remote_head(
'https://www.gravatar.com/avatar/' . $hash . '?d=404',
[ 'timeout' => 3 ]
);
// Erreur réseau : ne pas mettre en cache un faux négatif, retenter plus tard
if ( is_wp_error( $response ) ) return;
$url = wp_remote_retrieve_response_code( $response ) === 200
? 'https://www.gravatar.com/avatar/' . $hash . '?s=300'
: '';
set_transient( 'thalim_gravatar_' . $user_id, $url, WEEK_IN_SECONDS );
}
add_action( 'thalim_warm_gravatar_user', 'thalim_refresh_gravatar_cache' );
// Cron quotidien : réchauffe le cache Gravatar des utilisateurs sans avatar
// local, avant expiration des transients (1 semaine) — le premier rendu de
// /membres ne paie plus jamais N requêtes HEAD de 3 s.
add_action( 'init', function () {
if ( ! wp_next_scheduled( 'thalim_warm_gravatar_cache' ) ) {
wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'thalim_warm_gravatar_cache' );
}
} );
add_action( 'thalim_warm_gravatar_cache', function () {
$users = get_users( [ 'fields' => 'ID' ] );
foreach ( $users as $user_id ) {
$user_id = (int) $user_id;
// Avatar local → pas besoin de Gravatar
$meta = get_user_meta( $user_id, 'simple_local_avatar', true );
if ( is_array( $meta ) && ! empty( $meta['full'] ) ) continue;
// Transient encore frais (posé il y a < 1 semaine) : on le rafraîchit
// quand même s'il expire dans moins de 2 jours, sinon on saute.
$timeout = (int) get_option( '_transient_timeout_thalim_gravatar_' . $user_id );
if ( $timeout && $timeout - time() > 2 * DAY_IN_SECONDS ) continue;
thalim_refresh_gravatar_cache( $user_id );
}
} );

90
inc/config.php Normal file
View File

@@ -0,0 +1,90 @@
<?php
/**
* Configuration centralisée des identifiants du site.
*
* Les term_ids auto-incrémentés peuvent changer lors d'une réimportation de
* base ; partout où c'est possible on résout par slug (stable) avec cache
* statique par requête. Les quelques maps indexées par term_id documentent
* explicitement leur dépendance à l'installation.
*/
/**
* Résout un term_id par slug (catégories, rôles, etc.), avec cache statique.
* Renvoie 0 si le terme n'existe pas — les appelants doivent tolérer 0
* (clause tax_query vide, comparaison toujours fausse…).
*/
function thalim_term_id_by_slug( string $slug, string $taxonomy = 'category' ): int {
static $cache = [];
$key = $taxonomy . ':' . $slug;
if ( ! isset( $cache[ $key ] ) ) {
$term = get_term_by( 'slug', $slug, $taxonomy );
$cache[ $key ] = ( $term && ! is_wp_error( $term ) ) ? (int) $term->term_id : 0;
}
return $cache[ $key ];
}
/**
* Catégories « structurelles » du site, résolues par slug.
* Clés logiques → slug en base. Utiliser thalim_cat_id('seance') etc.
*/
function thalim_cat_id( string $key ): int {
static $slugs = [
// racines / rubriques
'laboratoire' => 'le-laboratoire',
'manifestations' => 'manifestations-scientifiques',
'publications' => 'publications-et-productions',
'mediation' => 'mediation-scientifique',
'ressources' => 'ressources',
// catégories techniques
'seance' => 'seance-de-seminaire',
'non-classe' => 'non-classe',
'vie-du-labo' => 'vie-du-labo-intranet',
'newsletter' => 'newsletter',
'message-labo' => 'message-du-laboratoire',
// sous-catégories utilisées dans la logique métier
'seminaires' => 'seminaires',
'colloques' => 'colloques-et-journees-detudes',
'communications' => 'communications',
'soutenances' => 'soutenances',
'ouvrages' => 'ouvrages',
'articles' => 'articles',
'revues' => 'revues-et-collections',
'multimedia' => 'multimedia',
'evenements-culturels' => 'evenements-culturels',
'medias' => 'medias',
'gazette' => 'gazette',
'podcast' => 'podcast-de-thalim',
'captations' => 'captations',
'appels' => 'appels-a-contribution',
];
if ( ! isset( $slugs[ $key ] ) ) return 0;
return thalim_term_id_by_slug( $slugs[ $key ], 'category' );
}
/**
* Rôles (taxonomy `role`) techniques, exclus de la recherche de membres.
*/
function thalim_excluded_role_ids(): array {
return array_values( array_filter( [
thalim_term_id_by_slug( 'a-ranger', 'role' ),
thalim_term_id_by_slug( 'archive', 'role' ),
] ) );
}
/**
* Clé couleur stable d'une catégorie racine, indexée sur le term_id (immuable)
* plutôt que sur le slug (que l'admin peut régénérer en renommant la catégorie).
* Renvoie la clé canonique attendue par les classes CSS .gradient--{clé} /
* .category--{clé} (_postcard.scss, _single.scss, _category.scss).
* Fallback sur le slug live pour toute racine hors des 5 rubriques connues.
*/
function thalim_category_color_slug( $root_term_id, $fallback_slug = '' ) {
$map = [
1 => 'le-laboratoire',
3 => 'manifestations-scientifiques',
4 => 'publications-et-productions',
5 => 'mediation-scientifique',
6 => 'ressources',
];
return $map[ (int) $root_term_id ] ?? $fallback_slug;
}

71
inc/context.php Normal file
View File

@@ -0,0 +1,71 @@
<?php
/**
* Contexte Twig global (menus, contenu général, axes courants, URL annonces,
* sélecteur de langue). Branché sur le filtre timber/context.
*/
function add_to_context($context) {
$current_lang = thalim_current_language();
// menus
$menu_slug = ($current_lang === 'en') ? 'Navigation-en' : 'Navigation';
$context['menu'] = Timber::get_menu($menu_slug);
$footer_menu_slug = ($current_lang === 'en') ? 'Footer-en' : 'Footer';
$context['footer_menu'] = Timber::get_menu($footer_menu_slug);
// contenus généraux (single post, bilingual)
$gc_posts = Timber::get_posts([
'post_type' => 'contenu_general',
'posts_per_page' => 1,
'orderby' => 'ID',
'order' => 'ASC',
]);
$gc_post = $gc_posts[0] ?? null;
if ( $gc_post ) {
$context['gc'] = [
'umr' => thalim_bilingual( $gc_post->umr ?: '', $current_lang ),
'thalim' => thalim_bilingual( $gc_post->thalim ?: '', $current_lang ),
'siecles' => thalim_bilingual( $gc_post->siecles ?: '', $current_lang ),
'presentation' => ( $current_lang === 'en' && $gc_post->presentation_en ) ? $gc_post->presentation_en : $gc_post->presentation,
'presentation_detail' => ( $current_lang === 'en' && $gc_post->presentation_detail_en ) ? $gc_post->presentation_detail_en : $gc_post->presentation_detail,
];
} else {
$context['gc'] = [];
}
$context['current_language'] = $current_lang;
// Axes thématiques courants (annee_fin >= current year) for navigation dropdown
$current_year = (int) date('Y');
$all_axes = get_terms(['taxonomy' => 'axe_thematique', 'hide_empty' => false, 'orderby' => 'name']);
$axes_courants = [];
if (!is_wp_error($all_axes)) {
foreach ($all_axes as $axe) {
$fin = (int) get_term_meta($axe->term_id, 'annee_fin', true);
if ($fin >= $current_year) {
$link = get_term_link($axe);
if (!is_wp_error($link)) {
$axes_courants[] = [
'name' => thalim_bilingual($axe->name, $current_lang),
'link' => $link,
'ordre' => (int) get_term_meta($axe->term_id, 'ordre_daffichage', true),
];
}
}
}
usort($axes_courants, fn($a, $b) => $a['ordre'] <=> $b['ordre']);
}
$context['axes_courants'] = $axes_courants;
// Annonces page URL (language-aware)
$annonces_page = get_page_by_path('annonces');
$annonces_base = $annonces_page ? get_permalink($annonces_page->ID) : home_url('/annonces/');
$context['annonces_url'] = $annonces_base;
// Language switcher
$context['languages'] = thalim_language_switcher();
return $context;
}
add_filter('timber/context', 'add_to_context');

148
inc/event-dates.php Normal file
View File

@@ -0,0 +1,148 @@
<?php
/**
* Tri et filtre par date d'événement (date_de_debut / datetime / post_date)
* + helpers de listes : groupes d'axes pour les filtres, posts épinglés.
*
* Activation sur un WP_Query / Timber::get_posts :
* 'thalim_event_date_order' => true
* 'thalim_event_date_filter' => ['from' => 'Y-m-d', 'to' => 'Y-m-d']
*/
// Event date ordering: COALESCE(date_de_debut, datetime, post_date)
// Activated by adding 'thalim_event_date_order' => true to WP_Query args.
add_filter('posts_join', function ($join, $query) {
if (!$query->get('thalim_event_date_order') && !$query->get('thalim_event_date_filter')) return $join;
global $wpdb;
$join .= " LEFT JOIN {$wpdb->postmeta} AS thalim_ed"
. " ON (thalim_ed.post_id = {$wpdb->posts}.ID"
. " AND thalim_ed.meta_key = 'date_de_debut') ";
$join .= " LEFT JOIN {$wpdb->postmeta} AS thalim_dt"
. " ON (thalim_dt.post_id = {$wpdb->posts}.ID"
. " AND thalim_dt.meta_key = 'datetime') ";
return $join;
}, 10, 2);
add_filter('posts_orderby', function ($orderby, $query) {
if (!$query->get('thalim_event_date_order')) return $orderby;
global $wpdb;
$valid = "IS NOT NULL AND %s != '' AND %s NOT LIKE '0000-00-00%%'";
return "CASE"
. " WHEN thalim_ed.meta_value " . sprintf($valid, 'thalim_ed.meta_value', 'thalim_ed.meta_value') . " THEN thalim_ed.meta_value"
. " WHEN thalim_dt.meta_value " . sprintf($valid, 'thalim_dt.meta_value', 'thalim_dt.meta_value') . " THEN thalim_dt.meta_value"
. " ELSE {$wpdb->posts}.post_date"
. " END DESC, {$wpdb->posts}.ID DESC"; // tiebreaker déterministe: sans lui, les dates ex-æquo paginent de façon instable entre les requêtes séparées (initial vs AJAX)
}, 10, 2);
// Event date range filter: uses same CASE logic as ordering so date_de_debut/datetime take priority over post_date.
// Activated by adding 'thalim_event_date_filter' => ['from' => $date_from, 'to' => $date_to] to WP_Query args.
add_filter('posts_where', function ($where, $query) {
$filter = $query->get('thalim_event_date_filter');
if (empty($filter) || (!isset($filter['from']) && !isset($filter['to']))) return $where;
global $wpdb;
$effective = "CASE"
. " WHEN thalim_ed.meta_value IS NOT NULL AND thalim_ed.meta_value != '' AND thalim_ed.meta_value NOT LIKE '0000-00-00%' THEN thalim_ed.meta_value"
. " WHEN thalim_dt.meta_value IS NOT NULL AND thalim_dt.meta_value != '' AND thalim_dt.meta_value NOT LIKE '0000-00-00%' THEN thalim_dt.meta_value"
. " ELSE {$wpdb->posts}.post_date"
. " END";
if (!empty($filter['from'])) {
$from = $wpdb->prepare('%s', $filter['from']);
$where .= " AND ({$effective}) >= {$from}";
}
if (!empty($filter['to'])) {
$to = $wpdb->prepare('%s', $filter['to'] . ' 23:59:59');
$where .= " AND ({$effective}) <= {$to}";
}
return $where;
}, 10, 2);
// Return the IDs of posts currently pinned ("épinglé dans la catégorie") in a given category.
// A pin is active if epingler_dans_la_categorie == 1 AND date_de_fin_depinglage is empty/0000-00-00/future.
// Shared by category.php (pull them out of the main flow) and the AJAX handler (exclude them from
// pagination) so both sides stay in sync — a mismatch shifts the page boundary and dupes posts.
function thalim_get_active_pinned_ids( $category_id ) {
if ( ! $category_id ) return [];
$today = date( 'Y-m-d' );
$pinned_query = new WP_Query([
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => -1,
'fields' => 'ids',
'no_found_rows' => true,
'tax_query' => [[
'taxonomy' => 'category',
'field' => 'term_id',
'terms' => [ $category_id ],
'include_children' => false,
]],
'meta_query' => [[
'key' => 'epingler_dans_la_categorie',
'value' => '1',
]],
]);
$pinned_ids = [];
foreach ( $pinned_query->posts as $pid ) {
$fin = get_post_meta( $pid, 'date_de_fin_depinglage', true );
if ( empty( $fin ) || $fin === '0000-00-00' || $fin >= $today ) {
$pinned_ids[] = $pid;
}
}
return $pinned_ids;
}
// ── Axes thématiques groupés pour les filtres ──────────────────
// Retourne un tableau de groupes triés par période (plus récent en premier,
// "passés" toujours en dernier). Chaque terme contient id, name, ordre.
function thalim_get_axes_filter_groups() {
$terms = get_terms( [ 'taxonomy' => 'axe_thematique', 'hide_empty' => false ] );
$axes_map = [];
foreach ( $terms as $term ) {
$debut = trim( get_term_meta( $term->term_id, 'annee_debut', true ) );
$fin = trim( get_term_meta( $term->term_id, 'annee_fin', true ) );
if ( $debut && $fin ) {
$key = $debut . '-' . $fin;
$label = $debut . ' ' . $fin;
} else {
$key = 'passes';
$label = 'Axes antérieurs';
}
if ( ! isset( $axes_map[ $key ] ) ) {
$axes_map[ $key ] = [ 'label' => $label, 'debut' => intval( $debut ), 'terms' => [] ];
}
$ordre = trim( get_term_meta( $term->term_id, 'ordre_daffichage', true ) );
$axes_map[ $key ]['terms'][] = [
'id' => $term->term_id,
'name' => $term->name,
'ordre' => $ordre !== '' ? intval( $ordre ) : null,
'href' => get_term_link( $term ),
];
}
// Tri des groupes : plus récent en premier, passés toujours en dernier
uasort( $axes_map, function ( $a, $b ) {
if ( $a['label'] === 'Axes antérieurs' ) return 1;
if ( $b['label'] === 'Axes antérieurs' ) return -1;
return $b['debut'] - $a['debut'];
} );
// Tri des termes dans chaque groupe : ordre_daffichage d'abord, puis alphabétique
foreach ( $axes_map as &$group ) {
usort( $group['terms'], function ( $a, $b ) {
$a_has = $a['ordre'] !== null;
$b_has = $b['ordre'] !== null;
if ( $a_has && $b_has ) return $a['ordre'] - $b['ordre'];
if ( $a_has ) return -1;
if ( $b_has ) return 1;
return strcmp( $a['name'], $b['name'] );
} );
}
unset( $group );
return array_values( $axes_map );
}

120
inc/i18n.php Normal file
View File

@@ -0,0 +1,120 @@
<?php
/**
* Multilingue maison (remplace Polylang).
*
* Détection de langue par préfixe /en/, champs bilingues "FR // EN",
* préfixage des URLs internes, traduction des noms de catégories,
* filtres Twig (|bilingual, |en_url, |cat_name) et sélecteur de langue.
*/
// ── Language detection (replaces Polylang) ───────────────────────
define( 'THALIM_ORIGINAL_URI', $_SERVER['REQUEST_URI'] ?? '/' );
function thalim_current_language(): string {
if ( isset( $GLOBALS['thalim_lang_override'] ) ) return $GLOBALS['thalim_lang_override'];
$uri = THALIM_ORIGINAL_URI;
return ( strpos( $uri, '/en/' ) === 0 || rtrim( $uri, '/' ) === '/en' ) ? 'en' : 'fr';
}
// Strip /en/ prefix before WordPress resolves the URL
add_filter( 'do_parse_request', function ( $do_parse ) {
$uri = THALIM_ORIGINAL_URI;
if ( strpos( $uri, '/en/' ) === 0 ) {
$_SERVER['REQUEST_URI'] = substr( $uri, 3 );
} elseif ( rtrim( $uri, '/' ) === '/en' ) {
$_SERVER['REQUEST_URI'] = '/';
}
return $do_parse;
}, 1 );
// Prevent WP canonical redirect from overriding /en/ URLs
add_filter( 'redirect_canonical', function ( $redirect ) {
if ( strpos( THALIM_ORIGINAL_URI, '/en/' ) === 0 ) return false;
return $redirect;
}, 10, 2 );
// Split "FR // EN" bilingual fields — returns the right part for the given lang
function thalim_bilingual( string $value, string $lang = null ): string {
if ( $lang === null ) $lang = thalim_current_language();
if ( strpos( $value, ' // ' ) === false ) return $value;
$parts = explode( ' // ', $value, 2 );
return ( $lang === 'en' && trim( $parts[1] ?? '' ) !== '' ) ? trim( $parts[1] ) : trim( $parts[0] );
}
// Prepend /en to any internal URL when current language is EN.
// Idempotent: safe to call multiple times on the same URL.
function thalim_en_url( string $url ): string {
if ( thalim_current_language() !== 'en' ) return $url;
$home = rtrim( home_url(), '/' );
$path = substr( $url, strlen( $home ) );
if ( str_starts_with( $path, '/en/' ) || $path === '/en' ) return $url;
return $home . '/en' . $path;
}
// Auto-prefix all WP-generated internal URLs in EN mode.
// Safe on admin: THALIM_ORIGINAL_URI starts with /wp-admin/ there,
// so thalim_current_language() returns 'fr' and thalim_en_url() is a no-op.
add_filter( 'term_link', 'thalim_en_url' ); // get_term_link(), get_category_link(), get_tag_link()
add_filter( 'post_link', 'thalim_en_url' ); // get_permalink() on regular posts
add_filter( 'page_link', 'thalim_en_url' ); // get_permalink() on pages
add_filter( 'post_type_link', 'thalim_en_url' ); // get_permalink() on CPTs
add_filter( 'author_link', 'thalim_en_url' ); // get_author_posts_url()
// Return the translated category name if viewing in EN and titre_anglais is set.
// Accepts a WP_Term, Timber\Term, term_id (int), or a name string + term_id.
function thalim_cat_name( $cat, string $lang = null ): string {
if ( $lang === null ) $lang = thalim_current_language();
if ( is_object( $cat ) ) {
$term_id = $cat->term_id ?? ( $cat->id ?? 0 );
$fallback = $cat->name;
} elseif ( is_numeric( $cat ) ) {
$term_id = (int) $cat;
$term = get_term( $term_id, 'category' );
$fallback = $term && ! is_wp_error( $term ) ? $term->name : (string) $cat;
} else {
return (string) $cat;
}
if ( $lang !== 'en' ) return $fallback;
$en = get_term_meta( $term_id, 'titre_anglais', true );
return ( $en !== '' && $en !== false ) ? $en : $fallback;
}
// Register bilingual and en_url as Twig filters
add_filter( 'timber/twig', function ( $twig ) {
$twig->addFilter( new \Twig\TwigFilter( 'bilingual', 'thalim_bilingual' ) );
$twig->addFilter( new \Twig\TwigFilter( 'en_url', 'thalim_en_url' ) );
$twig->addFilter( new \Twig\TwigFilter( 'cat_name', 'thalim_cat_name' ) );
return $twig;
} );
// Language switcher data (replaces pll_the_languages)
// Output matches the structure header.twig expects: slug, url, current_lang
function thalim_language_switcher(): array {
$uri = THALIM_ORIGINAL_URI;
$path = parse_url( $uri, PHP_URL_PATH ) ?? '/';
$query = ( $q = parse_url( $uri, PHP_URL_QUERY ) ) ? '?' . $q : '';
$is_en = thalim_current_language() === 'en';
$fr_path = $is_en ? ( substr( $path, 3 ) ?: '/' ) : $path;
$en_path = $is_en ? $path : '/en' . $path;
return [
'fr' => [ 'slug' => 'fr', 'url' => home_url( $fr_path ) . $query, 'current_lang' => ! $is_en ],
'en' => [ 'slug' => 'en', 'url' => home_url( $en_path ) . $query, 'current_lang' => $is_en ],
];
}
// Apply bilingual split to the browser tab title + translate category names
add_filter('document_title_parts', function ($title_parts) {
if (!empty($title_parts['title'])) {
$title_parts['title'] = thalim_bilingual($title_parts['title']);
// On category archives, replace the title with the translated category name
if ( is_category() ) {
$cat = get_queried_object();
if ( $cat ) {
$title_parts['title'] = thalim_cat_name( $cat );
}
}
}
return $title_parts;
});

View File

@@ -135,6 +135,12 @@ function thalim_get_membres_groups() {
// Pre-build member data for all relevant users (cache by ID)
$member_cache = [];
$all_users = array_merge( $users, $direction_users );
// Précharge les usermeta de tout le monde en une requête : chaque
// thalim_build_membre_data() fait ensuite ses get_user_meta sur le cache
// (rôles, statuts, avatar local, domaines…) au lieu d'une requête par champ.
update_meta_cache( 'user', wp_list_pluck( $all_users, 'ID' ) );
foreach ( $all_users as $user ) {
if ( ! isset( $member_cache[ $user->ID ] ) ) {
$member_cache[ $user->ID ] = thalim_build_membre_data( $user );

View File

@@ -11,8 +11,6 @@ function thalim_get_card_data($post_id) {
$data = [
'card_image' => null,
'card_membres' => [],
'card_axes' => [],
'card_etiquettes' => [],
'parent_slug' => '',
'card_category_name' => '',
'card_category_url' => '',
@@ -23,12 +21,13 @@ function thalim_get_card_data($post_id) {
];
// Category-based date formatting:
// - Séminaire (cat 11, not cat 12): "Du X au Y" from first/last linked séance dates
// - Ouvrage (cat 15): year only — includes the post.date fallback (overrides Twig default d/m/Y)
// - Séminaire (hors séances): "Du X au Y" from first/last linked séance dates
// - Ouvrage: year only — includes the post.date fallback (overrides Twig default d/m/Y)
// - Default: date_de_debut > datetime > (empty → Twig falls back to post.date('d/m/Y'))
$seance_cat = thalim_cat_id('seance');
$cat_ids = wp_get_post_categories($post_id);
$is_seminaire = in_array(11, $cat_ids, true) && !in_array(12, $cat_ids, true);
$is_ouvrage = in_array(15, $cat_ids, true);
$is_seminaire = in_array(thalim_cat_id('seminaires'), $cat_ids, true) && !in_array($seance_cat, $cat_ids, true);
$is_ouvrage = in_array(thalim_cat_id('ouvrages'), $cat_ids, true);
if ($is_seminaire) {
// Aggregate timestamps from linked séances (Pods `seances` meta = array of post IDs)
@@ -86,10 +85,10 @@ function thalim_get_card_data($post_id) {
// Resolve top-level parent category slug for color theming and direct category name for display
$categories = wp_get_post_categories($post_id, ['fields' => 'all']);
$excluded_ids = [12, 31];
$excluded_ids = array_filter([ $seance_cat, thalim_cat_id('non-classe') ]);
$is_seance = false;
foreach ($categories as $cat) {
if ($cat->term_id === 12) { $is_seance = true; }
if ($cat->term_id === $seance_cat) { $is_seance = true; }
}
foreach ($categories as $cat) {
if (in_array($cat->term_id, $excluded_ids)) continue;
@@ -107,28 +106,21 @@ function thalim_get_card_data($post_id) {
// Séances de séminaire: link to parent séminaire with hash, derive color from parent's categories
if ($is_seance) {
// Always show the category label for séances even though cat 12 is excluded from color resolution
// Always show the category label for séances even though the séance
// category is excluded from color resolution
if (!$data['card_category_name']) {
$seance_cat = get_category(12);
if ($seance_cat) {
$data['card_category_name'] = thalim_cat_name($seance_cat);
$data['card_category_url'] = get_category_link(12);
$seance_term = get_category($seance_cat);
if ($seance_term) {
$data['card_category_name'] = thalim_cat_name($seance_term);
$data['card_category_url'] = get_category_link($seance_cat);
}
}
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) $post_id
));
$parent_id = thalim_get_seance_parent_id($post_id);
if ($parent_id) {
$data['card_link'] = get_permalink((int) $parent_id) . '#seance-' . $post_id;
$data['card_link'] = get_permalink($parent_id) . '#seance-' . $post_id;
// Derive color from parent séminaire's categories if not already set
if (!$data['parent_slug']) {
foreach (wp_get_post_categories((int) $parent_id, ['fields' => 'all']) as $cat) {
foreach (wp_get_post_categories($parent_id, ['fields' => 'all']) as $cat) {
if (in_array($cat->term_id, $excluded_ids)) continue;
$ancestor_ids = get_ancestors($cat->term_id, 'category');
$root = !empty($ancestor_ids) ? get_category(end($ancestor_ids)) : $cat;
@@ -186,32 +178,44 @@ function thalim_get_card_data($post_id) {
}
}
// Axes thématiques (post IDs → titles)
$axe_ids = get_post_meta($post_id, 'axes_thematiques', false);
foreach ($axe_ids as $axe_id) {
$axe = get_post($axe_id);
if ($axe) {
$data['card_axes'][] = $axe->post_title;
}
}
// Etiquettes (post IDs → titles)
$tag_ids = get_post_meta($post_id, 'etiquettes', false);
foreach ($tag_ids as $tag_id) {
$tag_post = get_post($tag_id);
if ($tag_post) {
$data['card_etiquettes'][] = $tag_post->post_title;
}
}
return $data;
}
/**
* Build card data map for a collection of posts.
* Returns an array keyed by post ID.
*
* Précharge en lot les caches meta/termes des posts et les utilisateurs
* référencés (membres/autre_membres) pour éviter les requêtes N+1 de
* thalim_get_card_data() (une grille de 12 cartes passait par des dizaines
* de get_post_meta/get_userdata individuels).
*/
function thalim_get_cards_data($posts) {
$ids = [];
foreach ($posts as $post) {
$ids[] = $post->ID;
}
if ($ids) {
// Meta + termes en 2 requêtes pour tout le lot
update_meta_cache('post', $ids);
update_object_term_cache($ids, 'post');
// Utilisateurs référencés par les cartes (lecture servie par le cache meta)
$user_ids = [];
foreach ($ids as $pid) {
foreach (['membres', 'autre_membres'] as $key) {
foreach (get_post_meta($pid, $key, false) as $uid) {
$user_ids[] = (int) $uid;
}
}
}
$user_ids = array_values(array_unique(array_filter($user_ids)));
if ($user_ids) {
cache_users($user_ids);
}
}
$cards = [];
foreach ($posts as $post) {
$cards[$post->ID] = thalim_get_card_data($post->ID);

52
inc/seance-helpers.php Normal file
View File

@@ -0,0 +1,52 @@
<?php
/**
* Séances de séminaire (cat « seance-de-seminaire ») :
* résolution mémoïsée séance → séminaire parent, et redirection des
* permaliens de séance vers le parent avec ancre #seance-{ID}.
*/
/**
* Renvoie l'ID du séminaire parent (publié) qui liste cette séance dans son
* champ Pods `seances`, ou 0. Mémoïsé par requête : la même résolution est
* utilisée par les cards, l'agenda, l'AJAX et la redirection.
*/
function thalim_get_seance_parent_id( int $seance_id ): int {
static $cache = [];
if ( ! isset( $cache[ $seance_id ] ) ) {
global $wpdb;
$cache[ $seance_id ] = (int) $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 $cache[ $seance_id ];
}
/**
* Lien public d'une séance : permalien du séminaire parent + #seance-{ID},
* ou permalien de la séance elle-même si aucun parent n'est trouvé.
*/
function thalim_get_seance_link( int $seance_id ): string {
$parent_id = thalim_get_seance_parent_id( $seance_id );
return $parent_id
? get_permalink( $parent_id ) . '#seance-' . $seance_id
: (string) get_permalink( $seance_id );
}
// Séance de séminaire: redirect to parent séminaire with #seance-{ID} anchor
add_action( 'template_redirect', function() {
if ( ! is_single() ) return;
if ( ! has_category( thalim_cat_id( 'seance' ) ) ) return;
$seance_id = get_the_ID();
$parent_id = thalim_get_seance_parent_id( $seance_id );
if ( $parent_id ) {
wp_redirect( get_permalink( $parent_id ) . '#seance-' . $seance_id, 301 );
exit;
}
} );

View File

@@ -40,14 +40,16 @@ function thalim_get_single_data($post_id) {
$data = [
// Text fields
'sous_titre' => thalim_bilingual( get_post_meta($post_id, 'sous-titre', true) ?: '', $lang ),
'reference_bibliographique' => get_post_meta($post_id, 'reference_bibliographique', true) ?: '',
// wp_kses_post: rendus en |raw dans single.twig (autoescape off) et
// éditables par les contributeurs listés en membres → filtrer le HTML.
'reference_bibliographique' => wp_kses_post( get_post_meta($post_id, 'reference_bibliographique', true) ?: '' ),
'editeur' => get_post_meta($post_id, 'editeur', true) ?: '',
'journal' => get_post_meta($post_id, 'journal', true) ?: '',
'lieu' => thalim_bilingual( get_post_meta($post_id, 'lieu', true) ?: '', $lang ),
'adresse' => nl2br( esc_html( get_post_meta($post_id, 'adresse', true) ?: '' ) ),
'autrepersonnes' => get_post_meta($post_id, 'autrepersonnes', true) ?: '',
'autre_autrepersonnes' => get_post_meta($post_id, 'autre_autrepersonnes', true) ?: '',
'body_en' => apply_filters( 'the_content', get_post_meta($post_id, 'body_en', true) ?: '' ),
'body_en' => apply_filters( 'the_content', wp_kses_post( get_post_meta($post_id, 'body_en', true) ?: '' ) ),
// Dates (formatted for display)
'datetime' => thalim_format_date(get_post_meta($post_id, 'datetime', true), $lang),
@@ -110,10 +112,10 @@ function thalim_get_single_data($post_id) {
if ($ts_debut) $data['date_debut_ymd'] = date('Y-m-d', $ts_debut);
if ($ts_fin) $data['date_fin_ymd'] = date('Y-m-d', $ts_fin);
// Ouvrages (cat 15): override display to year only — raw timestamps and
// Ouvrages: override display to year only — raw timestamps and
// *_ymd fields stay full-precision so sorting/filtering on index pages
// (`thalim_event_date_order`) keeps working.
if (in_array(15, wp_get_post_categories($post_id), true)) {
if (in_array(thalim_cat_id('ouvrages'), wp_get_post_categories($post_id), true)) {
$data['date_de_debut'] = thalim_format_date($raw_debut, $lang, 'Y');
$data['date_de_fin'] = thalim_format_date($raw_fin, $lang, 'Y');
$data['datetime'] = thalim_format_date(get_post_meta($post_id, 'datetime', true), $lang, 'Y');
@@ -138,7 +140,7 @@ function thalim_get_single_data($post_id) {
// --- Category hierarchy for breadcrumb and color ---
$categories = wp_get_post_categories($post_id, ['fields' => 'all']);
$excluded_ids = [12, 31];
$excluded_ids = array_filter([ thalim_cat_id('seance'), thalim_cat_id('non-classe') ]);
foreach ($categories as $cat) {
if (in_array($cat->term_id, $excluded_ids)) continue;
$ancestor_ids = get_ancestors($cat->term_id, 'category');
@@ -256,7 +258,6 @@ function thalim_get_single_data($post_id) {
'post_type' => 'post',
'post__in' => array_map('intval', $related_ids),
'posts_per_page' => -1,
'lang' => '',
]);
}
@@ -272,7 +273,6 @@ function thalim_get_single_data($post_id) {
'orderby' => 'meta_value',
'meta_key' => 'date_de_debut',
'order' => 'ASC',
'lang' => '',
'post_status' => ['publish', 'future'],
]);
$now = time();
@@ -303,7 +303,7 @@ function thalim_get_single_data($post_id) {
'heure_de_fin' => substr( get_post_meta($seance->ID, 'heure_de_fin', true) ?: '', 0, 5 ),
'lieu' => thalim_bilingual( get_post_meta($seance->ID, 'lieu', true) ?: '', $lang ),
'adresse' => nl2br( esc_html( get_post_meta($seance->ID, 'adresse', true) ?: '' ) ),
'body_en' => apply_filters( 'the_content', get_post_meta($seance->ID, 'body_en', true) ?: '' ),
'body_en' => apply_filters( 'the_content', wp_kses_post( get_post_meta($seance->ID, 'body_en', true) ?: '' ) ),
'intervenants' => [],
'images' => [],
'documents' => [],
@@ -372,7 +372,6 @@ function thalim_get_single_data($post_id) {
'post_type' => 'post',
'post__in' => array_map('intval', $s_related_ids),
'posts_per_page' => -1,
'lang' => '',
]);
}