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

2
.gitignore vendored
View File

@@ -1,5 +1,5 @@
# --- Dépendances Composer --- # --- Dépendances Composer ---
vendor/ /vendor/
# --- Artefacts SASS --- # --- Artefacts SASS ---
.sass-cache/ .sass-cache/

22
assets/vendor/iconoir/iconoir.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -4,8 +4,8 @@ $category = get_queried_object();
$context['category'] = Timber::get_term($category); $context['category'] = Timber::get_term($category);
$context['cards'] = []; $context['cards'] = [];
$excluded_ids = [12, 31]; // Séance de séminaire, Non classé // Séance de séminaire, Non classé (+ Vie du labo pour les non-connectés)
if ( ! is_user_logged_in() ) $excluded_ids[] = 9; // Vie du labo $excluded_ids = thalim_archive_excluded_cat_ids();
// Parent category slug for color theming // Parent category slug for color theming
if ($category->parent) { if ($category->parent) {
@@ -54,68 +54,25 @@ $all_cats = get_categories([
'exclude' => $excluded_ids, 'exclude' => $excluded_ids,
]); ]);
$filter_parents = []; // Liens de filtre : navigation vers la page de catégorie, en conservant axe/dates
foreach ($all_cats as $cat) { $make_cat_link = function ($cat) use ($filter_query) {
if ($cat->parent == 0) { $link = get_category_link($cat->term_id);
$link = get_category_link($cat->term_id); return $filter_query ? $link . '?' . $filter_query : $link;
if ($filter_query) $link .= '?' . $filter_query; };
$filter_parents[] = [ $context['filter_parents'] = thalim_archive_filter_parents($all_cats, $make_cat_link);
'id' => $cat->term_id,
'name' => thalim_cat_name($cat),
'slug' => $cat->slug,
'link' => $link,
];
}
}
$context['filter_parents'] = $filter_parents;
// Children of active rubrique for catégorie filter (with links) // Children of active rubrique for catégorie filter (with links)
$active_rubrique_id = $context['active_rubrique']; $active_rubrique_id = $context['active_rubrique'];
$is_direct = (bool) get_query_var('thalim_direct_posts'); $is_direct = (bool) get_query_var('thalim_direct_posts');
$lang = thalim_current_language();
$filter_categories = []; $filter_categories = thalim_archive_filter_children($all_cats, $active_rubrique_id, $make_cat_link);
foreach ($all_cats as $cat) {
if ($cat->parent == $active_rubrique_id) {
$link = get_category_link($cat->term_id);
if ($filter_query) $link .= '?' . $filter_query;
$filter_categories[] = [
'id' => $cat->term_id,
'name' => thalim_cat_name($cat),
'slug' => $cat->slug,
'link' => $link,
];
}
}
// Add "Autres" entry if the active rubrique has posts directly assigned to it // Add "Autres" entry if the active rubrique has posts directly assigned to it
if ($is_direct) { $has_direct_posts = $is_direct ?: thalim_rubrique_has_direct_posts($active_rubrique_id);
$has_direct_posts = true;
} else {
$direct_check = new WP_Query([
'post_type' => 'post',
'posts_per_page' => 1,
'fields' => 'ids',
'no_found_rows' => true,
'lang' => '',
'tax_query' => [[
'taxonomy' => 'category',
'field' => 'term_id',
'terms' => [$active_rubrique_id],
'include_children' => false,
]],
]);
$has_direct_posts = $direct_check->have_posts();
}
if ($has_direct_posts && !empty($filter_categories)) { if ($has_direct_posts && !empty($filter_categories)) {
$autres_link = trailingslashit(get_category_link($active_rubrique_id)) . 'autres/'; $autres_link = trailingslashit(get_category_link($active_rubrique_id)) . 'autres/';
if ($filter_query) $autres_link .= '?' . $filter_query; if ($filter_query) $autres_link .= '?' . $filter_query;
$filter_categories[] = [ $filter_categories[] = thalim_archive_autres_entry($autres_link);
'id' => 'autres',
'name' => $lang === 'en' ? 'Other' : 'Autres',
'slug' => 'autres',
'link' => $autres_link,
];
} }
$context['filter_categories'] = $filter_categories; $context['filter_categories'] = $filter_categories;
@@ -135,23 +92,23 @@ $children = get_categories([
'exclude' => $excluded_ids, 'exclude' => $excluded_ids,
]); ]);
// Ordre personnalisé des sous-catégories (term_id => position). // Ordre personnalisé des sous-catégories (slug => position).
// Les termes absents du tableau sont placés en dernier (position 999). // Les termes absents du tableau sont placés en dernier (position 999).
$subcategory_order = [ $subcategory_order = [
// Publications et productions (parent: 4) // Publications et productions
15 => 0, // Ouvrages 'ouvrages' => 0,
16 => 1, // Articles 'articles' => 1,
65 => 2, // Revues et collections 'revues-et-collections' => 2,
17 => 3, // Multimédia 'multimedia' => 3,
// Activités (parent: 3) // Manifestations scientifiques
11 => 0, // Séminaires 'seminaires' => 0,
10 => 1, // Colloques et journées d'études 'colloques-et-journees-detudes' => 1,
13 => 2, // Communications 'communications' => 2,
14 => 3, // Soutenances 'soutenances' => 3,
]; ];
usort($children, function($a, $b) use ($subcategory_order) { usort($children, function($a, $b) use ($subcategory_order) {
$pos_a = $subcategory_order[$a->term_id] ?? 999; $pos_a = $subcategory_order[$a->slug] ?? 999;
$pos_b = $subcategory_order[$b->term_id] ?? 999; $pos_b = $subcategory_order[$b->slug] ?? 999;
return $pos_a - $pos_b; return $pos_a - $pos_b;
}); });
@@ -188,7 +145,6 @@ if (!$is_direct && !empty($children)) {
'posts_per_page' => 3, 'posts_per_page' => 3,
'orderby' => 'date', 'orderby' => 'date',
'order' => 'DESC', 'order' => 'DESC',
'lang' => '',
'thalim_event_date_order' => true, 'thalim_event_date_order' => true,
], $extra_query_args); ], $extra_query_args);
$posts = $sort_with_pinned( Timber::get_posts($query_args) ); $posts = $sort_with_pinned( Timber::get_posts($query_args) );
@@ -212,7 +168,6 @@ if (!$is_direct && !empty($children)) {
'posts_per_page' => 3, 'posts_per_page' => 3,
'orderby' => 'date', 'orderby' => 'date',
'order' => 'DESC', 'order' => 'DESC',
'lang' => '',
'thalim_event_date_order' => true, 'thalim_event_date_order' => true,
], $extra_query_args); ], $extra_query_args);
$direct_posts = $sort_with_pinned( Timber::get_posts($direct_query_args) ); $direct_posts = $sort_with_pinned( Timber::get_posts($direct_query_args) );
@@ -244,7 +199,6 @@ if (!$is_direct && !empty($children)) {
'posts_per_page' => 12, 'posts_per_page' => 12,
'orderby' => 'date', 'orderby' => 'date',
'order' => 'DESC', 'order' => 'DESC',
'lang' => '',
'thalim_event_date_order' => true, 'thalim_event_date_order' => true,
], $extra_query_args); ], $extra_query_args);
if ( $pinned_ids ) { if ( $pinned_ids ) {
@@ -259,7 +213,6 @@ if (!$is_direct && !empty($children)) {
'post__in' => $pinned_ids, 'post__in' => $pinned_ids,
'orderby' => 'post__in', 'orderby' => 'post__in',
'posts_per_page' => -1, 'posts_per_page' => -1,
'lang' => '',
], $extra_query_args ) ) : []; ], $extra_query_args ) ) : [];
$context['cards'] = thalim_get_cards_data($pinned_posts) + thalim_get_cards_data($posts); $context['cards'] = thalim_get_cards_data($pinned_posts) + thalim_get_cards_data($posts);
@@ -288,6 +241,6 @@ $context['agenda_toggle_url'] = add_query_arg( $toggle_params, $toggle_base );
$cat_lang = thalim_current_language(); $cat_lang = thalim_current_language();
$pres_fr = get_term_meta( $category->term_id, 'presentation', true ) ?: ''; $pres_fr = get_term_meta( $category->term_id, 'presentation', true ) ?: '';
$pres_en = get_term_meta( $category->term_id, 'presentation_en', true ) ?: ''; $pres_en = get_term_meta( $category->term_id, 'presentation_en', true ) ?: '';
$context['term_presentation'] = wpautop( ( $cat_lang === 'en' && $pres_en ) ? $pres_en : $pres_fr ); $context['term_presentation'] = wpautop( wp_kses_post( ( $cat_lang === 'en' && $pres_en ) ? $pres_en : $pres_fr ) );
Timber::render('category.twig', $context); Timber::render('category.twig', $context);

