true, 'notifications_enabled' => true, 'cleanup_on_uninstall' => true, ]; } /* ⭐ CACHE MEMOIRE DES REGLAGES — reparation, pas silence (22/08/2026). * * `get_settings()` est appele a chaque requete AJAX et par le notifier. Il passe par * `get_option`, deja cache par WordPress, mais il refait a chaque appel la fusion avec * les valeurs par defaut. Le sniff `NoCaching` le signalait, et l annotation ne le * faisait PAS taire — trois placements essayes, le constat restait a la meme ligne. * ⇒ Plutot que de chercher a le museler, on lui donne raison : le resultat est mis en * cache dans le meme groupe que le reste de la classe, et `save_settings` l invalide. * Un garde-fou qu on n arrive pas a desarmer proprement a souvent raison sur le fond. */ public function get_settings(): array { $cle = $this->cle_cache( 'settings' ); $mem = wp_cache_get( $cle, self::CACHE_GROUPE ); if ( is_array( $mem ) ) { return $mem; } $stored = get_option( 'aiforya_ma_settings', [] ); if ( ! is_array( $stored ) ) { $stored = []; } $fusion = wp_parse_args( $stored, self::get_default_settings() ); wp_cache_set( $cle, $fusion, self::CACHE_GROUPE ); return $fusion; } public function save_settings( array $settings = [] ): bool { $defaults = self::get_default_settings(); $merged = []; foreach ( $defaults as $key => $default ) { $merged[ $key ] = array_key_exists( $key, $settings ) ? $settings[ $key ] : $default; } $ok = update_option( 'aiforya_ma_settings', $merged, true ); /* ⛔ SANS CETTE LIGNE, LE CACHE CI-DESSUS SERAIT UN BUG : un reglage enregistre ne * prendrait effet qu au tour suivant, et l utilisateur verrait son changement ignore. */ $this->invalider_cache(); return $ok; } /* --------------------------------------------------------------------- * Assignations * ------------------------------------------------------------------- */ private function assignments_table(): string { global $wpdb; return $wpdb->prefix . 'aiforya_ma_assignments'; } private function coauthors_table(): string { global $wpdb; return $wpdb->prefix . 'aiforya_ma_coauthors'; } /* --------------------------------------------------------------------- * Cache objet * * Le tableau de bord et l'onglet Auteurs enchaînent plusieurs COUNT() à chaque * affichage : sans cache, une page = cinq requêtes qui rendent toutes la même chose. * Ce n'est pas qu'une affaire d'avertissement Plugin Check (« Direct database call * without caching detected ») — c'est du temps serveur payé à chaque chargement. * * ⭐ L'INVALIDATION EST LE SEUL POINT DÉLICAT, et on la rend impossible à oublier : * plutôt que de lister les clés à purger à chaque écriture (ce qu'on oublie au premier * ajout de requête), on incrémente UN compteur de version qui entre dans toutes les * clés. Toute écriture rend donc l'ensemble du cache inatteignable d'un coup, sans * qu'aucune fonction de lecture n'ait à être tenue à jour. * ------------------------------------------------------------------- */ private const CACHE_GROUPE = 'aiforya_ma'; private function version_cache(): int { $v = wp_cache_get( 'version', self::CACHE_GROUPE ); if ( false === $v ) { $v = 1; wp_cache_set( 'version', $v, self::CACHE_GROUPE ); } return (int) $v; } /** Appelé après CHAQUE écriture : rend tout le cache de lecture obsolète. */ private function invalider_cache(): void { wp_cache_set( 'version', $this->version_cache() + 1, self::CACHE_GROUPE ); } private function cle_cache( string $nom, ...$parties ): string { return $nom . '_v' . $this->version_cache() . ( $parties ? '_' . md5( wp_json_encode( $parties ) ) : '' ); } /** * Crée ou met à jour l'assignation d'un article à un auteur. * * @return int|WP_Error ID de l'assignation, ou WP_Error. */ public function create_assignment( int $post_id = 0, int $user_id = 0, string $status = 'draft' ) { global $wpdb; if ( empty( $post_id ) || empty( $user_id ) ) { return new WP_Error( 'invalid_params', __( 'Post and author are both required.', 'aiforya-multi-author' ) ); } if ( ! get_post( $post_id ) ) { return new WP_Error( 'invalid_post', __( 'The post does not exist.', 'aiforya-multi-author' ) ); } if ( ! get_user_by( 'ID', $user_id ) ) { return new WP_Error( 'invalid_user', __( 'The user does not exist.', 'aiforya-multi-author' ) ); } if ( '' === $status ) { $status = 'draft'; } $table = $this->assignments_table(); $existing = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM %i WHERE post_id = %d", $table, $post_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table propre a l extension : aucune API WordPress ne sait la lire, et le cache a compteur de version est gere par cle_cache()/invalider_cache(). if ( $existing ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- table propre a l extension ; ECRITURE : un cache n a pas d objet, et le cache de lecture est invalide par invalider_cache(). $wpdb->update( $table, [ 'user_id' => $user_id, 'status' => $status, 'updated_at' => current_time( 'mysql' ) ], [ 'id' => (int) $existing ], [ '%d', '%s', '%s' ], [ '%d' ] ); $this->invalider_cache(); return (int) $existing; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- table propre a l extension ; ECRITURE : un cache n a pas d objet, et le cache de lecture est invalide par invalider_cache(). $result = $wpdb->insert( $table, [ 'post_id' => $post_id, 'user_id' => $user_id, 'status' => $status, 'assigned_at' => current_time( 'mysql' ), 'updated_at' => current_time( 'mysql' ), ], [ '%d', '%d', '%s', '%s', '%s' ] ); if ( false === $result ) { return new WP_Error( 'db_error', __( 'Something went wrong while creating the assignment.', 'aiforya-multi-author' ) ); } $this->invalider_cache(); return (int) $wpdb->insert_id; } public function get_assignment_by_post( int $post_id = 0 ): ?object { global $wpdb; if ( empty( $post_id ) ) { return null; } $cle = $this->cle_cache( 'assignment_post', $post_id ); $mem = wp_cache_get( $cle, self::CACHE_GROUPE ); if ( false !== $mem ) { return $mem ?: null; } $table = $this->assignments_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- table propre a l extension : aucune API WordPress ne sait la lire. Cache a compteur de version gere par cle_cache()/invalider_cache(). $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE post_id = %d', $table, $post_id ) ); wp_cache_set( $cle, $row ?: 0, self::CACHE_GROUPE ); return $row ?: null; } public function update_assignment_status( int $post_id = 0, string $new_status = '' ): bool { global $wpdb; if ( empty( $post_id ) || '' === $new_status ) { return false; } $table = $this->assignments_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- table propre a l extension ; ECRITURE : un cache n a pas d objet, et le cache de lecture est invalide par invalider_cache(). $result = $wpdb->update( $table, [ 'status' => $new_status, 'updated_at' => current_time( 'mysql' ) ], [ 'post_id' => $post_id ], [ '%s', '%s' ], [ '%d' ] ); $this->invalider_cache(); return false !== $result; } /** * @param string[] $statuses * @return object[] */ public function get_assignments_by_status( array $statuses = [] ): array { global $wpdb; if ( empty( $statuses ) ) { return []; } $cle = $this->cle_cache( 'assignments_status', $statuses ); $mem = wp_cache_get( $cle, self::CACHE_GROUPE ); if ( is_array( $mem ) ) { return $mem; } $table = $this->assignments_table(); $placeholders = implode( ', ', array_fill( 0, count( $statuses ), '%s' ) ); /* ⛔ NE PAS « CORRIGER » CETTE REQUETE — elle est juste, et le sniff se trompe. * * `UnfinishedPrepare` signale « replacement variables found, but no valid placeholders » : * il compte les marqueurs du LITTERAL, or les %s vivent dans {$placeholders}, une * INTERPOLATION. Aucun comptage ne peut les voir (mesure du 17/08/2026). * * Et `$statuses` est bien le SEUL argument apres la requete : `wpdb::prepare` aplatit * un tableau a cette condition exacte (`is_array($args[0]) && 1 === count($args)`, lu * dans le source de WordPress). Passer le nom de table en %i ferait DEUX arguments, * prepare rendrait une chaine VIDE sans rien lever, et la requete ne partirait jamais — * c'est le bug fonctionnel trouve dans redirect-manager le 17/08. * * Le nom de table ne vient d'aucune entree : `assignments_table()` le compose depuis * `$wpdb->prefix` et une constante. */ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- marqueurs %s generes dans $placeholders, valeurs passees par $statuses ; nom de table issu de $wpdb->prefix. $query = $wpdb->prepare( "SELECT * FROM {$table} WHERE status IN ({$placeholders}) ORDER BY updated_at DESC", $statuses ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare // $placeholders n'est qu'une suite de marqueurs %s, un par statut ; les valeurs // partent dans prepare() via $statuses. // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- requete PREPAREE juste au-dessus (marqueurs generes dans $placeholders, valeurs passees par $statuses) ; table propre a l extension ; resultat mis en cache a la ligne suivante. $results = $wpdb->get_results( $query ); $results = is_array( $results ) ? $results : []; wp_cache_set( $cle, $results, self::CACHE_GROUPE ); return $results; } public function count_articles_by_status( ?string $status = null ): int { global $wpdb; $cle = $this->cle_cache( 'count_status', (string) $status ); $mem = wp_cache_get( $cle, self::CACHE_GROUPE ); if ( false !== $mem ) { return (int) $mem; } $table = $this->assignments_table(); if ( null === $status || '' === $status ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- table propre a l extension : aucune API WordPress ne sait la lire. Cache a compteur de version gere par cle_cache()/invalider_cache(). $count = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) ); } else { $count = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE status = %s", $table, $status ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table propre a l extension : aucune API WordPress ne sait la lire, et le cache a compteur de version est gere par cle_cache()/invalider_cache(). } wp_cache_set( $cle, (int) $count, self::CACHE_GROUPE ); return (int) $count; } /* --------------------------------------------------------------------- * Co-auteurs (byline) * ------------------------------------------------------------------- */ /** * @return int|WP_Error */ public function add_coauthor( int $post_id = 0, int $user_id = 0, string $role = 'coauthor' ) { global $wpdb; if ( empty( $post_id ) || empty( $user_id ) ) { return new WP_Error( 'invalid_params', __( 'Post and co-author are both required.', 'aiforya-multi-author' ) ); } if ( ! get_post( $post_id ) ) { return new WP_Error( 'invalid_post', __( 'The post does not exist.', 'aiforya-multi-author' ) ); } if ( ! get_user_by( 'ID', $user_id ) ) { return new WP_Error( 'invalid_user', __( 'The user does not exist.', 'aiforya-multi-author' ) ); } $table = $this->coauthors_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- table propre a l extension : aucune API WordPress ne sait la lire. Cache a compteur de version gere par cle_cache()/invalider_cache(). $existing = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM %i WHERE post_id = %d AND user_id = %d", $table, $post_id, $user_id ) ); if ( $existing ) { return (int) $existing; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- table propre a l extension ; ECRITURE : un cache n a pas d objet, et le cache de lecture est invalide par invalider_cache(). $result = $wpdb->insert( $table, [ 'post_id' => $post_id, 'user_id' => $user_id, 'role' => ( '' !== $role ) ? $role : 'coauthor', 'created_at' => current_time( 'mysql' ), ], [ '%d', '%d', '%s', '%s' ] ); if ( false === $result ) { return new WP_Error( 'db_error', __( 'Something went wrong while adding the co-author.', 'aiforya-multi-author' ) ); } $this->invalider_cache(); return (int) $wpdb->insert_id; } public function remove_coauthor( int $post_id = 0, int $user_id = 0 ): bool { global $wpdb; if ( empty( $post_id ) || empty( $user_id ) ) { return false; } $table = $this->coauthors_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- table propre a l extension ; ECRITURE : un cache n a pas d objet, et le cache de lecture est invalide par invalider_cache(). $result = $wpdb->delete( $table, [ 'post_id' => $post_id, 'user_id' => $user_id ], [ '%d', '%d' ] ); $this->invalider_cache(); return false !== $result; } /** * @return object[] Lignes {user_id, role, display_name}. */ public function get_coauthors_for_post( int $post_id = 0 ): array { global $wpdb; if ( empty( $post_id ) ) { return []; } $cle = $this->cle_cache( 'coauthors', $post_id ); $mem = wp_cache_get( $cle, self::CACHE_GROUPE ); if ( is_array( $mem ) ) { return $mem; } $table = $this->coauthors_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- table propre a l extension : aucune API WordPress ne sait la lire. Cache a compteur de version gere par cle_cache()/invalider_cache(). $rows = $wpdb->get_results( $wpdb->prepare( "SELECT c.user_id, c.role, u.display_name FROM %i c LEFT JOIN %i u ON c.user_id = u.ID WHERE c.post_id = %d ORDER BY c.created_at ASC", $table, $wpdb->users, $post_id ) ); $rows = is_array( $rows ) ? $rows : []; wp_cache_set( $cle, $rows, self::CACHE_GROUPE ); return $rows; } /* --------------------------------------------------------------------- * Statistiques tableau de bord * ------------------------------------------------------------------- */ /** * Statistiques agrégées du flux éditorial. * * @return array{total:int,by_status:array,coauthored:int,authors:int} */ public function get_dashboard_stats(): array { global $wpdb; /* Quatre requetes a chaque ouverture du tableau de bord : elles passent par le meme * cache a compteur de version que le reste de cette classe, donc toute ecriture les * invalide sans qu'aucune liste de cles ne soit a tenir. */ $cle = $this->cle_cache( 'dashboard_stats' ); $mem = wp_cache_get( $cle, self::CACHE_GROUPE ); if ( is_array( $mem ) ) { return $mem; } $assignments = $this->assignments_table(); $coauthors = $this->coauthors_table(); /* ⚠ OBJECT_K est le 2e argument de get_results(), PAS de prepare() : la requête * préparée doit rester UN seul argument, sinon on décale le mode de sortie. */ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- table propre a l extension : aucune API WordPress ne sait la lire. Cache a compteur de version gere par cle_cache()/invalider_cache(). $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT status, COUNT(*) AS count FROM %i GROUP BY status', $assignments ), OBJECT_K ); $by_status = []; if ( is_array( $rows ) ) { foreach ( $rows as $status => $data ) { $by_status[ (string) $status ] = (int) $data->count; } } $stats = [ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- table propre a l extension : aucune API WordPress ne sait la lire. Cache a compteur de version gere par cle_cache()/invalider_cache(). 'total' => (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $assignments ) ), 'by_status' => $by_status, // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- table propre a l extension : aucune API WordPress ne sait la lire. Cache a compteur de version gere par cle_cache()/invalider_cache(). 'coauthored' => (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(DISTINCT post_id) FROM %i', $coauthors ) ), // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- table propre a l extension : aucune API WordPress ne sait la lire. Cache a compteur de version gere par cle_cache()/invalider_cache(). 'authors' => (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(DISTINCT user_id) FROM %i', $assignments ) ), ]; wp_cache_set( $cle, $stats, self::CACHE_GROUPE ); return $stats; } }