File diff suppressed because it is too large Load Diff

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) ? thalim_bilingual(get_user_meta($user_id, 'affiliation_autre', true) ?: '', $lang)
: $v; : $v;
})(), })(),
'bio' => wpautop( make_clickable( get_user_meta($user_id, 'biographie', true) ?: '' ) ), // wp_kses_post: ces champs sont éditables par les contributeurs (profil)
'bio_en' => wpautop( make_clickable( get_user_meta($user_id, 'biographie_en', true) ?: '' ) ), // 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_tags' => $domaines_tags,
'domaines' => wpautop( make_clickable( get_user_meta($user_id, 'autres_domaines_de_recherches', true) ?: '' ) ), 'domaines' => wpautop( make_clickable( wp_kses_post( 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) ?: '' ) ), 'domaines_en' => wpautop( make_clickable( wp_kses_post( get_user_meta($user_id, 'autres_domaines_de_recherches_en', true) ?: '' ) ) ),
'recherches' => wpautop( get_user_meta($user_id, 'recherches_en_cours', true) ?: '' ), 'recherches' => wpautop( wp_kses_post( get_user_meta($user_id, 'recherches_en_cours', true) ?: '' ) ),
'recherches_en' => wpautop( get_user_meta($user_id, 'recherches_en_cours_en', true) ?: '' ), 'recherches_en' => wpautop( wp_kses_post( get_user_meta($user_id, 'recherches_en_cours_en', true) ?: '' ) ),
'axes' => $axes, 'axes' => $axes,
'titre_these' => thalim_bilingual(get_user_meta($user_id, 'titre_de_these', true) ?: '', $lang), '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) ?: '', 'date_soutenance' => get_user_meta($user_id, 'date_de_soutenance', true) ?: '',
'directeur_thalim'=> $directeur_thalim, 'directeur_thalim'=> $directeur_thalim,
'autre_directeur' => get_user_meta($user_id, 'autre_directeur_de_these', true) ?: '', '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' => wpautop( wp_kses_post( 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_en' => wpautop( wp_kses_post( get_user_meta($user_id, 'resume_de_la_these_en', true) ?: '' ) ),
'email' => $show_email ? $user->user_email : '', 'email' => $show_email ? $user->user_email : '',
'liens_externes' => $liens_externes, 'liens_externes' => $liens_externes,
'documents' => $documents, 'documents' => $documents,
@@ -148,7 +150,8 @@ function thalim_get_author_data($user_id) {
* Returns an array sorted by post count (descending). * Returns an array sorted by post count (descending).
*/ */
function thalim_get_author_posts_by_category($user_id) { 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(); $lang = thalim_current_language();
$posts = Timber::get_posts([ $posts = Timber::get_posts([
@@ -166,7 +169,6 @@ function thalim_get_author_posts_by_category($user_id) {
], ],
], ],
'thalim_event_date_order' => true, 'thalim_event_date_order' => true,
'lang' => '',
]); ]);
$groups = []; $groups = [];
@@ -205,29 +207,28 @@ function thalim_get_author_posts_by_category($user_id) {
$groups[$cat_id]['posts'][] = $post; $groups[$cat_id]['posts'][] = $post;
} }
// Séances de séminaire — dedicated group. Posts in cat 12 where the member // Séances de séminaire — dedicated group. Posts in the séance category
// is listed in `membres`/`autre_membres`. Cards use the parent séminaire // where the member is listed in `membres`/`autre_membres`. Cards use the
// permalink with a #seance-{ID} hash (see thalim_get_card_data). // parent séminaire permalink with a #seance-{ID} hash (see thalim_get_card_data).
$seances = Timber::get_posts([ $seances = Timber::get_posts([
'post_type' => 'post', 'post_type' => 'post',
'posts_per_page' => -1, 'posts_per_page' => -1,
'category__in' => [12], 'category__in' => [ $seance_cat ],
'meta_query' => [ 'meta_query' => [
'relation' => 'OR', 'relation' => 'OR',
[ 'key' => 'membres', 'value' => $user_id ], [ 'key' => 'membres', 'value' => $user_id ],
[ 'key' => 'autre_membres', 'value' => $user_id ], [ 'key' => 'autre_membres', 'value' => $user_id ],
], ],
'thalim_event_date_order' => true, 'thalim_event_date_order' => true,
'lang' => '',
]); ]);
if (count($seances) > 0) { if (count($seances) > 0) {
$seance_cat = get_term(12, 'category'); $seance_term = get_term($seance_cat, 'category');
$groups[12] = [ $groups[$seance_cat] = [
'cat_id' => 12, 'cat_id' => $seance_cat,
'cat_name' => $seance_cat && !is_wp_error($seance_cat) 'cat_name' => $seance_term && !is_wp_error($seance_term)
? thalim_cat_name($seance_cat, $lang) ? thalim_cat_name($seance_term, $lang)
: ($lang === 'en' ? 'Seminar sessions' : 'Séances de séminaire'), : ($lang === 'en' ? 'Seminar sessions' : 'Séances de séminaire'),
'cat_url' => get_category_link(12), 'cat_url' => get_category_link($seance_cat),
'posts' => $seances, '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) // Pre-build member data for all relevant users (cache by ID)
$member_cache = []; $member_cache = [];
$all_users = array_merge( $users, $direction_users ); $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 ) { foreach ( $all_users as $user ) {
if ( ! isset( $member_cache[ $user->ID ] ) ) { if ( ! isset( $member_cache[ $user->ID ] ) ) {
$member_cache[ $user->ID ] = thalim_build_membre_data( $user ); $member_cache[ $user->ID ] = thalim_build_membre_data( $user );

View File

@@ -11,8 +11,6 @@ function thalim_get_card_data($post_id) {
$data = [ $data = [
'card_image' => null, 'card_image' => null,
'card_membres' => [], 'card_membres' => [],
'card_axes' => [],
'card_etiquettes' => [],
'parent_slug' => '', 'parent_slug' => '',
'card_category_name' => '', 'card_category_name' => '',
'card_category_url' => '', 'card_category_url' => '',
@@ -23,12 +21,13 @@ function thalim_get_card_data($post_id) {
]; ];
// Category-based date formatting: // Category-based date formatting:
// - Séminaire (cat 11, not cat 12): "Du X au Y" from first/last linked séance dates // - Séminaire (hors séances): "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) // - 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')) // - 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); $cat_ids = wp_get_post_categories($post_id);
$is_seminaire = in_array(11, $cat_ids, true) && !in_array(12, $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(15, $cat_ids, true); $is_ouvrage = in_array(thalim_cat_id('ouvrages'), $cat_ids, true);
if ($is_seminaire) { if ($is_seminaire) {
// Aggregate timestamps from linked séances (Pods `seances` meta = array of post IDs) // 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 // Resolve top-level parent category slug for color theming and direct category name for display
$categories = wp_get_post_categories($post_id, ['fields' => 'all']); $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; $is_seance = false;
foreach ($categories as $cat) { 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) { foreach ($categories as $cat) {
if (in_array($cat->term_id, $excluded_ids)) continue; 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 // Séances de séminaire: link to parent séminaire with hash, derive color from parent's categories
if ($is_seance) { 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']) { if (!$data['card_category_name']) {
$seance_cat = get_category(12); $seance_term = get_category($seance_cat);
if ($seance_cat) { if ($seance_term) {
$data['card_category_name'] = thalim_cat_name($seance_cat); $data['card_category_name'] = thalim_cat_name($seance_term);
$data['card_category_url'] = get_category_link(12); $data['card_category_url'] = get_category_link($seance_cat);
} }
} }
global $wpdb; $parent_id = thalim_get_seance_parent_id($post_id);
$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
));
if ($parent_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 // Derive color from parent séminaire's categories if not already set
if (!$data['parent_slug']) { 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; if (in_array($cat->term_id, $excluded_ids)) continue;
$ancestor_ids = get_ancestors($cat->term_id, 'category'); $ancestor_ids = get_ancestors($cat->term_id, 'category');
$root = !empty($ancestor_ids) ? get_category(end($ancestor_ids)) : $cat; $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; return $data;
} }
/** /**
* Build card data map for a collection of posts. * Build card data map for a collection of posts.
* Returns an array keyed by post ID. * 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) { 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 = []; $cards = [];
foreach ($posts as $post) { foreach ($posts as $post) {
$cards[$post->ID] = thalim_get_card_data($post->ID); $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 = [ $data = [
// Text fields // Text fields
'sous_titre' => thalim_bilingual( get_post_meta($post_id, 'sous-titre', true) ?: '', $lang ), '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) ?: '', 'editeur' => get_post_meta($post_id, 'editeur', true) ?: '',
'journal' => get_post_meta($post_id, 'journal', true) ?: '', 'journal' => get_post_meta($post_id, 'journal', true) ?: '',
'lieu' => thalim_bilingual( get_post_meta($post_id, 'lieu', true) ?: '', $lang ), 'lieu' => thalim_bilingual( get_post_meta($post_id, 'lieu', true) ?: '', $lang ),
'adresse' => nl2br( esc_html( get_post_meta($post_id, 'adresse', true) ?: '' ) ), 'adresse' => nl2br( esc_html( get_post_meta($post_id, 'adresse', true) ?: '' ) ),
'autrepersonnes' => get_post_meta($post_id, 'autrepersonnes', true) ?: '', 'autrepersonnes' => get_post_meta($post_id, 'autrepersonnes', true) ?: '',
'autre_autrepersonnes' => get_post_meta($post_id, 'autre_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) // Dates (formatted for display)
'datetime' => thalim_format_date(get_post_meta($post_id, 'datetime', true), $lang), '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_debut) $data['date_debut_ymd'] = date('Y-m-d', $ts_debut);
if ($ts_fin) $data['date_fin_ymd'] = date('Y-m-d', $ts_fin); 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 // *_ymd fields stay full-precision so sorting/filtering on index pages
// (`thalim_event_date_order`) keeps working. // (`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_debut'] = thalim_format_date($raw_debut, $lang, 'Y');
$data['date_de_fin'] = thalim_format_date($raw_fin, $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'); $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 --- // --- Category hierarchy for breadcrumb and color ---
$categories = wp_get_post_categories($post_id, ['fields' => 'all']); $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) { foreach ($categories as $cat) {
if (in_array($cat->term_id, $excluded_ids)) continue; if (in_array($cat->term_id, $excluded_ids)) continue;
$ancestor_ids = get_ancestors($cat->term_id, 'category'); $ancestor_ids = get_ancestors($cat->term_id, 'category');
@@ -256,7 +258,6 @@ function thalim_get_single_data($post_id) {
'post_type' => 'post', 'post_type' => 'post',
'post__in' => array_map('intval', $related_ids), 'post__in' => array_map('intval', $related_ids),
'posts_per_page' => -1, 'posts_per_page' => -1,
'lang' => '',
]); ]);
} }
@@ -272,7 +273,6 @@ function thalim_get_single_data($post_id) {
'orderby' => 'meta_value', 'orderby' => 'meta_value',
'meta_key' => 'date_de_debut', 'meta_key' => 'date_de_debut',
'order' => 'ASC', 'order' => 'ASC',
'lang' => '',
'post_status' => ['publish', 'future'], 'post_status' => ['publish', 'future'],
]); ]);
$now = time(); $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 ), '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 ), 'lieu' => thalim_bilingual( get_post_meta($seance->ID, 'lieu', true) ?: '', $lang ),
'adresse' => nl2br( esc_html( get_post_meta($seance->ID, 'adresse', true) ?: '' ) ), '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' => [], 'intervenants' => [],
'images' => [], 'images' => [],
'documents' => [], 'documents' => [],
@@ -372,7 +372,6 @@ function thalim_get_single_data($post_id) {
'post_type' => 'post', 'post_type' => 'post',
'post__in' => array_map('intval', $s_related_ids), 'post__in' => array_map('intval', $s_related_ids),
'posts_per_page' => -1, 'posts_per_page' => -1,
'lang' => '',
]); ]);
} }

View File

@@ -30,7 +30,6 @@ $annonces_raw = Timber::get_posts([
]], ]],
'orderby' => 'date', 'orderby' => 'date',
'order' => 'DESC', 'order' => 'DESC',
'lang' => '',
'thalim_event_date_order' => true, 'thalim_event_date_order' => true,
]); ]);
$context['annonces'] = $sort_with_pinned($annonces_raw, 'epingler_dans_le_diaporama_dannonces', $max_swiper); $context['annonces'] = $sort_with_pinned($annonces_raw, 'epingler_dans_le_diaporama_dannonces', $max_swiper);
@@ -42,20 +41,19 @@ $publications_raw = Timber::get_posts([
'posts_per_page' => 30, 'posts_per_page' => 30,
'orderby' => 'date', 'orderby' => 'date',
'order' => 'DESC', 'order' => 'DESC',
'lang' => '',
'tax_query' => [ 'tax_query' => [
'relation' => 'AND', 'relation' => 'AND',
[ [
'taxonomy' => 'category', 'taxonomy' => 'category',
'field' => 'term_id', 'field' => 'term_id',
'terms' => [4], 'terms' => [ thalim_cat_id('publications') ],
'operator' => 'IN', 'operator' => 'IN',
'include_children' => true, 'include_children' => true,
], ],
[ [
'taxonomy' => 'category', 'taxonomy' => 'category',
'field' => 'term_id', 'field' => 'term_id',
'terms' => [16], 'terms' => [ thalim_cat_id('articles') ],
'operator' => 'NOT IN', 'operator' => 'NOT IN',
], ],
], ],
@@ -74,24 +72,34 @@ $publications_raw = Timber::get_posts([
]); ]);
$context['publications'] = $sort_with_pinned($publications_raw, 'epingler_dans_le_diaporama_des_publications_et_productions', $max_swiper); $context['publications'] = $sort_with_pinned($publications_raw, 'epingler_dans_le_diaporama_des_publications_et_productions', $max_swiper);
$context['publications_cards'] = thalim_get_cards_data($publications_raw); $context['publications_cards'] = thalim_get_cards_data($publications_raw);
$context['publications_link'] = thalim_en_url( get_category_link(4) ); $context['publications_link'] = thalim_en_url( get_category_link( thalim_cat_id('publications') ) );
$context['annonces_link'] = thalim_en_url( get_permalink(29100) ); // Page « annonces » résolue par slug dans add_to_context() (annonces_url),
// au lieu d'un get_permalink(29100) codé en dur (post potentiellement absent).
$context['annonces_link'] = $context['annonces_url'];
// --- Message du laboratoire --- // --- Message du laboratoire ---
$messages_labo = Timber::get_posts([ $messages_labo = Timber::get_posts([
'post_type' => 'post', 'post_type' => 'post',
'posts_per_page' => 5, 'posts_per_page' => 5,
'cat' => 268, 'cat' => thalim_cat_id('message-labo'),
'orderby' => 'date', 'orderby' => 'date',
'order' => 'DESC', 'order' => 'DESC',
'lang' => '',
]); ]);
$context['messages_labo'] = $messages_labo ?: []; $context['messages_labo'] = $messages_labo ?: [];
$context['message_labo_link'] = thalim_en_url( get_category_link(268) ); $context['message_labo_link'] = thalim_en_url( get_category_link( thalim_cat_id('message-labo') ) );
// --- Agenda (médiation scientifique + séances de séminaire à venir) --- // --- Agenda (médiation scientifique + séances de séminaire à venir) ---
$agenda_lang = thalim_current_language(); $agenda_lang = thalim_current_language();
$mediation_cat_ids = [5, 18, 19, 20, 21, 22, 23]; // Catégories « agenda » résolues par slug (médiation + newsletter/gazette historiques)
$mediation_cat_ids = array_values( array_filter( [
thalim_cat_id('mediation'),
thalim_cat_id('evenements-culturels'),
thalim_cat_id('medias'),
thalim_cat_id('newsletter'),
thalim_cat_id('gazette'),
thalim_cat_id('podcast'),
thalim_cat_id('captations'),
] ) );
$months_fr = ['jan.', 'fév.', 'mars', 'avr.', 'mai', 'juin', 'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.']; $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_en = ['Jan.', 'Feb.', 'Mar.', 'Apr.', 'May', 'Jun.', 'Jul.', 'Aug.', 'Sep.', 'Oct.', 'Nov.', 'Dec.'];
$agenda_type_fields = [ $agenda_type_fields = [
@@ -123,7 +131,6 @@ $mediation_upcoming = Timber::get_posts([
'posts_per_page' => 8, 'posts_per_page' => 8,
'category__in' => $mediation_cat_ids, 'category__in' => $mediation_cat_ids,
'orderby' => ['date_clause' => 'ASC'], 'orderby' => ['date_clause' => 'ASC'],
'lang' => '',
'meta_query' => [ 'meta_query' => [
'date_clause' => [ 'date_clause' => [
'key' => 'date_de_debut', 'key' => 'date_de_debut',
@@ -150,13 +157,12 @@ foreach ($mediation_upcoming as $mpost) {
if ($item) $agenda_items[] = $item; if ($item) $agenda_items[] = $item;
} }
// 2. Upcoming séances de séminaire (cat 12) // 2. Upcoming séances de séminaire
$seances_upcoming = Timber::get_posts([ $seances_upcoming = Timber::get_posts([
'post_type' => 'post', 'post_type' => 'post',
'posts_per_page' => 8, 'posts_per_page' => 8,
'category__in' => [12], 'category__in' => [ thalim_cat_id('seance') ],
'orderby' => ['date_clause' => 'ASC'], 'orderby' => ['date_clause' => 'ASC'],
'lang' => '',
'meta_query' => [ 'meta_query' => [
'date_clause' => [ 'date_clause' => [
'key' => 'date_de_debut', 'key' => 'date_de_debut',
@@ -169,19 +175,7 @@ $seances_upcoming = Timber::get_posts([
foreach ($seances_upcoming as $seance) { foreach ($seances_upcoming as $seance) {
$raw_date = get_post_meta($seance->ID, 'date_de_debut', true); $raw_date = get_post_meta($seance->ID, 'date_de_debut', true);
if (!$raw_date) continue; if (!$raw_date) continue;
// Direct DB lookup — bypasses Polylang and other hook filters $link = thalim_get_seance_link($seance->ID);
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
));
$link = $parent_id
? get_permalink((int) $parent_id) . '#seance-' . $seance->ID
: get_permalink($seance->ID);
$label = $agenda_lang === 'en' ? 'Seminar session' : 'Séance de séminaire'; $label = $agenda_lang === 'en' ? 'Seminar session' : 'Séance de séminaire';
$item = $make_agenda_item($seance, $raw_date, $label, get_post_meta($seance->ID, 'lieu', true) ?: '', $link); $item = $make_agenda_item($seance, $raw_date, $label, get_post_meta($seance->ID, 'lieu', true) ?: '', $link);
if ($item) $agenda_items[] = $item; if ($item) $agenda_items[] = $item;
@@ -198,7 +192,6 @@ if (empty($agenda_items)) {
'posts_per_page' => 5, 'posts_per_page' => 5,
'category__in' => $mediation_cat_ids, 'category__in' => $mediation_cat_ids,
'orderby' => ['date_clause' => 'DESC'], 'orderby' => ['date_clause' => 'DESC'],
'lang' => '',
'meta_query' => [ 'meta_query' => [
'date_clause' => ['key' => 'date_de_debut', 'type' => 'DATETIME'], 'date_clause' => ['key' => 'date_de_debut', 'type' => 'DATETIME'],
], ],
@@ -222,7 +215,7 @@ if (empty($agenda_items)) {
} }
$context['agenda_items'] = $agenda_items; $context['agenda_items'] = $agenda_items;
$context['manifestations_link'] = thalim_en_url( add_query_arg( 'view', 'agenda', get_category_link(3) ) ); $context['manifestations_link'] = thalim_en_url( add_query_arg( 'view', 'agenda', get_category_link( thalim_cat_id('manifestations') ) ) );
// --- Quick links --- // --- Quick links ---
$newsletter_cat = get_category_by_slug('newsletter'); $newsletter_cat = get_category_by_slug('newsletter');
@@ -247,7 +240,7 @@ if ( ! $newsletter_url ) {
); );
} }
$context['quick_links'] = [ $context['quick_links'] = [
'agenda' => thalim_en_url(add_query_arg('view', 'agenda', get_category_link(3))), 'agenda' => thalim_en_url(add_query_arg('view', 'agenda', get_category_link( thalim_cat_id('manifestations') ))),
'contacts' => thalim_en_url(home_url('/contacts/')), 'contacts' => thalim_en_url(home_url('/contacts/')),
'newsletter' => $newsletter_url, 'newsletter' => $newsletter_url,
]; ];
@@ -257,7 +250,6 @@ $context['has_tags'] = !empty(get_terms([
'taxonomy' => 'post_tag', 'taxonomy' => 'post_tag',
'hide_empty' => true, 'hide_empty' => true,
'number' => 1, 'number' => 1,
'lang' => '',
])); ]));
Timber::render('index.twig', $context); Timber::render('index.twig', $context);

405
js/admin/admin-base.js Normal file
View File

@@ -0,0 +1,405 @@
/**
* Socle partagé des customisations admin (namespace window.ThalimAdmin).
* Chargé sur toutes les pages admin, avant les scripts de contexte
* (admin-rename, admin-post-edit, admin-profile, admin-taxonomy-list,
* admin-pods-modal) qui en dépendent.
*/
(function($) {
'use strict';
// ── Configuration ──────────────────────────────────────────
// Sélecteurs et identifiants Pods utilisés par les modules admin,
// centralisés pour qu'un renommage côté Pods ne demande qu'une édition ici.
var CONFIG = {
// Options désactivées dans le select Pods « Type d'annonce » (term IDs)
disabledCategoryIds: ['1', '12', '5', '20'],
// Catégorie « Séance de séminaire » (verrouillée dans la modale Pods)
seanceCategoryId: '12',
// Select Pods de la catégorie
categorySelect: '#pods-form-ui-pods-meta-categorie',
// IDs des éditeurs TinyMCE Pods à réparer (reinitEditor)
editors: {
bodyEn: 'pods-form-ui-pods-meta-body-en',
refBib: 'pods-form-ui-pods-meta-reference-bibliographique'
},
// Metaboxes Pods déplacées / observées
boxes: {
bodyEn: '#pods-meta-body-en',
typeDannonce: '#pods-meta-type-dannonce',
affichageAccueil: '#pods-meta-affichage-sur-laccueil',
thematique: '#pods-meta-thematique',
champsContextuels: '#pods-meta-champs-contextuels',
documentsJoints: '#pods-meta-documents-joints',
membres: '#pods-meta-membres'
},
// Classes des lignes Pods conditionnelles observées (MutationObserver)
rows: {
axes: 'pods-form-ui-row-name-axes-thematiques',
refBib: 'pods-form-ui-row-name-reference-bibliographique'
},
// Taxonomies dont la page liste reçoit l'info-bulle « FR // EN »
translateTaxonomies: ['axe_thematique', 'programme_de_recherche', 'post_tag']
};
// Exécute un bloc d'init de façon isolée : une exception dans un module
// n'empêche pas les modules suivants de s'initialiser.
function safeRun(name, fn) {
try {
fn();
} catch (err) {
if (window.console && console.error) {
console.error('[thalim-admin] ' + name + ' failed:', err);
}
}
}
function isPostEditPage() {
return window.pagenow === 'post'
|| window.pagenow === 'post-new'
// On CPTs, pagenow is the post_type slug — also catch them via the
// body classes WP sets for any post.php / post-new.php screen.
|| document.body.classList.contains('post-php')
|| document.body.classList.contains('post-new-php');
}
function isProfileEditPage() {
return window.pagenow === 'profile' || window.pagenow === 'user-edit' || window.pagenow === 'user-new';
}
function getProfileForm() {
return document.querySelector('#your-profile, #createuser');
}
function isPodsModal() {
return new URLSearchParams(window.location.search).has('pods_modal');
}
function updatePostboxVisibility() {
document.querySelectorAll('.postbox').forEach(function(postBox) {
if (postBox.id.startsWith('pods')) {
// body-en is controlled by language tabs — never auto-hide it
if ('#' + postBox.id === CONFIG.boxes.bodyEn) return;
var fields = postBox.querySelectorAll('tr');
var hasVisibleFields = Array.from(fields).some(function(field) {
return field.style.display !== 'none';
});
postBox.style.display = hasVisibleFields ? 'block' : 'none';
}
});
}
// Force Visual (TinyMCE) mode on page load.
// WP stores the last-used editor mode in localStorage and restores it at document.ready.
// When Code mode is restored, TinyMCE is never initialised — tinymce.get() returns null.
// Instead, check the wrapper's CSS class:
// tmce-active = Visual mode (fine)
// html-active = Code mode (switch to Visual)
function ensureVisualMode(editorId, attempt) {
attempt = attempt || 0;
if (attempt > 15) return;
var wrap = document.getElementById('wp-' + editorId + '-wrap');
if (!wrap) {
setTimeout(function() { ensureVisualMode(editorId, attempt + 1); }, 200);
return;
}
if (wrap.classList.contains('html-active')) {
var ed = window.tinymce && tinymce.get(editorId);
if (!ed || !ed.initialized) {
// TinyMCE not ready yet — retry rather than calling switchEditors.go() prematurely
setTimeout(function() { ensureVisualMode(editorId, attempt + 1); }, 200);
return;
}
if (typeof switchEditors !== 'undefined') {
switchEditors.go(editorId, 'tmce');
}
return;
}
if (!wrap.classList.contains('tmce-active')) {
// Mode not yet determined — retry
setTimeout(function() { ensureVisualMode(editorId, attempt + 1); }, 200);
}
}
// Rebuild a TinyMCE editor whose iframe is broken (empty/non-interactive).
// This happens when TinyMCE is initialised on a hidden (display:none) element:
// the iframe can't measure dimensions and its document body stays empty.
//
// We reinit from tinyMCEPreInit.mceInit — first trying the editor's own config
// (registered by Pods server-side), falling back to 'content' (the native WP editor).
//
// Inline toolbar positioning fix:
// TinyMCE's 'wordpress' plugin captures document.getElementById(id+'_ifr') during
// 'preinit' — before the iframe is created — so mceIframe is always null.
// Fix: intercept getElementById during preinit so the 'wordpress' plugin captures
// a proxyIframe instead of null. After init, proxy delegates to the real iframe.
function reinitEditor(editorId) {
var ed = window.tinymce && tinymce.get(editorId);
// Preserve existing content before destroying the instance
var savedContent = '';
if (ed) {
try { savedContent = ed.getContent(); } catch (e) {}
ed.remove();
}
if (!savedContent) {
var ta = document.getElementById(editorId);
if (ta) savedContent = ta.value || '';
}
if (!window.tinyMCEPreInit || !window.tinymce) return;
// Use the editor's own server-side config if available, else clone from 'content'
var baseInit = (tinyMCEPreInit.mceInit && tinyMCEPreInit.mceInit[editorId])
|| (tinyMCEPreInit.mceInit && tinyMCEPreInit.mceInit['content']);
if (!baseInit) return;
// Proxy iframe: getBoundingClientRect() falls back to the editor wrap
var wrapId = 'wp-' + editorId + '-wrap';
var proxyIframe = {
getBoundingClientRect: function() {
var el = document.getElementById(wrapId);
return el ? el.getBoundingClientRect()
: { top: 0, left: 0, right: window.innerWidth,
bottom: window.innerHeight, width: window.innerWidth,
height: window.innerHeight };
}
};
var savedGetById = document.getElementById;
var origSetup = baseInit.setup;
var content = savedContent;
tinymce.init($.extend({}, baseInit, {
selector: '#' + editorId,
setup: function(editor) {
if (typeof origSetup === 'function') origSetup(editor);
editor.on('focus', function() {
window.wpActiveEditor = editorId;
});
editor.on('preinit', function() {
document.getElementById = function(id) {
if (id === editorId + '_ifr') return proxyIframe;
return savedGetById.call(document, id);
};
setTimeout(function() {
document.getElementById = savedGetById;
}, 0);
});
editor.on('init', function() {
// Point proxy to real iframe
var realIframe = savedGetById.call(document, editorId + '_ifr');
if (realIframe) {
proxyIframe.getBoundingClientRect = function() {
return realIframe.getBoundingClientRect();
};
}
// Restore content that was in the textarea
if (content) {
editor.setContent(content);
}
});
}
}));
}
// ── Info-popovers (post / user / taxonomy) ─────────────────
var INFO_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 11v6"/><path d="M12 8v.01" stroke-width="2"/></svg>';
var TRANSLATE_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>';
var TRANSLATE_LINES = [
'Traduction en anglais après //',
'ex : Texte en français // English text'
];
// Tips without a `page` key default to 'post'.
// type: 'translate' uses the globe icon + green button style.
var INFO_TIPS = [
// --- post edit page: info ---
{
selector: '.wp-heading-inline',
lines: [
'Saisir le titre anglais après //',
'ex : Titre de lannonce // Title of the announcement'
]
},
{
selector: '#pods-meta-documents-joints .postbox-header h2',
lines: [
'Ajouter les images dans les documents.',
'Ajouter les légendes comme titre du document.'
]
},
{
selector: '#pods-meta-membres .postbox-header h2',
lines: [
'Le champ fonction change le libellé de la liste de personnes citées.',
'Le champ membre permet de lister les membres de Thalim liés à lannonce.',
'Le champ autre personnes permet de lister des personnes extérieures à Thalim.'
]
},
{
selector: '#pods-meta-dates .postbox-header h2',
lines: [
'Pour entrer une date sans lheure, régler lheure sur 00:00.'
]
},
{
selector: '#pods-meta-affichage-sur-laccueil .postbox-header h2',
lines: [
'Épingler lannonce dans le diaporama la fait safficher avant les autres.'
]
},
{
selector: '#pods-meta-medias .postbox-header h2',
lines: [
'Pour ajouter un média Canal U, copier le lien depuis « Citer cette ressource ».',
'ex : https://www.canal-u.tv/166564'
]
},
// --- post edit page: translate ---
{ type: 'translate', selector: '#pods-meta-documents-joints .postbox-header h2', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-sous-titre th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-lieu th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-titre-du-lien-externe-1 th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-titre-du-lien-externe-2 th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-titre-du-lien-externe-3 th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-fonction-organisation th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-fonction-intervention th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-fonction-candidat th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-fonction-realisation th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-fonction-dirige th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-fonction-redaction th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-fonction-auteur th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-fonction-responsable th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-autre-fonction-autre th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-autre-fonction-concerne th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-autre-fonction-directeur th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-autre-fonction-direction-d-ouvrage th',lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-autre-fonction-intervenant th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-autre-fonction-participants th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-type-autre th', lines: TRANSLATE_LINES },
// --- contenu_general edit page: translate ---
{ type: 'translate', selector: '.pods-form-ui-row-name-umr th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-thalim th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-siecles th', lines: TRANSLATE_LINES },
// --- user/profile edit page: translate ---
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-titre-du-lien-1 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-titre-du-lien-2 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-titre-du-lien-3 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-titre-du-lien-4 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-complement-de-role-1 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-complement-de-role-2 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-complement-de-role-3 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-affichage-du-statut-1 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-affichage-du-statut-2 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-affichage-du-statut-3 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-affiliation-autre th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-titre-de-these th', lines: TRANSLATE_LINES },
// --- taxonomy edit pages: translate ---
{ type: 'translate', page: 'taxonomy', selector: 'label[for="name"]', lines: TRANSLATE_LINES },
// --- user/profile edit page: info ---
{
page: 'user',
selector: '.pods-form-ui-label-pods-meta-identifiant-hal',
lines: [
'Renseigner votre idHAL (en lettres), pas votre PersonId (en chiffres).'
]
},
{
page: 'user',
selector: '.pods-form-ui-label-pods-meta-affichage-du-statut-1',
lines: [
'Texte de statut affiché sur le profil publique.'
]
}
];
var _popoverCloseHandlerRegistered = false;
function initInfoPopovers(currentPage) {
currentPage = currentPage || 'post';
INFO_TIPS.forEach(function(tip) {
if ((tip.page || 'post') !== currentPage) return;
var el = document.querySelector(tip.selector);
if (!el) return;
var isTranslate = tip.type === 'translate';
var btn = document.createElement('button');
btn.type = 'button';
btn.className = isTranslate ? 'thalim-translate-btn' : 'thalim-info-btn';
btn.setAttribute('aria-label', isTranslate ? 'Traduction bilingue' : 'Informations');
btn.innerHTML = isTranslate ? TRANSLATE_ICON : INFO_ICON;
var popover = document.createElement('div');
popover.className = 'thalim-info-popover' + (isTranslate ? ' thalim-translate-popover' : '');
popover.innerHTML = tip.lines.map(function(line) {
return '<p>' + line + '</p>';
}).join('');
var wrapper = document.createElement('span');
wrapper.className = 'thalim-info-wrapper';
wrapper.appendChild(btn);
wrapper.appendChild(popover);
el.appendChild(wrapper);
btn.addEventListener('click', function(e) {
e.stopPropagation();
var isOpen = popover.classList.contains('is-open');
document.querySelectorAll('.thalim-info-popover.is-open').forEach(function(p) {
p.classList.remove('is-open');
});
if (!isOpen) {
var rect = btn.getBoundingClientRect();
popover.style.top = (rect.bottom + 6) + 'px';
popover.style.left = (rect.left + rect.width / 2) + 'px';
popover.classList.add('is-open');
}
});
popover.addEventListener('click', function(e) {
e.stopPropagation();
});
});
if (!_popoverCloseHandlerRegistered) {
_popoverCloseHandlerRegistered = true;
document.addEventListener('click', function() {
document.querySelectorAll('.thalim-info-popover.is-open').forEach(function(p) {
p.classList.remove('is-open');
});
});
}
}
// ── Reveal (#wpbody est masqué en CSS sur post/profil jusqu'à l'init) ──
function markReady() {
document.body.classList.add('admin-mods-ready');
}
// Fallback global : force le reveal après 2 s même si le script de
// contexte a planté ou n'a pas été chargé.
$(document).ready(function() {
setTimeout(markReady, 2000);
});
window.ThalimAdmin = {
CONFIG: CONFIG,
safeRun: safeRun,
isPostEditPage: isPostEditPage,
isProfileEditPage: isProfileEditPage,
getProfileForm: getProfileForm,
isPodsModal: isPodsModal,
ensureVisualMode: ensureVisualMode,
reinitEditor: reinitEditor,
updatePostboxVisibility: updatePostboxVisibility,
initInfoPopovers: initInfoPopovers,
markReady: markReady
};
})(jQuery);

View File

@@ -0,0 +1,49 @@
/**
* Modale Pods de création de séance (URL contenant pods_modal) :
* verrouille la catégorie sur « Séance de séminaire ».
* Dépend de admin-base.js (window.ThalimAdmin) — enqueue conditionnel.
*/
(function($) {
'use strict';
var TA = window.ThalimAdmin;
var CONFIG = TA.CONFIG;
var safeRun = TA.safeRun;
$(document).ready(function() {
if (!TA.isPodsModal()) return;
var lockSeanceCategory = function() {
var seanceCat = CONFIG.seanceCategoryId;
var itemId = $('#post_ID').val();
if (window.PodsDFV && itemId) {
window.PodsDFV.setFieldValue('post', itemId, 'categorie', seanceCat, 0);
}
// Lock category select to the séance category in iframe —
// delay to run after Pods React re-render
setTimeout(function() {
var $select = $(CONFIG.categorySelect);
if ($select.length) {
$select.find('option').each(function() {
this.disabled = this.value !== seanceCat;
});
$select.val(seanceCat);
}
safeRun('updatePostboxVisibility', TA.updatePostboxVisibility);
}, 200);
};
// Dans l'iframe de la modale, window.load peut déjà être passé au moment
// où ce code s'exécute : s'abonner à un load déjà émis ne rejoue rien.
// On exécute donc tout de suite si la page est déjà chargée.
if (document.readyState === 'complete') {
safeRun('lockSeanceCategory', lockSeanceCategory);
} else {
$(window).on('load', function() {
safeRun('lockSeanceCategory', lockSeanceCategory);
});
}
});
})(jQuery);

299
js/admin/admin-post-edit.js Normal file
View File

@@ -0,0 +1,299 @@
/**
* Page d'édition de post (post.php / post-new.php) : onglets FR/EN du corps,
* réordonnancement des metaboxes, groupement des axes, visibilité
* conditionnelle des boxes Pods, popovers d'aide, fixes TinyMCE/Gutenberg.
* Dépend de admin-base.js (window.ThalimAdmin) — enqueue conditionnel.
*/
(function($) {
'use strict';
var TA = window.ThalimAdmin;
var CONFIG = TA.CONFIG;
var safeRun = TA.safeRun;
// Phase 1: insert the tab bar and relocate #pods-meta-body-en.
// The DOM move breaks TinyMCE's iframe (browsers reset iframe content on detach),
// so we leave the container visible here and let Pods/TinyMCE initialise normally.
// The broken iframe is repaired by reinitEditor() on first EN tab open.
function setupBodyTabsDom() {
var nativeEditor = document.getElementById('postdivrich') || document.getElementById('postdiv');
var bodyEnBox = document.querySelector(CONFIG.boxes.bodyEn);
if (!nativeEditor || !bodyEnBox) return;
var tabBar = document.createElement('div');
tabBar.className = 'body-lang-tabs';
tabBar.innerHTML =
'<button type="button" class="body-lang-tab is-active" data-panel="fr">Français</button>' +
'<button type="button" class="body-lang-tab" data-panel="en">English</button>';
nativeEditor.parentNode.insertBefore(tabBar, nativeEditor);
// Move EN metabox to sit right after the native editor for correct visual layout.
// Do NOT hide it yet — Pods must init TinyMCE with the container visible so the
// iframe can measure its dimensions. Page is still opacity:0 so no flash.
nativeEditor.parentNode.insertBefore(bodyEnBox, nativeEditor.nextSibling);
}
// Phase 2: wire tab click handlers — runs at t=100ms after metabox reordering.
function initBodyLanguageTabs() {
var nativeEditor = document.getElementById('postdivrich') || document.getElementById('postdiv');
var bodyEnBox = document.querySelector(CONFIG.boxes.bodyEn);
var tabBar = document.querySelector('.body-lang-tabs');
if (!nativeEditor || !bodyEnBox || !tabBar) {
// body_en not available (e.g. contributor role) — still force visual mode on main editor
if (nativeEditor) TA.ensureVisualMode('content');
return;
}
var enEditorId = CONFIG.editors.bodyEn;
var enTmceReady = false;
// Hide EN panel — page is still opacity:0, user won't see the switch
bodyEnBox.style.display = 'none';
tabBar.querySelectorAll('.body-lang-tab').forEach(function(btn) {
btn.addEventListener('click', function() {
tabBar.querySelectorAll('.body-lang-tab').forEach(function(b) {
b.classList.remove('is-active');
});
btn.classList.add('is-active');
var revealedPanel;
if (btn.dataset.panel === 'fr') {
bodyEnBox.style.display = 'none';
nativeEditor.style.opacity = '0';
nativeEditor.style.display = '';
revealedPanel = nativeEditor;
} else {
nativeEditor.style.display = 'none';
bodyEnBox.style.opacity = '0';
bodyEnBox.style.display = 'block';
revealedPanel = bodyEnBox;
if (!enTmceReady) {
enTmceReady = true;
// Reinit while container is visible so TinyMCE can measure dimensions
TA.reinitEditor(enEditorId);
}
}
// Notify TinyMCE to reflow, then fade in once layout is correct
setTimeout(function() {
window.dispatchEvent(new Event('resize'));
requestAnimationFrame(function() {
requestAnimationFrame(function() {
revealedPanel.style.opacity = '';
});
});
}, 50);
});
});
// Ensure both editors start in Visual (not Code) mode
TA.ensureVisualMode('content');
TA.ensureVisualMode(enEditorId);
}
function groupAxesCheckboxes() {
if (!window.thalimAxesGroups || !thalimAxesGroups.length) return;
var row = document.querySelector('.' + CONFIG.rows.axes);
if (!row) return;
var list = row.querySelector('ul');
if (!list) return;
// Already grouped — nothing to do
if (list.querySelector('.axes-group-label')) return;
// Map existing <li> by checkbox value; preserve "add new" button
var liMap = {};
var addNewItem = null;
list.querySelectorAll('li').forEach(function(li) {
if (li.classList.contains('pods-pick-add-new')) { addNewItem = li; return; }
var cb = li.querySelector('input[type="checkbox"]');
if (cb) liMap[cb.value] = li;
});
// Rebuild list in group order
list.innerHTML = '';
thalimAxesGroups.forEach(function(group) {
var labelLi = document.createElement('li');
labelLi.className = 'axes-group-label';
labelLi.textContent = group.label;
list.appendChild(labelLi);
group.terms.forEach(function(term) {
var li = liMap[String(term.id)];
if (li) list.appendChild(li);
});
});
if (addNewItem) list.appendChild(addNewItem);
}
var REF_BIB_EDITOR_ID = CONFIG.editors.refBib;
var refBibReinited = false;
// Reinit the référence bibliographique TinyMCE editor.
// Called at page load (if the field is already visible) and by the
// MutationObserver (when the field becomes visible after a category change).
function initRefBibEditor() {
if (refBibReinited) return;
var row = document.querySelector('.' + CONFIG.rows.refBib);
if (!row || row.style.display === 'none') return;
refBibReinited = true;
TA.reinitEditor(REF_BIB_EDITOR_ID);
TA.ensureVisualMode(REF_BIB_EDITOR_ID);
}
function initAxesGroupObserver() {
// Pods shows/hides conditional rows by removing inline style="display:none"
// Watch the entire Pods meta form for style changes on the axes row
var podsForm = document.querySelector('.pods-pick-values, ' + CONFIG.boxes.champsContextuels + ', form#post');
if (!podsForm) podsForm = document.body;
var observer = new MutationObserver(function(mutations) {
for (var i = 0; i < mutations.length; i++) {
var target = mutations[i].target;
if (target.classList && target.classList.contains(CONFIG.rows.axes)) {
if (target.style.display !== 'none') {
setTimeout(groupAxesCheckboxes, 50);
}
}
// Reinit TinyMCE on the référence bibliographique field when its
// row becomes visible — Pods hides it with display:none which breaks
// the TinyMCE iframe. Only reinit once per page load.
if (!refBibReinited && target.classList &&
target.classList.contains(CONFIG.rows.refBib)) {
if (target.style.display !== 'none') {
setTimeout(initRefBibEditor, 100);
}
}
}
});
observer.observe(podsForm, { attributes: true, attributeFilter: ['style'], subtree: true });
}
// Gutenberg's Popover component closes on outside click via focusout detection.
// But if focus never enters the popover, focusout never fires and clicking outside
// does nothing. Fix: focus the popover container as soon as it appears in the DOM.
function initDatePickerPopoverFix() {
var observer = new MutationObserver(function(mutations) {
for (var i = 0; i < mutations.length; i++) {
var added = mutations[i].addedNodes;
for (var j = 0; j < added.length; j++) {
var node = added[j];
if (node.nodeType !== 1) continue;
var content = node.classList.contains('components-popover__content')
? node
: node.querySelector && node.querySelector('.components-popover__content');
if (content) {
var c = content;
requestAnimationFrame(function() {
if (!c.hasAttribute('tabindex')) c.setAttribute('tabindex', '-1');
c.focus();
});
}
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
function updateMembresGridSeparator() {
var sep = document.querySelector(CONFIG.boxes.membres + ' .membres-grid-separator');
if (!sep) return;
var autreRows = document.querySelectorAll(CONFIG.boxes.membres + ' [class*="pods-form-ui-row-name-autre-"]');
var anyVisible = Array.from(autreRows).some(function(row) {
return row.style.display !== 'none';
});
sep.style.display = anyVisible ? '' : 'none';
}
function initPostEditPage() {
// Disable category options (CSS handles the color)
const categorieSelect = document.querySelector(CONFIG.categorySelect);
if (categorieSelect) {
categorieSelect.querySelectorAll('option').forEach(option => {
if (CONFIG.disabledCategoryIds.includes(option.value)) {
option.disabled = true;
}
});
}
// Reorder meta boxes
const sideSortables = document.querySelector('#side-sortables');
if (sideSortables) {
const typeDannonce = document.querySelector(CONFIG.boxes.typeDannonce);
const affichageAccueil = document.querySelector(CONFIG.boxes.affichageAccueil);
const thematique = document.querySelector(CONFIG.boxes.thematique);
if (typeDannonce) sideSortables.prepend(typeDannonce);
if (affichageAccueil) sideSortables.appendChild(affichageAccueil);
if (thematique) sideSortables.appendChild(thematique);
}
const submitDiv = document.querySelector('#submitdiv');
if (submitDiv && submitDiv.parentNode) {
submitDiv.parentNode.appendChild(submitDiv);
}
const champsContextuels = document.querySelector(CONFIG.boxes.champsContextuels);
if (champsContextuels && champsContextuels.parentNode) {
champsContextuels.parentNode.prepend(champsContextuels);
}
// Chaque sous-module est isolé : une exception dans l'un
// n'empêche pas les suivants de s'initialiser.
safeRun('initBodyLanguageTabs', initBodyLanguageTabs);
safeRun('initRefBibEditor', initRefBibEditor);
safeRun('groupAxesCheckboxes', groupAxesCheckboxes);
safeRun('initAxesGroupObserver', initAxesGroupObserver);
safeRun('updatePostboxVisibility', TA.updatePostboxVisibility);
safeRun('initDatePickerPopoverFix', initDatePickerPopoverFix);
safeRun('initInfoPopovers', TA.initInfoPopovers);
// Place #pods-meta-documents-joints in #normal-sortables, right after
// #pods-meta-champs-contextuels. This keeps it out of #post-body-content
// (the body editor section) regardless of whether champsContextuels is
// currently visible. When champsContextuels is hidden it takes no space,
// so documentsJoints simply appears first in #normal-sortables.
const documentsJoints = document.querySelector(CONFIG.boxes.documentsJoints);
if (documentsJoints) {
if (champsContextuels && champsContextuels.parentNode) {
champsContextuels.parentNode.insertBefore(documentsJoints, champsContextuels.nextSibling);
} else {
const normalSortables = document.querySelector('#normal-sortables');
if (normalSortables) normalSortables.prepend(documentsJoints);
}
}
// Inject separator row for the Membres grid layout
var membresTbody = document.querySelector(CONFIG.boxes.membres + ' .form-table tbody');
if (membresTbody && !membresTbody.querySelector('.membres-grid-separator')) {
var sep = document.createElement('tr');
sep.className = 'membres-grid-separator';
membresTbody.appendChild(sep);
}
updateMembresGridSeparator();
}
$(document).ready(function() {
if (!TA.isPostEditPage()) return;
safeRun('setupBodyTabsDom', setupBodyTabsDom);
setTimeout(function() {
safeRun('initPostEditPage', initPostEditPage);
TA.markReady();
}, 100);
$(CONFIG.categorySelect).change(function() {
setTimeout(function() {
safeRun('updatePostboxVisibility', TA.updatePostboxVisibility);
safeRun('updateMembresGridSeparator', updateMembresGridSeparator);
}, 10);
});
});
})(jQuery);

149
js/admin/admin-profile.js Normal file
View File

@@ -0,0 +1,149 @@
/**
* Pages profil / utilisateur (profile.php, user-edit.php, user-new.php) :
* réordonnancement des sections natives, popovers d'aide, mode visuel forcé
* sur les WYSIWYG Pods.
* Dépend de admin-base.js (window.ThalimAdmin) — enqueue conditionnel.
*/
(function($) {
'use strict';
var TA = window.ThalimAdmin;
var safeRun = TA.safeRun;
// Only native WP field sections — never touch Pods tables (they may contain TinyMCE editors)
var PROFILE_SECTION_KEYS = [
'user-language-wrap',
'user-first-name-wrap',
'user-email-wrap',
'user-pass1-wrap',
'upload-avatar-row',
];
// Desired order. Groups of 2 are wrapped in a flex row and displayed side by side.
var PROFILE_ORDER = [
['user-first-name-wrap', 'upload-avatar-row'],
['user-email-wrap'],
['user-language-wrap', 'user-pass1-wrap'],
];
function reorderProfileSections() {
var form = TA.getProfileForm();
if (!form) return;
var pairMap = {};
form.querySelectorAll('table.form-table').forEach(function(table) {
PROFILE_SECTION_KEYS.forEach(function(key) {
if (pairMap[key] || !table.querySelector('.' + key)) return;
// Find the associated heading: first try preceding sibling in same parent,
// then look for an h2/h3 inside the same wrapper element.
var h2 = null;
var el = table.previousElementSibling;
while (el) {
if (el.tagName === 'H2' || el.tagName === 'H3') { h2 = el; break; }
if (el.tagName === 'TABLE') break;
el = el.previousElementSibling;
}
if (!h2 && table.parentElement !== form) {
h2 = table.parentElement.querySelector('h2, h3');
}
// The unit to move: if h2 and table share a non-form wrapper, move the wrapper.
var wrapper = null;
if (h2 && h2.parentElement !== form && h2.parentElement === table.parentElement) {
wrapper = h2.parentElement;
}
pairMap[key] = { h2: h2, table: table, wrapper: wrapper };
});
});
// Remove all matched units from DOM (dedup by actual element)
var removed = new Set();
function removeEl(el) {
if (el && !removed.has(el)) { removed.add(el); el.remove(); }
}
Object.values(pairMap).forEach(function(unit) {
if (unit.wrapper) { removeEl(unit.wrapper); }
else { removeEl(unit.h2); removeEl(unit.table); }
});
// Re-insert in declared order before the submit button
var submitAnchor = form.querySelector('p.submit');
function append(el) {
if (submitAnchor && submitAnchor.parentNode) form.insertBefore(el, submitAnchor);
else form.appendChild(el);
}
function appendUnit(unit) {
if (unit.wrapper) { append(unit.wrapper); }
else { if (unit.h2) append(unit.h2); append(unit.table); }
}
PROFILE_ORDER.forEach(function(group) {
var available = group.filter(function(key) { return !!pairMap[key]; });
if (!available.length) return;
// Dedup: two keys may resolve to the same table/wrapper
var seen = new Set();
var units = [];
available.forEach(function(key) {
var unit = pairMap[key];
var id = unit.wrapper || unit.table;
if (!seen.has(id)) { seen.add(id); units.push(unit); }
});
if (units.length === 1) {
appendUnit(units[0]);
} else {
var row = document.createElement('div');
row.className = 'profile-section-row';
units.forEach(function(unit) {
var col = document.createElement('div');
col.className = 'profile-section-col';
if (unit.wrapper) { col.appendChild(unit.wrapper); }
else { if (unit.h2) col.appendChild(unit.h2); col.appendChild(unit.table); }
row.appendChild(col);
});
append(row);
}
});
}
function initProfileEditors() {
reorderProfileSections();
TA.initInfoPopovers('user');
// Hide the "À propos du compte" section heading
document.querySelectorAll('#your-profile h2, #adduser h2, #createuser h2').forEach(function(h2) {
if (h2.textContent.trim() === 'À propos du compte') {
h2.style.display = 'none';
}
});
// Rename "Rôle" label to "Rôle sur le site"
var roleLabel = document.querySelector('label[for="role"]');
if (roleLabel && roleLabel.textContent.trim() === 'Rôle') {
roleLabel.textContent = 'Rôle sur le site';
}
}
$(document).ready(function() {
if (!TA.isProfileEditPage()) return;
setTimeout(function() {
safeRun('initProfileEditors', initProfileEditors);
TA.markReady();
}, 100);
// Force visual mode on all Pods WYSIWYG fields once everything is loaded
$(window).on('load', function() {
var scope = TA.getProfileForm() || document;
scope.querySelectorAll('.pods-dfv-container-wysiwyg textarea').forEach(function(ta) {
if (!ta.id) return;
TA.ensureVisualMode(ta.id);
});
});
});
})(jQuery);

77
js/admin/admin-rename.js Normal file
View File

@@ -0,0 +1,77 @@
/**
* Renomme « Article » en « Annonce » dans l'interface admin (toutes pages).
* Dépend de admin-base.js (window.ThalimAdmin).
*/
(function() {
'use strict';
var TA = window.ThalimAdmin;
function renameArticlesToAnnonces() {
const replacements = [
[/Tous les articles/g, 'Toutes les annonces'],
[/Ajouter un article/g, 'Ajouter une annonce'],
[/Modifier l.article/g, "Modifier l'annonce"],
[/Prévisualiser l.article/g, "Prévisualiser l'annonce"],
[/Afficher l.article/g, "Afficher l'annonce"],
[/Voir l.article/g, "Voir l'annonce"],
[/Article publié/g, 'Annonce publiée'],
[/Article mis à jour/g, 'Annonce mise à jour'],
[/Article planifié/g, 'Annonce planifiée'],
[/Articles par page/g, 'Annonces par page'],
[/Articles/g, 'Annonces'],
[/Article/g, 'Annonce'],
[/Rechercher des articles/g, 'Rechercher des annonces'],
];
function applyReplacements(text) {
return replacements.reduce((t, [s, r]) => t.replace(s, r), text);
}
function replaceInTextNodes(el) {
if (!el) return;
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
const nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
nodes.forEach(function(node) {
const replaced = applyReplacements(node.textContent);
if (replaced !== node.textContent) node.textContent = replaced;
});
}
// Menu latéral
replaceInTextNodes(document.querySelector('#menu-posts'));
// Titre de page (h1) et bouton d'ajout
document.querySelectorAll('.wp-heading-inline, .page-title-action').forEach(replaceInTextNodes);
// Notifications après sauvegarde (Article publié, mis à jour…)
document.querySelectorAll('#message, .notice').forEach(replaceInTextNodes);
// Boîte de publication — lien "Voir l'article"
replaceInTextNodes(document.querySelector('.submitbox'));
// Options d'écran — "Articles par page"
replaceInTextNodes(document.querySelector('#screen-options-wrap'));
// Bouton de recherche (attribut value + aria-label)
var searchSubmit = document.querySelector('#search-submit');
if (searchSubmit) {
if (searchSubmit.value) {
searchSubmit.value = applyReplacements(searchSubmit.value);
}
var ariaLabel = searchSubmit.getAttribute('aria-label');
if (ariaLabel) {
searchSubmit.setAttribute('aria-label', applyReplacements(ariaLabel));
}
}
// Titre de l'onglet du navigateur
document.title = applyReplacements(document.title);
}
document.addEventListener('DOMContentLoaded', function() {
TA.safeRun('renameArticlesToAnnonces', renameArticlesToAnnonces);
});
})();

View File

@@ -0,0 +1,80 @@
/**
* Pages listes/édition de taxonomies (edit-tags.php, term.php) :
* info-bulles « FR // EN », filtre « Type de programme », mode visuel forcé
* sur les WYSIWYG Pods des pages term.
* Dépend de admin-base.js (window.ThalimAdmin) — enqueue conditionnel.
*/
(function($) {
'use strict';
var TA = window.ThalimAdmin;
var CONFIG = TA.CONFIG;
var safeRun = TA.safeRun;
// Inject a "Type de programme" filter select into the taxonomy search form.
// The form already has hidden taxonomy/post_type fields so the select value
// is submitted with them and picked up by pre_get_terms server-side.
function initProgrammeFilter() {
var form = document.querySelector('form.search-form');
if (!form) return;
var types = [
'Programme subventionné',
'Autre programme',
'Ancien programme'
];
// Read current filter value from the URL.
var params = new URLSearchParams(window.location.search);
var current = params.get('type_de_programme') || '';
var select = document.createElement('select');
select.name = 'type_de_programme';
select.id = 'filter-type-de-programme';
select.style.cssText = 'margin-right:6px;';
var blank = document.createElement('option');
blank.value = '';
blank.textContent = 'Tous les types';
select.appendChild(blank);
types.forEach(function(type) {
var opt = document.createElement('option');
opt.value = type;
opt.textContent = type;
if (type === current) opt.selected = true;
select.appendChild(opt);
});
// Insert before the first <p> (search-box) inside the form.
var searchBox = form.querySelector('p.search-box');
form.insertBefore(select, searchBox || null);
}
$(document).ready(function() {
setTimeout(function() {
safeRun('taxonomyPopovers', function() {
var isTranslateTaxonomy = CONFIG.translateTaxonomies.some(function(tax) {
return window.location.search.indexOf('taxonomy=' + tax) !== -1;
});
if (isTranslateTaxonomy) {
TA.initInfoPopovers('taxonomy');
}
});
safeRun('programmeFilter', function() {
if (window.location.search.indexOf('taxonomy=programme_de_recherche') !== -1) {
initProgrammeFilter();
}
});
}, 100);
// term.php / edit-tags.php : force visual mode on Pods WYSIWYG fields
$(window).on('load', function() {
document.querySelectorAll('.pods-dfv-container-wysiwyg textarea').forEach(function(ta) {
if (!ta.id) return;
TA.ensureVisualMode(ta.id);
});
});
});
})(jQuery);

View File

@@ -1,892 +0,0 @@
(function($) {
'use strict';
function isPostEditPage() {
return window.pagenow === 'post'
|| window.pagenow === 'post-new'
// On CPTs, pagenow is the post_type slug — also catch them via the
// body classes WP sets for any post.php / post-new.php screen.
|| document.body.classList.contains('post-php')
|| document.body.classList.contains('post-new-php');
}
function isProfileEditPage() {
return window.pagenow === 'profile' || window.pagenow === 'user-edit' || window.pagenow === 'user-new';
}
function getProfileForm() {
return document.querySelector('#your-profile, #createuser');
}
function isPodsModal() {
return new URLSearchParams(window.location.search).has('pods_modal');
}
function renameArticlesToAnnonces() {
const replacements = [
[/Tous les articles/g, 'Toutes les annonces'],
[/Ajouter un article/g, 'Ajouter une annonce'],
[/Modifier l.article/g, "Modifier l'annonce"],
[/Pr\u00e9visualiser l.article/g, "Pr\u00e9visualiser l'annonce"],
[/Afficher l.article/g, "Afficher l'annonce"],
[/Voir l.article/g, "Voir l'annonce"],
[/Article publi\u00e9/g, 'Annonce publi\u00e9e'],
[/Article mis \u00e0 jour/g, 'Annonce mise \u00e0 jour'],
[/Article planifi\u00e9/g, 'Annonce planifi\u00e9e'],
[/Articles par page/g, 'Annonces par page'],
[/Articles/g, 'Annonces'],
[/Article/g, 'Annonce'],
[/Rechercher des articles/g, 'Rechercher des annonces'],
];
function applyReplacements(text) {
return replacements.reduce((t, [s, r]) => t.replace(s, r), text);
}
function replaceInTextNodes(el) {
if (!el) return;
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
const nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
nodes.forEach(function(node) {
const replaced = applyReplacements(node.textContent);
if (replaced !== node.textContent) node.textContent = replaced;
});
}
// Menu latéral
replaceInTextNodes(document.querySelector('#menu-posts'));
// Titre de page (h1) et bouton d'ajout
document.querySelectorAll('.wp-heading-inline, .page-title-action').forEach(replaceInTextNodes);
// Notifications après sauvegarde (Article publié, mis à jour…)
document.querySelectorAll('#message, .notice').forEach(replaceInTextNodes);
// Boîte de publication — lien "Voir l'article"
replaceInTextNodes(document.querySelector('.submitbox'));
// Options d'écran — "Articles par page"
replaceInTextNodes(document.querySelector('#screen-options-wrap'));
// Bouton de recherche (attribut value + aria-label)
var searchSubmit = document.querySelector('#search-submit');
if (searchSubmit) {
if (searchSubmit.value) {
searchSubmit.value = applyReplacements(searchSubmit.value);
}
var ariaLabel = searchSubmit.getAttribute('aria-label');
if (ariaLabel) {
searchSubmit.setAttribute('aria-label', applyReplacements(ariaLabel));
}
}
// Titre de l'onglet du navigateur
document.title = applyReplacements(document.title);
}
function updatePostboxVisibility() {
document.querySelectorAll('.postbox').forEach((postBox) => {
if (postBox.id.startsWith('pods')) {
// body-en is controlled by language tabs — never auto-hide it
if (postBox.id === 'pods-meta-body-en') return;
const fields = postBox.querySelectorAll('tr');
const hasVisibleFields = Array.from(fields).some(field => field.style.display !== 'none');
postBox.style.display = hasVisibleFields ? 'block' : 'none';
}
});
}
// Force Visual (TinyMCE) mode on page load.
// WP stores the last-used editor mode in localStorage and restores it at document.ready.
// When Code mode is restored, TinyMCE is never initialised — tinymce.get() returns null.
// Instead, check the wrapper's CSS class:
// tmce-active = Visual mode (fine)
// html-active = Code mode (switch to Visual)
function ensureVisualMode(editorId, attempt) {
attempt = attempt || 0;
if (attempt > 15) return;
var wrap = document.getElementById('wp-' + editorId + '-wrap');
if (!wrap) {
setTimeout(function() { ensureVisualMode(editorId, attempt + 1); }, 200);
return;
}
if (wrap.classList.contains('html-active')) {
var ed = window.tinymce && tinymce.get(editorId);
if (!ed || !ed.initialized) {
// TinyMCE not ready yet — retry rather than calling switchEditors.go() prematurely
setTimeout(function() { ensureVisualMode(editorId, attempt + 1); }, 200);
return;
}
if (typeof switchEditors !== 'undefined') {
switchEditors.go(editorId, 'tmce');
}
return;
}
if (!wrap.classList.contains('tmce-active')) {
// Mode not yet determined — retry
setTimeout(function() { ensureVisualMode(editorId, attempt + 1); }, 200);
}
}
// Phase 1: insert the tab bar and relocate #pods-meta-body-en.
// The DOM move breaks TinyMCE's iframe (browsers reset iframe content on detach),
// so we leave the container visible here and let Pods/TinyMCE initialise normally.
// The broken iframe is repaired by reinitEditor() on first EN tab open.
function setupBodyTabsDom() {
var nativeEditor = document.getElementById('postdivrich') || document.getElementById('postdiv');
var bodyEnBox = document.getElementById('pods-meta-body-en');
if (!nativeEditor || !bodyEnBox) return;
var tabBar = document.createElement('div');
tabBar.className = 'body-lang-tabs';
tabBar.innerHTML =
'<button type="button" class="body-lang-tab is-active" data-panel="fr">Fran\u00e7ais</button>' +
'<button type="button" class="body-lang-tab" data-panel="en">English</button>';
nativeEditor.parentNode.insertBefore(tabBar, nativeEditor);
// Move EN metabox to sit right after the native editor for correct visual layout.
// Do NOT hide it yet — Pods must init TinyMCE with the container visible so the
// iframe can measure its dimensions. Page is still opacity:0 so no flash.
nativeEditor.parentNode.insertBefore(bodyEnBox, nativeEditor.nextSibling);
}
// Rebuild a TinyMCE editor whose iframe is broken (empty/non-interactive).
// This happens when TinyMCE is initialised on a hidden (display:none) element:
// the iframe can't measure dimensions and its document body stays empty.
//
// We reinit from tinyMCEPreInit.mceInit — first trying the editor's own config
// (registered by Pods server-side), falling back to 'content' (the native WP editor).
//
// Inline toolbar positioning fix:
// TinyMCE's 'wordpress' plugin captures document.getElementById(id+'_ifr') during
// 'preinit' — before the iframe is created — so mceIframe is always null.
// Fix: intercept getElementById during preinit so the 'wordpress' plugin captures
// a proxyIframe instead of null. After init, proxy delegates to the real iframe.
function reinitEditor(editorId) {
var ed = window.tinymce && tinymce.get(editorId);
// Preserve existing content before destroying the instance
var savedContent = '';
if (ed) {
try { savedContent = ed.getContent(); } catch (e) {}
ed.remove();
}
if (!savedContent) {
var ta = document.getElementById(editorId);
if (ta) savedContent = ta.value || '';
}
if (!window.tinyMCEPreInit || !window.tinymce) return;
// Use the editor's own server-side config if available, else clone from 'content'
var baseInit = (tinyMCEPreInit.mceInit && tinyMCEPreInit.mceInit[editorId])
|| (tinyMCEPreInit.mceInit && tinyMCEPreInit.mceInit['content']);
if (!baseInit) return;
// Proxy iframe: getBoundingClientRect() falls back to the editor wrap
var wrapId = 'wp-' + editorId + '-wrap';
var proxyIframe = {
getBoundingClientRect: function() {
var el = document.getElementById(wrapId);
return el ? el.getBoundingClientRect()
: { top: 0, left: 0, right: window.innerWidth,
bottom: window.innerHeight, width: window.innerWidth,
height: window.innerHeight };
}
};
var savedGetById = document.getElementById;
var origSetup = baseInit.setup;
var content = savedContent;
tinymce.init($.extend({}, baseInit, {
selector: '#' + editorId,
setup: function(editor) {
if (typeof origSetup === 'function') origSetup(editor);
editor.on('focus', function() {
window.wpActiveEditor = editorId;
});
editor.on('preinit', function() {
document.getElementById = function(id) {
if (id === editorId + '_ifr') return proxyIframe;
return savedGetById.call(document, id);
};
setTimeout(function() {
document.getElementById = savedGetById;
}, 0);
});
editor.on('init', function() {
// Point proxy to real iframe
var realIframe = savedGetById.call(document, editorId + '_ifr');
if (realIframe) {
proxyIframe.getBoundingClientRect = function() {
return realIframe.getBoundingClientRect();
};
}
// Restore content that was in the textarea
if (content) {
editor.setContent(content);
}
});
}
}));
}
// Phase 2: wire tab click handlers — runs at t=100ms after metabox reordering.
function initBodyLanguageTabs() {
var nativeEditor = document.getElementById('postdivrich') || document.getElementById('postdiv');
var bodyEnBox = document.getElementById('pods-meta-body-en');
var tabBar = document.querySelector('.body-lang-tabs');
if (!nativeEditor || !bodyEnBox || !tabBar) {
// body_en not available (e.g. contributor role) — still force visual mode on main editor
if (nativeEditor) ensureVisualMode('content');
return;
}
var enEditorId = 'pods-form-ui-pods-meta-body-en';
var enTmceReady = false;
// Hide EN panel — page is still opacity:0, user won't see the switch
bodyEnBox.style.display = 'none';
tabBar.querySelectorAll('.body-lang-tab').forEach(function(btn) {
btn.addEventListener('click', function() {
tabBar.querySelectorAll('.body-lang-tab').forEach(function(b) {
b.classList.remove('is-active');
});
btn.classList.add('is-active');
var revealedPanel;
if (btn.dataset.panel === 'fr') {
bodyEnBox.style.display = 'none';
nativeEditor.style.opacity = '0';
nativeEditor.style.display = '';
revealedPanel = nativeEditor;
} else {
nativeEditor.style.display = 'none';
bodyEnBox.style.opacity = '0';
bodyEnBox.style.display = 'block';
revealedPanel = bodyEnBox;
if (!enTmceReady) {
enTmceReady = true;
// Reinit while container is visible so TinyMCE can measure dimensions
reinitEditor(enEditorId);
}
}
// Notify TinyMCE to reflow, then fade in once layout is correct
setTimeout(function() {
window.dispatchEvent(new Event('resize'));
requestAnimationFrame(function() {
requestAnimationFrame(function() {
revealedPanel.style.opacity = '';
});
});
}, 50);
});
});
// Ensure both editors start in Visual (not Code) mode
ensureVisualMode('content');
ensureVisualMode(enEditorId);
}
function groupAxesCheckboxes() {
if (!window.thalimAxesGroups || !thalimAxesGroups.length) return;
var row = document.querySelector('.pods-form-ui-row-name-axes-thematiques');
if (!row) return;
var list = row.querySelector('ul');
if (!list) return;
// Already grouped — nothing to do
if (list.querySelector('.axes-group-label')) return;
// Map existing <li> by checkbox value; preserve "add new" button
var liMap = {};
var addNewItem = null;
list.querySelectorAll('li').forEach(function(li) {
if (li.classList.contains('pods-pick-add-new')) { addNewItem = li; return; }
var cb = li.querySelector('input[type="checkbox"]');
if (cb) liMap[cb.value] = li;
});
// Rebuild list in group order
list.innerHTML = '';
thalimAxesGroups.forEach(function(group) {
var labelLi = document.createElement('li');
labelLi.className = 'axes-group-label';
labelLi.textContent = group.label;
list.appendChild(labelLi);
group.terms.forEach(function(term) {
var li = liMap[String(term.id)];
if (li) list.appendChild(li);
});
});
if (addNewItem) list.appendChild(addNewItem);
}
var REF_BIB_EDITOR_ID = 'pods-form-ui-pods-meta-reference-bibliographique';
var refBibReinited = false;
// Reinit the référence bibliographique TinyMCE editor.
// Called at page load (if the field is already visible) and by the
// MutationObserver (when the field becomes visible after a category change).
function initRefBibEditor() {
if (refBibReinited) return;
var row = document.querySelector('.pods-form-ui-row-name-reference-bibliographique');
if (!row || row.style.display === 'none') return;
refBibReinited = true;
reinitEditor(REF_BIB_EDITOR_ID);
ensureVisualMode(REF_BIB_EDITOR_ID);
}
function initAxesGroupObserver() {
// Pods shows/hides conditional rows by removing inline style="display:none"
// Watch the entire Pods meta form for style changes on the axes row
var podsForm = document.querySelector('.pods-pick-values, #pods-meta-champs-contextuels, form#post');
if (!podsForm) podsForm = document.body;
var observer = new MutationObserver(function(mutations) {
for (var i = 0; i < mutations.length; i++) {
var target = mutations[i].target;
if (target.classList && target.classList.contains('pods-form-ui-row-name-axes-thematiques')) {
if (target.style.display !== 'none') {
setTimeout(groupAxesCheckboxes, 50);
}
}
// Reinit TinyMCE on the référence bibliographique field when its
// row becomes visible — Pods hides it with display:none which breaks
// the TinyMCE iframe. Only reinit once per page load.
if (!refBibReinited && target.classList &&
target.classList.contains('pods-form-ui-row-name-reference-bibliographique')) {
if (target.style.display !== 'none') {
setTimeout(initRefBibEditor, 100);
}
}
}
});
observer.observe(podsForm, { attributes: true, attributeFilter: ['style'], subtree: true });
}
function initPostEditPage() {
// Disable category options (CSS handles the color)
const categorieSelect = document.querySelector('#pods-form-ui-pods-meta-categorie');
if (categorieSelect) {
const categoriesToDisable = ['1', '12', '5', '20'];
categorieSelect.querySelectorAll('option').forEach(option => {
if (categoriesToDisable.includes(option.value)) {
option.disabled = true;
}
});
}
// Reorder meta boxes
const sideSortables = document.querySelector('#side-sortables');
if (sideSortables) {
const typeDannonce = document.querySelector('#pods-meta-type-dannonce');
const affichageAccueil = document.querySelector('#pods-meta-affichage-sur-laccueil');
const thematique = document.querySelector('#pods-meta-thematique');
if (typeDannonce) sideSortables.prepend(typeDannonce);
if (affichageAccueil) sideSortables.appendChild(affichageAccueil);
if (thematique) sideSortables.appendChild(thematique);
}
const submitDiv = document.querySelector('#submitdiv');
if (submitDiv && submitDiv.parentNode) {
submitDiv.parentNode.appendChild(submitDiv);
}
const champsContextuels = document.querySelector('#pods-meta-champs-contextuels');
if (champsContextuels && champsContextuels.parentNode) {
champsContextuels.parentNode.prepend(champsContextuels);
}
initBodyLanguageTabs();
initRefBibEditor();
groupAxesCheckboxes();
initAxesGroupObserver();
updatePostboxVisibility();
initDatePickerPopoverFix();
initInfoPopovers();
// Place #pods-meta-documents-joints in #normal-sortables, right after
// #pods-meta-champs-contextuels. This keeps it out of #post-body-content
// (the body editor section) regardless of whether champsContextuels is
// currently visible. When champsContextuels is hidden it takes no space,
// so documentsJoints simply appears first in #normal-sortables.
const documentsJoints = document.querySelector('#pods-meta-documents-joints');
if (documentsJoints) {
if (champsContextuels && champsContextuels.parentNode) {
champsContextuels.parentNode.insertBefore(documentsJoints, champsContextuels.nextSibling);
} else {
const normalSortables = document.querySelector('#normal-sortables');
if (normalSortables) normalSortables.prepend(documentsJoints);
}
}
// Inject separator row for the Membres grid layout
var membresTbody = document.querySelector('#pods-meta-membres .form-table tbody');
if (membresTbody && !membresTbody.querySelector('.membres-grid-separator')) {
var sep = document.createElement('tr');
sep.className = 'membres-grid-separator';
membresTbody.appendChild(sep);
}
updateMembresGridSeparator();
}
var INFO_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 11v6"/><path d="M12 8v.01" stroke-width="2"/></svg>';
var TRANSLATE_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>';
var TRANSLATE_LINES = [
'Traduction en anglais apr\u00e8s //',
'ex\u00a0: Texte en fran\u00e7ais // English text'
];
// Tips without a `page` key default to 'post'.
// type: 'translate' uses the globe icon + green button style.
var INFO_TIPS = [
// --- post edit page: info ---
{
selector: '.wp-heading-inline',
lines: [
'Saisir le titre anglais apr\u00e8s //',
'ex\u00a0: Titre de l\u2019annonce // Title of the announcement'
]
},
{
selector: '#pods-meta-documents-joints .postbox-header h2',
lines: [
'Ajouter les images dans les documents.',
'Ajouter les l\u00e9gendes comme titre du document.'
]
},
{
selector: '#pods-meta-membres .postbox-header h2',
lines: [
'Le champ fonction change le libell\u00e9 de la liste de personnes cit\u00e9es.',
'Le champ membre permet de lister les membres de Thalim li\u00e9s \u00e0 l\u2019annonce.',
'Le champ autre personnes permet de lister des personnes ext\u00e9rieures \u00e0 Thalim.'
]
},
{
selector: '#pods-meta-dates .postbox-header h2',
lines: [
'Pour entrer une date sans l\u2019heure, r\u00e9gler l\u2019heure sur 00\u202f:00.'
]
},
{
selector: '#pods-meta-affichage-sur-laccueil .postbox-header h2',
lines: [
'\u00c9pingler l\u2019annonce dans le diaporama la fait s\u2019afficher avant les autres.'
]
},
{
selector: '#pods-meta-medias .postbox-header h2',
lines: [
'Pour ajouter un m\u00e9dia Canal\u00a0U, copier le lien depuis \u00ab\u00a0Citer cette ressource\u00a0\u00bb.',
'ex\u00a0: https://www.canal-u.tv/166564'
]
},
// --- post edit page: translate ---
{ type: 'translate', selector: '#pods-meta-documents-joints .postbox-header h2', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-sous-titre th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-lieu th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-titre-du-lien-externe-1 th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-titre-du-lien-externe-2 th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-titre-du-lien-externe-3 th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-fonction-organisation th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-fonction-intervention th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-fonction-candidat th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-fonction-realisation th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-fonction-dirige th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-fonction-redaction th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-fonction-auteur th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-fonction-responsable th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-autre-fonction-autre th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-autre-fonction-concerne th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-autre-fonction-directeur th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-autre-fonction-direction-d-ouvrage th',lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-autre-fonction-intervenant th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-autre-fonction-participants th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-type-autre th', lines: TRANSLATE_LINES },
// --- contenu_general edit page: translate ---
{ type: 'translate', selector: '.pods-form-ui-row-name-umr th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-thalim th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-siecles th', lines: TRANSLATE_LINES },
// --- user/profile edit page: translate ---
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-titre-du-lien-1 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-titre-du-lien-2 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-titre-du-lien-3 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-titre-du-lien-4 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-complement-de-role-1 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-complement-de-role-2 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-complement-de-role-3 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-affichage-du-statut-1 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-affichage-du-statut-2 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-affichage-du-statut-3 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-affiliation-autre th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-titre-de-these th', lines: TRANSLATE_LINES },
// --- taxonomy edit pages: translate ---
{ type: 'translate', page: 'taxonomy', selector: 'label[for="name"]', lines: TRANSLATE_LINES },
// --- user/profile edit page: info ---
{
page: 'user',
selector: '.pods-form-ui-label-pods-meta-identifiant-hal',
lines: [
'Renseigner votre idHAL (en lettres), pas votre PersonId (en chiffres).'
]
},
{
page: 'user',
selector: '.pods-form-ui-label-pods-meta-affichage-du-statut-1',
lines: [
'Texte de statut affiché sur le profil publique.'
]
}
];
var _popoverCloseHandlerRegistered = false;
function initInfoPopovers(currentPage) {
currentPage = currentPage || 'post';
INFO_TIPS.forEach(function(tip) {
if ((tip.page || 'post') !== currentPage) return;
var el = document.querySelector(tip.selector);
if (!el) return;
var isTranslate = tip.type === 'translate';
var btn = document.createElement('button');
btn.type = 'button';
btn.className = isTranslate ? 'thalim-translate-btn' : 'thalim-info-btn';
btn.setAttribute('aria-label', isTranslate ? 'Traduction bilingue' : 'Informations');
btn.innerHTML = isTranslate ? TRANSLATE_ICON : INFO_ICON;
var popover = document.createElement('div');
popover.className = 'thalim-info-popover' + (isTranslate ? ' thalim-translate-popover' : '');
popover.innerHTML = tip.lines.map(function(line) {
return '<p>' + line + '</p>';
}).join('');
var wrapper = document.createElement('span');
wrapper.className = 'thalim-info-wrapper';
wrapper.appendChild(btn);
wrapper.appendChild(popover);
el.appendChild(wrapper);
btn.addEventListener('click', function(e) {
e.stopPropagation();
var isOpen = popover.classList.contains('is-open');
document.querySelectorAll('.thalim-info-popover.is-open').forEach(function(p) {
p.classList.remove('is-open');
});
if (!isOpen) {
var rect = btn.getBoundingClientRect();
popover.style.top = (rect.bottom + 6) + 'px';
popover.style.left = (rect.left + rect.width / 2) + 'px';
popover.classList.add('is-open');
}
});
popover.addEventListener('click', function(e) {
e.stopPropagation();
});
});
if (!_popoverCloseHandlerRegistered) {
_popoverCloseHandlerRegistered = true;
document.addEventListener('click', function() {
document.querySelectorAll('.thalim-info-popover.is-open').forEach(function(p) {
p.classList.remove('is-open');
});
});
}
}
// Only native WP field sections — never touch Pods tables (they may contain TinyMCE editors)
var PROFILE_SECTION_KEYS = [
'user-language-wrap',
'user-first-name-wrap',
'user-email-wrap',
'user-pass1-wrap',
'upload-avatar-row',
];
// Desired order. Groups of 2 are wrapped in a flex row and displayed side by side.
var PROFILE_ORDER = [
['user-first-name-wrap', 'upload-avatar-row'],
['user-email-wrap'],
['user-language-wrap', 'user-pass1-wrap'],
];
function reorderProfileSections() {
var form = getProfileForm();
if (!form) return;
var pairMap = {};
form.querySelectorAll('table.form-table').forEach(function(table) {
PROFILE_SECTION_KEYS.forEach(function(key) {
if (pairMap[key] || !table.querySelector('.' + key)) return;
// Find the associated heading: first try preceding sibling in same parent,
// then look for an h2/h3 inside the same wrapper element.
var h2 = null;
var el = table.previousElementSibling;
while (el) {
if (el.tagName === 'H2' || el.tagName === 'H3') { h2 = el; break; }
if (el.tagName === 'TABLE') break;
el = el.previousElementSibling;
}
if (!h2 && table.parentElement !== form) {
h2 = table.parentElement.querySelector('h2, h3');
}
// The unit to move: if h2 and table share a non-form wrapper, move the wrapper.
var wrapper = null;
if (h2 && h2.parentElement !== form && h2.parentElement === table.parentElement) {
wrapper = h2.parentElement;
}
pairMap[key] = { h2: h2, table: table, wrapper: wrapper };
});
});
// Remove all matched units from DOM (dedup by actual element)
var removed = new Set();
function removeEl(el) {
if (el && !removed.has(el)) { removed.add(el); el.remove(); }
}
Object.values(pairMap).forEach(function(unit) {
if (unit.wrapper) { removeEl(unit.wrapper); }
else { removeEl(unit.h2); removeEl(unit.table); }
});
// Re-insert in declared order before the submit button
var submitAnchor = form.querySelector('p.submit');
function append(el) {
if (submitAnchor && submitAnchor.parentNode) form.insertBefore(el, submitAnchor);
else form.appendChild(el);
}
function appendUnit(unit) {
if (unit.wrapper) { append(unit.wrapper); }
else { if (unit.h2) append(unit.h2); append(unit.table); }
}
PROFILE_ORDER.forEach(function(group) {
var available = group.filter(function(key) { return !!pairMap[key]; });
if (!available.length) return;
// Dedup: two keys may resolve to the same table/wrapper
var seen = new Set();
var units = [];
available.forEach(function(key) {
var unit = pairMap[key];
var id = unit.wrapper || unit.table;
if (!seen.has(id)) { seen.add(id); units.push(unit); }
});
if (units.length === 1) {
appendUnit(units[0]);
} else {
var row = document.createElement('div');
row.className = 'profile-section-row';
units.forEach(function(unit) {
var col = document.createElement('div');
col.className = 'profile-section-col';
if (unit.wrapper) { col.appendChild(unit.wrapper); }
else { if (unit.h2) col.appendChild(unit.h2); col.appendChild(unit.table); }
row.appendChild(col);
});
append(row);
}
});
}
function initProfileEditors() {
reorderProfileSections();
initInfoPopovers('user');
// Hide the "À propos du compte" section heading
document.querySelectorAll('#your-profile h2, #adduser h2, #createuser h2').forEach(function(h2) {
if (h2.textContent.trim() === '\u00c0 propos du compte') {
h2.style.display = 'none';
}
});
// Rename "Rôle" label to "Rôle sur le site"
var roleLabel = document.querySelector('label[for="role"]');
if (roleLabel && roleLabel.textContent.trim() === 'R\u00f4le') {
roleLabel.textContent = 'R\u00f4le sur le site';
}
}
// Gutenberg's Popover component closes on outside click via focusout detection.
// But if focus never enters the popover, focusout never fires and clicking outside
// does nothing. Fix: focus the popover container as soon as it appears in the DOM.
function initDatePickerPopoverFix() {
var observer = new MutationObserver(function(mutations) {
for (var i = 0; i < mutations.length; i++) {
var added = mutations[i].addedNodes;
for (var j = 0; j < added.length; j++) {
var node = added[j];
if (node.nodeType !== 1) continue;
var content = node.classList.contains('components-popover__content')
? node
: node.querySelector && node.querySelector('.components-popover__content');
if (content) {
var c = content;
requestAnimationFrame(function() {
if (!c.hasAttribute('tabindex')) c.setAttribute('tabindex', '-1');
c.focus();
});
}
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
function updateMembresGridSeparator() {
var sep = document.querySelector('#pods-meta-membres .membres-grid-separator');
if (!sep) return;
var autreRows = document.querySelectorAll('#pods-meta-membres [class*="pods-form-ui-row-name-autre-"]');
var anyVisible = Array.from(autreRows).some(function(row) {
return row.style.display !== 'none';
});
sep.style.display = anyVisible ? '' : 'none';
}
// Inject a "Type de programme" filter select into the taxonomy search form.
// The form already has hidden taxonomy/post_type fields so the select value
// is submitted with them and picked up by pre_get_terms server-side.
function initProgrammeFilter() {
var form = document.querySelector('form.search-form');
if (!form) return;
var types = [
'Programme subventionné',
'Autre programme',
'Ancien programme'
];
// Read current filter value from the URL.
var params = new URLSearchParams(window.location.search);
var current = params.get('type_de_programme') || '';
var select = document.createElement('select');
select.name = 'type_de_programme';
select.id = 'filter-type-de-programme';
select.style.cssText = 'margin-right:6px;';
var blank = document.createElement('option');
blank.value = '';
blank.textContent = 'Tous les types';
select.appendChild(blank);
types.forEach(function(type) {
var opt = document.createElement('option');
opt.value = type;
opt.textContent = type;
if (type === current) opt.selected = true;
select.appendChild(opt);
});
// Insert before the first <p> (search-box) inside the form.
var searchBox = form.querySelector('p.search-box');
form.insertBefore(select, searchBox || null);
}
$(document).ready(function() {
renameArticlesToAnnonces();
if (isPostEditPage()) {
setupBodyTabsDom();
}
setTimeout(() => {
if (isPostEditPage()) {
initPostEditPage();
}
if (isProfileEditPage()) {
initProfileEditors();
}
var TRANSLATE_TAXONOMIES = ['axe_thematique', 'programme_de_recherche', 'post_tag'];
var isTaxonomyListPage = TRANSLATE_TAXONOMIES.some(function(tax) {
return window.location.search.indexOf('taxonomy=' + tax) !== -1;
});
if (isTaxonomyListPage) {
initInfoPopovers('taxonomy');
}
if (window.location.search.indexOf('taxonomy=programme_de_recherche') !== -1) {
initProgrammeFilter();
}
document.body.classList.add('admin-mods-ready');
}, 100);
// Fallback: force reveal after 2s in case the 100ms path failed (e.g. JS error mid-init)
setTimeout(() => {
document.body.classList.add('admin-mods-ready');
}, 2000);
$('#pods-form-ui-pods-meta-categorie').change(function() {
setTimeout(function() {
updatePostboxVisibility();
updateMembresGridSeparator();
}, 10);
});
if (isProfileEditPage() || window.pagenow === 'edit-tags' || window.pagenow === 'term') {
$(window).on('load', function() {
var scope = getProfileForm() || document;
scope.querySelectorAll('.pods-dfv-container-wysiwyg textarea').forEach(function(ta) {
if (!ta.id) return;
ensureVisualMode(ta.id);
});
});
}
if (isPodsModal()) {
var lockSeanceCategory = function() {
var itemId = $('#post_ID').val();
if (window.PodsDFV && itemId) {
window.PodsDFV.setFieldValue('post', itemId, 'categorie', '12', 0);
}
// Lock category select to 12 in iframe — delay to run after Pods React re-render
setTimeout(function() {
var $select = $('#pods-form-ui-pods-meta-categorie');
if ($select.length) {
$select.find('option').each(function() {
this.disabled = this.value !== '12';
});
$select.val('12');
}
updatePostboxVisibility();
}, 200);
};
// Dans l'iframe de la modale, window.load peut déjà être passé au moment
// où ce code s'exécute : s'abonner à un load déjà émis ne rejoue rien.
// On exécute donc tout de suite si la page est déjà chargée.
if (document.readyState === 'complete') {
lockSeanceCategory();
} else {
$(window).on('load', lockSeanceCategory);
}
}
});
})(jQuery);

View File

@@ -1,119 +0,0 @@
(function($) {
'use strict';
var STORAGE_PREFIX = 'thalim_form_restore_';
var MAX_AGE_MS = 10 * 60 * 1000; // 10 min
function getPostId() {
return $('#post_ID').val() || 'new';
}
function getStorageKey() {
return STORAGE_PREFIX + getPostId();
}
function saveFormData() {
var data = { timestamp: Date.now() };
var $title = $('#title');
if ($title.length) data.title = $title.val();
var $content = $('#content');
if ($content.length) {
if (typeof tinyMCE !== 'undefined' && tinyMCE.get('content')) {
data.content = tinyMCE.get('content').getContent();
} else {
data.content = $content.val();
}
}
$('[name^="pods_meta_"]').each(function() {
data[this.name] = $(this).val();
});
try {
sessionStorage.setItem(getStorageKey(), JSON.stringify(data));
} catch (e) {}
}
function restoreFormData() {
var navEntries = performance.getEntriesByType('navigation');
if (!navEntries.length || navEntries[0].type !== 'back_forward') return;
var key = getStorageKey();
var stored, data;
try {
stored = sessionStorage.getItem(key);
if (!stored) return;
data = JSON.parse(stored);
} catch (e) { return; }
if (!data.timestamp || Date.now() - data.timestamp > MAX_AGE_MS) {
try { sessionStorage.removeItem(key); } catch (e) {}
return;
}
// Restaurer titre
if (data.title !== undefined) $('#title').val(data.title);
// Restaurer contenu (TinyMCE ou textarea brut)
if (data.content !== undefined) {
$('#content').val(data.content);
if (typeof tinyMCE !== 'undefined') {
var ed = tinyMCE.get('content');
if (ed) {
ed.setContent(data.content);
} else {
tinyMCE.on('AddEditor', function(e) {
if (e.editor.id === 'content') {
e.editor.on('init', function() {
e.editor.setContent(data.content);
});
}
});
}
}
}
// Restaurer champs Pods après rendu React
var restorePods = function() {
setTimeout(function() {
var postId = getPostId();
Object.keys(data).forEach(function(name) {
if (!name.startsWith('pods_meta_')) return;
var fieldName = name.replace(/^pods_meta_/, '');
var value = data[name];
var $el = $('[name="' + name + '"]');
if ($el.length) $el.val(value).trigger('change');
if (window.PodsDFV && postId) {
try { window.PodsDFV.setFieldValue('post', postId, fieldName, value, 0); } catch (e) {}
}
});
}, 300);
};
if (document.readyState === 'complete') {
restorePods();
} else {
$(window).on('load', restorePods);
}
// Notice informative
var $notice = $('<div class="notice notice-info is-dismissible"><p>Votre contenu a \u00e9t\u00e9 restaur\u00e9 suite \u00e0 une erreur de validation. V\u00e9rifiez les champs obligatoires avant de publier.</p></div>');
$('#wpbody-content').find('.wrap').first().find('h1').after($notice);
}
$(document).ready(function() {
if (window.pagenow !== 'post' && window.pagenow !== 'post-new') return;
// Nettoyage si sauvegarde réussie
if ($('.notice-success, #message.updated').length) {
try { sessionStorage.removeItem(getStorageKey()); } catch (e) {}
return;
}
$('#post').on('submit', saveFormData);
restoreFormData();
});
})(jQuery);

View File

@@ -1,4 +1,15 @@
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
// ── Row navigation (replaces inline onclick) ──────────────
// Delegated listener: rows carry their target in data-url.
document.querySelectorAll('.membres-table tbody').forEach(function (tbody) {
tbody.addEventListener('click', function (e) {
var row = e.target.closest('tr[data-url]');
if (row && row.dataset.url) {
window.location.href = row.dataset.url;
}
});
});
// ── Filters toggle ──────────────────────────────────────── // ── Filters toggle ────────────────────────────────────────
var membresToggleBtn = document.getElementById('membres-filters-toggle'); var membresToggleBtn = document.getElementById('membres-filters-toggle');
var membresFiltersEl = document.getElementById('membres-filters'); var membresFiltersEl = document.getElementById('membres-filters');

View File

@@ -1,15 +1,16 @@
<?php <?php
$context = Timber::context(); $context = Timber::context();
$excluded_cat_ids = [12, 31]; // Séance de séminaire, Non classé // Séance de séminaire, Non classé (+ Vie du labo pour les non-connectés)
if ( ! is_user_logged_in() ) $excluded_cat_ids[] = 9; // Vie du labo $excluded_cat_ids = thalim_archive_excluded_cat_ids();
// Read filter query params // Read filter query params
$active_axe = isset($_GET['axe']) ? intval($_GET['axe']) : 0; $f = thalim_archive_read_filters();
$active_date_from = isset($_GET['date_from']) ? sanitize_text_field($_GET['date_from']) : ''; $active_axe = $f['axe'];
$active_date_to = isset($_GET['date_to']) ? sanitize_text_field($_GET['date_to']) : ''; $active_date_from = $f['date_from'];
$active_cat_id = isset($_GET['filter_cat']) ? intval($_GET['filter_cat']) : 0; $active_date_to = $f['date_to'];
$filter_autres = isset($_GET['filter_autres']) ? 1 : 0; $active_cat_id = $f['cat_id'];
$filter_autres = $f['filter_autres'];
$context['active_axe'] = $active_axe; $context['active_axe'] = $active_axe;
$context['active_date_from'] = $active_date_from; $context['active_date_from'] = $active_date_from;
@@ -36,13 +37,7 @@ if ( $active_cat_id && ! $filter_autres ) {
} }
// Determine active rubrique // Determine active rubrique
$active_rubrique_id = 0; $active_rubrique_id = thalim_archive_active_rubrique($active_cat_id);
if ($active_cat_id) {
$active_cat_obj = get_category($active_cat_id);
$active_rubrique_id = ($active_cat_obj && $active_cat_obj->parent)
? $active_cat_obj->parent
: $active_cat_id;
}
$context['active_rubrique'] = $active_rubrique_id; $context['active_rubrique'] = $active_rubrique_id;
// Base filter params preserved across all filter links // Base filter params preserved across all filter links
@@ -76,7 +71,6 @@ $query_args = [
'posts_per_page' => 12, 'posts_per_page' => 12,
'orderby' => 'date', 'orderby' => 'date',
'order' => 'DESC', 'order' => 'DESC',
'lang' => '',
'tax_query' => $tax_query, 'tax_query' => $tax_query,
'thalim_event_date_order' => true, 'thalim_event_date_order' => true,
]; ];
@@ -100,61 +94,20 @@ $context['filter_axes'] = $current_axes;
$page_url = get_permalink(); $page_url = get_permalink();
$all_cats = get_categories(['taxonomy' => 'category', 'hide_empty' => false, 'exclude' => $excluded_cat_ids]); $all_cats = get_categories(['taxonomy' => 'category', 'hide_empty' => false, 'exclude' => $excluded_cat_ids]);
$filter_parents = []; // Liens de filtre : navigation vers la page de catégorie, en conservant axe/dates
foreach ($all_cats as $cat) { $make_cat_link = function ($cat) use ($base_filter_params) {
if ($cat->parent == 0) { return $base_filter_params
$filter_parents[] = [ ? add_query_arg($base_filter_params, get_category_link($cat->term_id))
'id' => $cat->term_id, : get_category_link($cat->term_id);
'name' => thalim_cat_name($cat), };
'slug' => $cat->slug, $context['filter_parents'] = thalim_archive_filter_parents($all_cats, $make_cat_link);
'link' => $base_filter_params
? add_query_arg($base_filter_params, get_category_link($cat->term_id)) $filter_categories = thalim_archive_filter_children($all_cats, $active_rubrique_id, $make_cat_link);
: get_category_link($cat->term_id),
];
}
}
$context['filter_parents'] = $filter_parents;
$filter_categories = [];
if ($active_rubrique_id) {
foreach ($all_cats as $cat) {
if ($cat->parent == $active_rubrique_id) {
$filter_categories[] = [
'id' => $cat->term_id,
'name' => thalim_cat_name($cat),
'slug' => $cat->slug,
'link' => $base_filter_params
? add_query_arg($base_filter_params, get_category_link($cat->term_id))
: get_category_link($cat->term_id),
];
}
}
}
// Add "Autres" entry if active rubrique has posts directly assigned to it // Add "Autres" entry if active rubrique has posts directly assigned to it
if ($active_rubrique_id && !empty($filter_categories)) { if ($active_rubrique_id && !empty($filter_categories) && thalim_rubrique_has_direct_posts($active_rubrique_id)) {
$lang = thalim_current_language(); $params = array_filter(array_merge($base_filter_params, ['filter_cat' => $active_rubrique_id, 'filter_autres' => 1]));
$direct_check = new WP_Query([ $filter_categories[] = thalim_archive_autres_entry(add_query_arg($params, $page_url));
'post_type' => 'post',
'posts_per_page' => 1,
'fields' => 'ids',
'no_found_rows' => true,
'lang' => '',
'tax_query' => [[
'taxonomy' => 'category',
'field' => 'term_id',
'terms' => [$active_rubrique_id],
'include_children' => false,
]],
]);
if ($direct_check->have_posts()) {
$params = array_filter(array_merge($base_filter_params, ['filter_cat' => $active_rubrique_id, 'filter_autres' => 1]));
$filter_categories[] = [
'id' => 'autres',
'name' => $lang === 'en' ? 'Other' : 'Autres',
'slug' => 'autres',
'link' => add_query_arg($params, $page_url),
];
}
} }
$context['filter_categories'] = $filter_categories; $context['filter_categories'] = $filter_categories;

View File

@@ -91,18 +91,19 @@ unset( $group );
$context['axes_groups'] = array_values( $axes_map ); $context['axes_groups'] = array_values( $axes_map );
// ── Body (English override) ────────────────────────────────── // ── Body (English override) ──────────────────────────────────
$context['body_en'] = apply_filters( 'the_content', get_post_meta( $page_id, 'body_en', true ) ?: '' ); $context['body_en'] = apply_filters( 'the_content', wp_kses_post( get_post_meta( $page_id, 'body_en', true ) ?: '' ) );
// ── WYSIWYG fields ──────────────────────────────────────────── // ── WYSIWYG fields ────────────────────────────────────────────
$context['partenaires_internationaux'] = wpautop( ( $labo_lang === 'en' && get_post_meta( $page_id, 'partenaires_internationaux_en', true ) ) // wp_kses_post: rendus en |raw dans le template (autoescape off)
$context['partenaires_internationaux'] = wpautop( wp_kses_post( ( $labo_lang === 'en' && get_post_meta( $page_id, 'partenaires_internationaux_en', true ) )
? get_post_meta( $page_id, 'partenaires_internationaux_en', true ) ? get_post_meta( $page_id, 'partenaires_internationaux_en', true )
: ( get_post_meta( $page_id, 'partenaires_internationaux', true ) ?: '' ) ); : ( get_post_meta( $page_id, 'partenaires_internationaux', true ) ?: '' ) ) );
$context['partenaires_nationaux'] = wpautop( ( $labo_lang === 'en' && get_post_meta( $page_id, 'partenaires_nationaux_en', true ) ) $context['partenaires_nationaux'] = wpautop( wp_kses_post( ( $labo_lang === 'en' && get_post_meta( $page_id, 'partenaires_nationaux_en', true ) )
? get_post_meta( $page_id, 'partenaires_nationaux_en', true ) ? get_post_meta( $page_id, 'partenaires_nationaux_en', true )
: ( get_post_meta( $page_id, 'partenaires_nationaux', true ) ?: '' ) ); : ( get_post_meta( $page_id, 'partenaires_nationaux', true ) ?: '' ) ) );
$context['bibliotheques'] = wpautop( ( $labo_lang === 'en' && get_post_meta( $page_id, 'bibliotheques_en', true ) ) $context['bibliotheques'] = wpautop( wp_kses_post( ( $labo_lang === 'en' && get_post_meta( $page_id, 'bibliotheques_en', true ) )
? get_post_meta( $page_id, 'bibliotheques_en', true ) ? get_post_meta( $page_id, 'bibliotheques_en', true )
: ( get_post_meta( $page_id, 'bibliotheques', true ) ?: '' ) ); : ( get_post_meta( $page_id, 'bibliotheques', true ) ?: '' ) ) );
// ── Edit link ───────────────────────────────────────────────── // ── Edit link ─────────────────────────────────────────────────
$context['page_edit_link'] = current_user_can( 'edit_page', $page_id ) ? get_edit_post_link( $page_id ) : ''; $context['page_edit_link'] = current_user_can( 'edit_page', $page_id ) ? get_edit_post_link( $page_id ) : '';

View File

@@ -16,7 +16,7 @@ foreach ( $terms as $term ) {
$item = [ $item = [
'name' => $term->name, 'name' => $term->name,
'description' => wpautop( $term->description ), 'description' => wpautop( wp_kses_post( $term->description ) ),
'url' => get_term_link( $term ), 'url' => get_term_link( $term ),
'annee_fin' => (int) get_term_meta( $term->term_id, 'annee_fin', true ), 'annee_fin' => (int) get_term_meta( $term->term_id, 'annee_fin', true ),
]; ];

26
phpcs.xml.dist Normal file
View File

@@ -0,0 +1,26 @@
<?xml version="1.0"?>
<ruleset name="THALIM Theme">
<description>WordPress Coding Standards pour le thème THALIM.</description>
<!-- Installation (une fois, nécessite Composer) :
composer global require wp-coding-standards/wpcs dealerdirect/phpcodesniffer-composer-installer
Exécution depuis ce dossier : phpcs -->
<file>.</file>
<exclude-pattern>vendor/*</exclude-pattern>
<exclude-pattern>node_modules/*</exclude-pattern>
<exclude-pattern>assets/vendor/*</exclude-pattern>
<arg name="extensions" value="php"/>
<arg value="sp"/>
<rule ref="WordPress-Core">
<!-- Le code existant utilise la syntaxe courte de tableaux et
l'indentation 4 espaces — on ne reformate pas tout le thème. -->
<exclude name="Universal.Arrays.DisallowShortArraySyntax"/>
<exclude name="WordPress.WhiteSpace.PrecisionAlignment"/>
<exclude name="Generic.WhiteSpace.DisallowSpaceIndent"/>
</rule>
<rule ref="WordPress.Security"/>
<rule ref="WordPress.DB.PreparedSQL"/>
</ruleset>

View File

@@ -1,18 +1,18 @@
<?php <?php
$context = Timber::context(); $context = Timber::context();
// Séances de séminaire (cat 12) are included: post-card-helpers rewrites their // Les séances de séminaire sont incluses : post-card-helpers réécrit leur
// link to the parent séminaire + #seance-{ID} hash. // lien vers le séminaire parent + ancre #seance-{ID}.
$excluded_cat_ids = [31]; // Non classé $excluded_cat_ids = thalim_archive_excluded_cat_ids( false ); // Non classé (+ Vie du labo non connectés)
if ( ! is_user_logged_in() ) $excluded_cat_ids[] = 9; // Vie du labo
$search_query = get_search_query(); $search_query = get_search_query();
// Read filter query params // Read filter query params
$active_axe = isset($_GET['axe']) ? intval($_GET['axe']) : 0; $f = thalim_archive_read_filters();
$active_date_from = isset($_GET['date_from']) ? sanitize_text_field($_GET['date_from']) : ''; $active_axe = $f['axe'];
$active_date_to = isset($_GET['date_to']) ? sanitize_text_field($_GET['date_to']) : ''; $active_date_from = $f['date_from'];
$active_cat_id = isset($_GET['filter_cat']) ? intval($_GET['filter_cat']) : 0; $active_date_to = $f['date_to'];
$filter_autres = isset($_GET['filter_autres']) ? 1 : 0; $active_cat_id = $f['cat_id'];
$filter_autres = $f['filter_autres'];
$context['search_query'] = $search_query; $context['search_query'] = $search_query;
$context['active_axe'] = $active_axe; $context['active_axe'] = $active_axe;
@@ -23,13 +23,7 @@ $context['active_cat_id'] = $active_cat_id;
$context['filter_autres'] = $filter_autres; $context['filter_autres'] = $filter_autres;
// Determine active rubrique // Determine active rubrique
$active_rubrique_id = 0; $active_rubrique_id = thalim_archive_active_rubrique($active_cat_id);
if ($active_cat_id) {
$active_cat_obj = get_category($active_cat_id);
$active_rubrique_id = ($active_cat_obj && $active_cat_obj->parent)
? $active_cat_obj->parent
: $active_cat_id;
}
$context['active_rubrique'] = $active_rubrique_id; $context['active_rubrique'] = $active_rubrique_id;
// Base URL for search filter links (language-aware) // Base URL for search filter links (language-aware)
@@ -72,7 +66,6 @@ $query_args = [
'posts_per_page' => 12, 'posts_per_page' => 12,
'orderby' => 'relevance', 'orderby' => 'relevance',
'order' => 'DESC', 'order' => 'DESC',
'lang' => '',
'tax_query' => $tax_query, 'tax_query' => $tax_query,
]; ];
if ($active_axe) { if ($active_axe) {
@@ -98,59 +91,19 @@ $context['axe_stay_on_page'] = true;
// Rubrique/catégorie filter links (all preserve search term) // Rubrique/catégorie filter links (all preserve search term)
$all_cats = get_categories(['taxonomy' => 'category', 'hide_empty' => false, 'exclude' => $excluded_cat_ids]); $all_cats = get_categories(['taxonomy' => 'category', 'hide_empty' => false, 'exclude' => $excluded_cat_ids]);
$filter_parents = []; // Liens de filtre : on reste sur la recherche avec un paramètre filter_cat
foreach ($all_cats as $cat) { $make_filter_link = function ($cat) use ($base_filter_params, $search_base) {
if ($cat->parent == 0) { $params = array_filter(array_merge($base_filter_params, ['filter_cat' => $cat->term_id]));
$params = array_filter(array_merge($base_filter_params, ['filter_cat' => $cat->term_id])); return add_query_arg($params, $search_base);
$filter_parents[] = [ };
'id' => $cat->term_id, $context['filter_parents'] = thalim_archive_filter_parents($all_cats, $make_filter_link);
'name' => thalim_cat_name($cat),
'slug' => $cat->slug, $filter_categories = thalim_archive_filter_children($all_cats, $active_rubrique_id, $make_filter_link);
'link' => add_query_arg($params, $search_base),
];
}
}
$context['filter_parents'] = $filter_parents;
$filter_categories = [];
if ($active_rubrique_id) {
foreach ($all_cats as $cat) {
if ($cat->parent == $active_rubrique_id) {
$params = array_filter(array_merge($base_filter_params, ['filter_cat' => $cat->term_id]));
$filter_categories[] = [
'id' => $cat->term_id,
'name' => thalim_cat_name($cat),
'slug' => $cat->slug,
'link' => add_query_arg($params, $search_base),
];
}
}
}
// Add "Autres" entry if active rubrique has posts directly assigned to it // Add "Autres" entry if active rubrique has posts directly assigned to it
if ($active_rubrique_id && !empty($filter_categories)) { if ($active_rubrique_id && !empty($filter_categories) && thalim_rubrique_has_direct_posts($active_rubrique_id)) {
$lang = thalim_current_language(); $params = array_filter(array_merge($base_filter_params, ['filter_cat' => $active_rubrique_id, 'filter_autres' => 1]));
$direct_check = new WP_Query([ $filter_categories[] = thalim_archive_autres_entry(add_query_arg($params, $search_base));
'post_type' => 'post',
'posts_per_page' => 1,
'fields' => 'ids',
'no_found_rows' => true,
'lang' => '',
'tax_query' => [[
'taxonomy' => 'category',
'field' => 'term_id',
'terms' => [$active_rubrique_id],
'include_children' => false,
]],
]);
if ($direct_check->have_posts()) {
$params = array_filter(array_merge($base_filter_params, ['filter_cat' => $active_rubrique_id, 'filter_autres' => 1]));
$filter_categories[] = [
'id' => 'autres',
'name' => $lang === 'en' ? 'Other' : 'Autres',
'slug' => 'autres',
'link' => add_query_arg($params, $search_base),
];
}
} }
$context['filter_categories'] = $filter_categories; $context['filter_categories'] = $filter_categories;
@@ -161,21 +114,24 @@ $context['posts'] = $posts;
// Search users (members) by display_name // Search users (members) by display_name
$author_cards = []; $author_cards = [];
if ( $search_query ) { if ( $search_query ) {
$excluded_role_ids = [ 600, 598 ]; // "À ranger", "Archive" $excluded_role_ids = thalim_excluded_role_ids(); // « À ranger », « Archive » (résolus par slug)
$user_query = new WP_User_Query([ $user_query_args = [
'search' => '*' . $search_query . '*', 'search' => '*' . $search_query . '*',
'search_columns' => ['display_name'], 'search_columns' => ['display_name'],
'number' => 6, 'number' => 6,
'orderby' => 'display_name', 'orderby' => 'display_name',
'order' => 'ASC', 'order' => 'ASC',
'meta_query' => [ ];
if ( $excluded_role_ids ) {
$user_query_args['meta_query'] = [
[ [
'key' => 'role_1', 'key' => 'role_1',
'value' => $excluded_role_ids, 'value' => $excluded_role_ids,
'compare' => 'NOT IN', 'compare' => 'NOT IN',
], ],
], ];
]); }
$user_query = new WP_User_Query( $user_query_args );
$lang = thalim_current_language(); $lang = thalim_current_language();
// Direction IDs (same source as membres page and author page) // Direction IDs (same source as membres page and author page)

View File

@@ -10,15 +10,16 @@ $context['parent_slug'] = '';
$tax_object = get_taxonomy($taxonomy); $tax_object = get_taxonomy($taxonomy);
$context['taxonomy_label'] = $tax_object ? $tax_object->labels->singular_name : $taxonomy; $context['taxonomy_label'] = $tax_object ? $tax_object->labels->singular_name : $taxonomy;
$excluded_ids = [12, 31]; // Séance de séminaire, Non classé // Séance de séminaire, Non classé (+ Vie du labo pour les non-connectés)
if ( ! is_user_logged_in() ) $excluded_ids[] = 9; // Vie du labo $excluded_ids = thalim_archive_excluded_cat_ids();
// Read filter query params // Read filter query params
$active_axe = isset($_GET['axe']) ? intval($_GET['axe']) : 0; $f = thalim_archive_read_filters();
$active_date_from = isset($_GET['date_from']) ? sanitize_text_field($_GET['date_from']) : ''; $active_axe = $f['axe'];
$active_date_to = isset($_GET['date_to']) ? sanitize_text_field($_GET['date_to']) : ''; $active_date_from = $f['date_from'];
$active_cat_id = isset($_GET['filter_cat']) ? intval($_GET['filter_cat']) : 0; $active_date_to = $f['date_to'];
$filter_autres = isset($_GET['filter_autres']) ? 1 : 0; $active_cat_id = $f['cat_id'];
$filter_autres = $f['filter_autres'];
$context['active_axe'] = $active_axe; $context['active_axe'] = $active_axe;
$context['active_date_from'] = $active_date_from; $context['active_date_from'] = $active_date_from;
@@ -28,13 +29,7 @@ $context['active_cat_id'] = $active_cat_id;
$context['filter_autres'] = $filter_autres; $context['filter_autres'] = $filter_autres;
// Determine active rubrique from active category (parent if subcategory, itself if top-level) // Determine active rubrique from active category (parent if subcategory, itself if top-level)
$active_rubrique_id = 0; $active_rubrique_id = thalim_archive_active_rubrique($active_cat_id);
if ($active_cat_id) {
$active_cat_obj = get_category($active_cat_id);
$active_rubrique_id = ($active_cat_obj && $active_cat_obj->parent)
? $active_cat_obj->parent
: $active_cat_id;
}
$context['active_rubrique'] = $active_rubrique_id; $context['active_rubrique'] = $active_rubrique_id;
// Base params shared across all filter links (preserves active filters when navigating) // Base params shared across all filter links (preserves active filters when navigating)
@@ -53,11 +48,11 @@ $tax_query = [
'field' => 'term_id', 'field' => 'term_id',
'terms' => [$term->term_id], 'terms' => [$term->term_id],
], ],
// Exclure les séances de séminaire (catégorie 12) // Exclure les séances de séminaire
[ [
'taxonomy' => 'category', 'taxonomy' => 'category',
'field' => 'term_id', 'field' => 'term_id',
'terms' => [12], 'terms' => [ thalim_cat_id('seance') ],
'operator' => 'NOT IN', 'operator' => 'NOT IN',
], ],
]; ];
@@ -101,66 +96,28 @@ $context['axe_stay_on_page'] = !$axe_taxonomy_mode;
$current_term_url = get_term_link($term); $current_term_url = get_term_link($term);
$all_cats = get_categories(['taxonomy' => 'category', 'hide_empty' => false, 'exclude' => $excluded_ids]); $all_cats = get_categories(['taxonomy' => 'category', 'hide_empty' => false, 'exclude' => $excluded_ids]);
$filter_parents = []; // Liens de filtre : on reste sur l'URL du terme courant avec un paramètre filter_cat
foreach ($all_cats as $cat) { $make_filter_link = function ($cat) use ($base_filter_params, $current_term_url) {
if ($cat->parent == 0) { $params = array_filter(array_merge($base_filter_params, ['filter_cat' => $cat->term_id]));
$params = array_filter(array_merge($base_filter_params, ['filter_cat' => $cat->term_id])); return add_query_arg($params, $current_term_url);
$filter_parents[] = [ };
'id' => $cat->term_id, $context['filter_parents'] = thalim_archive_filter_parents($all_cats, $make_filter_link);
'name' => thalim_cat_name($cat),
'slug' => $cat->slug, $filter_categories = thalim_archive_filter_children($all_cats, $active_rubrique_id, $make_filter_link);
'link' => add_query_arg($params, $current_term_url),
];
}
}
$context['filter_parents'] = $filter_parents;
$filter_categories = [];
if ($active_rubrique_id) {
foreach ($all_cats as $cat) {
if ($cat->parent == $active_rubrique_id) {
$params = array_filter(array_merge($base_filter_params, ['filter_cat' => $cat->term_id]));
$filter_categories[] = [
'id' => $cat->term_id,
'name' => thalim_cat_name($cat),
'slug' => $cat->slug,
'link' => add_query_arg($params, $current_term_url),
];
}
}
}
// Add "Autres" entry if active rubrique has posts directly assigned to it // Add "Autres" entry if active rubrique has posts directly assigned to it
// (contraints au terme de taxonomie courant)
if ($active_rubrique_id && !empty($filter_categories)) { if ($active_rubrique_id && !empty($filter_categories)) {
$lang = thalim_current_language(); $has_direct = thalim_rubrique_has_direct_posts($active_rubrique_id, [
$direct_check = new WP_Query([ [
'post_type' => 'post', 'taxonomy' => $taxonomy,
'posts_per_page' => 1, 'field' => 'term_id',
'fields' => 'ids', 'terms' => [$term->term_id],
'no_found_rows' => true,
'lang' => '',
'tax_query' => [
'relation' => 'AND',
[
'taxonomy' => $taxonomy,
'field' => 'term_id',
'terms' => [$term->term_id],
],
[
'taxonomy' => 'category',
'field' => 'term_id',
'terms' => [$active_rubrique_id],
'include_children' => false,
],
], ],
]); ]);
if ($direct_check->have_posts()) { if ($has_direct) {
$params = array_filter(array_merge($base_filter_params, ['filter_cat' => $active_rubrique_id, 'filter_autres' => 1])); $params = array_filter(array_merge($base_filter_params, ['filter_cat' => $active_rubrique_id, 'filter_autres' => 1]));
$filter_categories[] = [ $filter_categories[] = thalim_archive_autres_entry(add_query_arg($params, $current_term_url));
'id' => 'autres',
'name' => $lang === 'en' ? 'Other' : 'Autres',
'slug' => 'autres',
'link' => add_query_arg($params, $current_term_url),
];
} }
} }
$context['filter_categories'] = $filter_categories; $context['filter_categories'] = $filter_categories;
@@ -171,7 +128,6 @@ $posts = Timber::get_posts(array_merge([
'posts_per_page' => 12, 'posts_per_page' => 12,
'orderby' => 'date', 'orderby' => 'date',
'order' => 'DESC', 'order' => 'DESC',
'lang' => '',
'thalim_event_date_order' => true, 'thalim_event_date_order' => true,
], $extra_query_args)); ], $extra_query_args));
$context['cards'] = thalim_get_cards_data($posts); $context['cards'] = thalim_get_cards_data($posts);
@@ -181,6 +137,6 @@ $context['posts'] = $posts;
$tax_lang = thalim_current_language(); $tax_lang = thalim_current_language();
$pres_fr = get_term_meta($term->term_id, 'presentation', true) ?: ''; $pres_fr = get_term_meta($term->term_id, 'presentation', true) ?: '';
$pres_en = get_term_meta($term->term_id, 'presentation_en', true) ?: ''; $pres_en = get_term_meta($term->term_id, 'presentation_en', true) ?: '';
$context['term_presentation'] = wpautop( ( $tax_lang === 'en' && $pres_en ) ? $pres_en : $pres_fr ); $context['term_presentation'] = wpautop( wp_kses_post( ( $tax_lang === 'en' && $pres_en ) ? $pres_en : $pres_fr ) );
Timber::render('taxonomy.twig', $context); Timber::render('taxonomy.twig', $context);

View File

@@ -22,13 +22,13 @@
{% if author.avatar_url %} {% if author.avatar_url %}
<div class="author-header"> <div class="author-header">
<div class="author-avatar"> <div class="author-avatar">
<img src="{{ author.avatar_url }}" alt="{{ author.display_name }}"> <img src="{{ author.avatar_url|esc_url }}" alt="{{ author.display_name|esc_attr }}">
</div> </div>
<div class="author-identity"> <div class="author-identity">
<h2><p>{{ author.display_name }}</p></h2> <h2><p>{{ author.display_name|esc_html }}</p></h2>
{% if author.role_label or author.role_complement or author.affiliation %} {% if author.role_label or author.role_complement or author.affiliation %}
<p class="author-role"> <p class="author-role">
{{ author.role_label }}{% if author.role_complement %} {{ author.role_complement }}{% if author.affiliation %},{% endif %}{% endif %}{% if author.affiliation %} {{ author.affiliation }}{% endif %} {{ author.role_label|esc_html }}{% if author.role_complement %} {{ author.role_complement|esc_html }}{% if author.affiliation %},{% endif %}{% endif %}{% if author.affiliation %} {{ author.affiliation|esc_html }}{% endif %}
</p> </p>
{% endif %} {% endif %}
<p class="maj">{{ current_language == 'en' ? 'Updated on' : 'Mis à jour le' }} {{ author.user_since }}</p> <p class="maj">{{ current_language == 'en' ? 'Updated on' : 'Mis à jour le' }} {{ author.user_since }}</p>
@@ -63,10 +63,10 @@
{% if not author.avatar_url %} {% if not author.avatar_url %}
<div class="author-identity"> <div class="author-identity">
<h2><p>{{ author.display_name }}</p></h2> <h2><p>{{ author.display_name|esc_html }}</p></h2>
{% if author.role_label or author.role_complement or author.affiliation %} {% if author.role_label or author.role_complement or author.affiliation %}
<p class="author-role"> <p class="author-role">
{{ author.role_label }}{% if author.role_complement %} {{ author.role_complement }}{% if author.affiliation %},{% endif %}{% endif %}{% if author.affiliation %} {{ author.affiliation }}{% endif %} {{ author.role_label|esc_html }}{% if author.role_complement %} {{ author.role_complement|esc_html }}{% if author.affiliation %},{% endif %}{% endif %}{% if author.affiliation %} {{ author.affiliation|esc_html }}{% endif %}
</p> </p>
{% endif %} {% endif %}
<p class="maj">{{ current_language == 'en' ? 'Updated on' : 'Mis à jour le' }} {{ author.user_since }}</p> <p class="maj">{{ current_language == 'en' ? 'Updated on' : 'Mis à jour le' }} {{ author.user_since }}</p>

View File

@@ -42,7 +42,7 @@
<ul> <ul>
<li data-role="">{{ current_language == 'en' ? 'All statuses' : 'Tous les statuts' }}</li> <li data-role="">{{ current_language == 'en' ? 'All statuses' : 'Tous les statuts' }}</li>
{% for role in filter_roles %} {% for role in filter_roles %}
<li data-role="{{ role.name }}">{{ role.name }}</li> <li data-role="{{ role.name|esc_attr }}">{{ role.name|esc_html }}</li>
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>
@@ -81,18 +81,20 @@
</thead> </thead>
<tbody> <tbody>
{% for member in group.members %} {% for member in group.members %}
<tr onclick="window.location.href='{{ member.url }}'" {# data-url + listener délégué (membresFilters.js) au lieu d'un onclick inline ;
data-name="{{ member.display_name }}" tout passe par esc_attr/esc_html : ces valeurs viennent des profils utilisateurs #}
data-sort-name="{{ member.sort_key }}" <tr data-url="{{ member.url|esc_url }}"
data-roles="{{ member.role_names|join('|') }}" data-name="{{ member.display_name|esc_attr }}"
data-avatar="{{ member.avatar_url }}" data-sort-name="{{ member.sort_key|esc_attr }}"
data-status="{{ member.status }}" data-roles="{{ member.role_names|join('|')|esc_attr }}"
data-affiliation="{{ member.affiliation }}" data-avatar="{{ member.avatar_url|esc_url }}"
data-domaines="{{ member.domaines|join(', ') }}" data-status="{{ member.status|esc_attr }}"
data-autres-domaines="{{ member.autres_domaines }}"> data-affiliation="{{ member.affiliation|esc_attr }}"
<td>{{ member.display_name }}</td> data-domaines="{{ member.domaines|join(', ')|esc_attr }}"
<td>{{ member.status }}</td> data-autres-domaines="{{ member.autres_domaines|esc_attr }}">
<td>{{ member.affiliation }}</td> <td>{{ member.display_name|esc_html }}</td>
<td>{{ member.status|esc_html }}</td>
<td>{{ member.affiliation|esc_html }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@@ -20,6 +20,6 @@
{% if type_label %}<span>{{ type_label }}</span>{% endif %} {% if type_label %}<span>{{ type_label }}</span>{% endif %}
{% if lieu %}<span>{{ lieu }}</span>{% endif %} {% if lieu %}<span>{{ lieu }}</span>{% endif %}
</div> </div>
<p class="agenda-card__title">{{ post.title }}</p> <p class="agenda-card__title">{{ post.title|bilingual }}</p>
</div> </div>
</a> </a>

View File

@@ -1,18 +1,18 @@
<article class="author-card"> <article class="author-card">
<a href="{{ author.url }}" class="author-card__visual" tabindex="-1" aria-hidden="true"> <a href="{{ author.url|esc_url }}" class="author-card__visual" tabindex="-1" aria-hidden="true">
{% if author.avatar_url %} {% if author.avatar_url %}
<img src="{{ author.avatar_url }}" alt="{{ author.name }}" loading="lazy"> <img src="{{ author.avatar_url|esc_url }}" alt="{{ author.name|esc_attr }}" loading="lazy">
{% else %} {% else %}
<span class="author-card__initials">{{ author.initials }}</span> <span class="author-card__initials">{{ author.initials|esc_html }}</span>
{% endif %} {% endif %}
</a> </a>
<div class="author-card__info"> <div class="author-card__info">
<h2 class="author-card__name"><a href="{{ author.url }}">{{ author.name }}</a></h2> <h2 class="author-card__name"><a href="{{ author.url|esc_url }}">{{ author.name|esc_html }}</a></h2>
{% if author.role_label %} {% if author.role_label %}
<p class="author-card__role">{{ author.role_label }}</p> <p class="author-card__role">{{ author.role_label|esc_html }}</p>
{% endif %} {% endif %}
{% if author.affiliation %} {% if author.affiliation %}
<p class="author-card__affiliation">{{ author.affiliation }}</p> <p class="author-card__affiliation">{{ author.affiliation|esc_html }}</p>
{% endif %} {% endif %}
</div> </div>
</article> </article>

View File

@@ -40,77 +40,3 @@
</h2> </h2>
{% endif %} {% endif %}
</article> </article>
{#
<article class="post-card">
{% if card.card_image %}
<div class="post-card__image">
<img src="{{ card.card_image }}" alt="{{ post.title }}" loading="lazy">
</div>
{% endif %}
<div class="post-card__content">
<h3 class="post-card__title">
<a href="{{ post.link }}">{{ post.title }}</a>
</h3>
{% if post.meta('sous-titre') %}
<p class="post-card__subtitle">{{ post.meta('sous-titre') }}</p>
{% endif %}
<time class="post-card__date" datetime="{{ post.date('Y-m-d') }}">{{ post.date('d/m/Y') }}</time>
{% if card.card_membres is not empty or post.meta('autrepersonnes') %}
<div class="post-card__authors">
{% for name in card.card_membres %}
<span class="post-card__author">{{ name }}</span>
{% endfor %}
{% if post.meta('autrepersonnes') %}
<span class="post-card__author post-card__author--external">{{ post.meta('autrepersonnes') }}</span>
{% endif %}
</div>
{% endif %}
{% if post.meta('fonction_auteur') %}
<span class="post-card__role">{{ post.meta('fonction_auteur') }}</span>
{% endif %}
{% if post.meta('editeur') %}
<span class="post-card__publisher">{{ post.meta('editeur') }}</span>
{% endif %}
{% if post.meta('journal') %}
<span class="post-card__journal">{{ post.meta('journal') }}</span>
{% endif %}
{% if card.card_axes is not empty %}
<div class="post-card__axes">
{% for axe in card.card_axes %}
<span class="post-card__axe">{{ axe }}</span>
{% endfor %}
</div>
{% endif %}
{% if card.card_etiquettes is not empty %}
<div class="post-card__tags">
{% for tag in card.card_etiquettes %}
<span class="post-card__tag">{{ tag }}</span>
{% endfor %}
</div>
{% endif %}
<div class="post-card__links">
{% if post.meta('lien_externe_1') %}
<a href="{{ post.meta('lien_externe_1') }}" class="post-card__link" target="_blank" rel="noopener">
{{ post.meta('titre_du_lien_externe_1') ?: post.meta('lien_externe_1') }}
</a>
{% endif %}
{% if post.meta('hal_url') %}
<a href="{{ post.meta('hal_url') }}" class="post-card__link post-card__link--hal" target="_blank" rel="noopener">
HAL
</a>
{% endif %}
</div>
</div>
</article>
#}

View File

@@ -13,9 +13,7 @@
{% if article.category_name and article.category_name != article.parent_name %} {% if article.category_name and article.category_name != article.parent_name %}
<span class="breadcrumb__separator">&rarr;</span> <span class="breadcrumb__separator">&rarr;</span>
<a class="breadcrumb__cat" href="{{ article.category_link }}">{{ article.category_name }}</a> <a class="breadcrumb__cat" href="{{ article.category_link }}">{{ article.category_name }}</a>
{% endif %}{# {% endif %}
<span class="breadcrumb__separator">&rarr;</span>
<span class="breadcrumb__current">{{ post.title }}</span> #}
</nav> </nav>
{% if post.edit_link %} {% if post.edit_link %}
<a href="{{ post.edit_link }}" class="link-button" target="_blank" rel="noopener"> <a href="{{ post.edit_link }}" class="link-button" target="_blank" rel="noopener">

100
tests/run-tests.php Normal file
View File

@@ -0,0 +1,100 @@
<?php
/**
* Tests du thème THALIM — fonctions pures à fort enjeu.
*
* Exécution (environnement Docker de dev) :
* docker exec wordpress php /var/www/html/wp-content/themes/thalim/tests/run-tests.php
*
* Pas de PHPUnit (pas de pipeline) : assertions maison, sortie lisible,
* code retour ≠ 0 en cas d'échec. Charge WordPress (wp-load) car les
* fonctions testées vivent dans functions.php/inc/*.
*/
if (PHP_SAPI !== 'cli') {
exit("CLI only\n");
}
$_SERVER['HTTP_HOST'] = $_SERVER['HTTP_HOST'] ?? 'localhost';
$_SERVER['REQUEST_URI'] = $_SERVER['REQUEST_URI'] ?? '/';
require dirname(__DIR__, 4) . '/wp-load.php';
$failures = 0;
$count = 0;
function check(string $name, $actual, $expected): void {
global $failures, $count;
$count++;
if ($actual === $expected) {
echo " ok $name\n";
} else {
$failures++;
echo " FAIL $name\n attendu: " . var_export($expected, true)
. "\n obtenu : " . var_export($actual, true) . "\n";
}
}
echo "== thalim_bilingual ==\n";
check('FR par défaut', thalim_bilingual('Bonjour // Hello'), 'Bonjour');
check('EN explicite', thalim_bilingual('Bonjour // Hello', 'en'), 'Hello');
check('FR explicite', thalim_bilingual('Bonjour // Hello', 'fr'), 'Bonjour');
check('sans séparateur', thalim_bilingual('Bonjour', 'en'), 'Bonjour');
check('EN vide → FR', thalim_bilingual('Bonjour // ', 'en'), 'Bonjour');
check('espaces normalisés', thalim_bilingual(' Bonjour // Hello ', 'en'), 'Hello');
check('chaîne vide', thalim_bilingual('', 'en'), '');
check('séparateur multiple', thalim_bilingual('A // B // C', 'en'), 'B // C');
echo "== thalim_en_url ==\n";
$GLOBALS['thalim_lang_override'] = 'fr';
check('no-op en FR', thalim_en_url(home_url('/membres/')), home_url('/membres/'));
$GLOBALS['thalim_lang_override'] = 'en';
$home = rtrim(home_url(), '/');
check('préfixe /en', thalim_en_url($home . '/membres/'), $home . '/en/membres/');
check('idempotente', thalim_en_url($home . '/en/membres/'), $home . '/en/membres/');
unset($GLOBALS['thalim_lang_override']);
echo "== thalim_cat_name ==\n";
$articles = get_term_by('slug', 'articles', 'category');
check('FR = nom natif', thalim_cat_name($articles, 'fr'), $articles->name);
$en_meta = get_term_meta($articles->term_id, 'titre_anglais', true);
check('EN = titre_anglais ou fallback', thalim_cat_name($articles, 'en'), $en_meta ?: $articles->name);
check('valeur non-term', thalim_cat_name('Texte brut'), 'Texte brut');
echo "== thalim_format_date ==\n";
check('date vide', thalim_format_date(''), '');
check('0000-00-00', thalim_format_date('0000-00-00 00:00:00'), '');
check('format Y', thalim_format_date('2026-03-15 00:00:00', 'fr', 'Y'), '2026');
check('format Y-m-d', thalim_format_date('2026-03-15 10:30:00', 'fr', 'Y-m-d'), '2026-03-15');
echo "== config (résolution par slug) ==\n";
check('cat seance', thalim_cat_id('seance'), 12);
check('cat non-classe', thalim_cat_id('non-classe'), 31);
check('cat vie-du-labo', thalim_cat_id('vie-du-labo'), 9);
check('cat publications', thalim_cat_id('publications'), 4);
check('cat message-labo', thalim_cat_id('message-labo'), 268);
check('clé inconnue', thalim_cat_id('nexiste-pas'), 0);
check('slug inexistant', thalim_term_id_by_slug('slug-bidon-xyz'), 0);
check('rôles exclus', thalim_excluded_role_ids(), [600, 598]);
echo "== thalim_get_active_pinned_ids ==\n";
check('catégorie 0 → []', thalim_get_active_pinned_ids(0), []);
echo "== thalim_get_seance_parent_id ==\n";
global $wpdb;
$seance_id = (int) $wpdb->get_var(
"SELECT tr.object_id FROM {$wpdb->term_relationships} tr
JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
JOIN {$wpdb->posts} p ON p.ID = tr.object_id
WHERE tt.term_id = 12 AND p.post_status = 'publish' LIMIT 1"
);
if ($seance_id) {
$parent = thalim_get_seance_parent_id($seance_id);
check('séance a un parent publié', $parent > 0, true);
check('lien = parent + ancre', thalim_get_seance_link($seance_id), get_permalink($parent) . '#seance-' . $seance_id);
} else {
echo " skip aucune séance publiée en base\n";
}
check('séance inexistante → 0', thalim_get_seance_parent_id(999999999), 0);
echo "\n$count tests, $failures échec(s)\n";
exit($failures ? 1 : 0);