>> et <<>>. */ if ( ! defined( 'ABSPATH' ) ) { exit; } if ( class_exists( 'Aiforya_Schema_Markup_Ajax', false ) ) { return; } class Aiforya_Schema_Markup_Ajax { /** @var self|null Singleton instance (pattern AIFORYA canonical). */ private static ?self $instance = null; /** * Singleton accessor — convention AIFORYA canonical. * Tous les autres services et tabs accèdent à cette classe via X::get_instance(). */ public static function get_instance(): self { if ( self::$instance === null ) { self::$instance = new self(); } return self::$instance; } public function __construct() { $this->register_hooks(); } /** * Enregistre tous les hooks AJAX. */ public function register_hooks( ): void { /* ⛔ REGISTRATION(S) RETIRÉE(S) LE 23/08/2026 — AUCUN APPELANT, NI DANS LA SOURCE NI * DANS LE PAQUET LIVRÉ. Mesuré action par action : une seule occurrence du nom dans * tout l'arbre, l'`add_action` lui-même. * * Un point d'entrée `wp_ajax_` que rien n'appelle n'est pas neutre : c'est de la * surface offerte sans contrepartie, et c'est du code que le prochain lecteur croira * utile. Le HANDLER reste en place — s'il faut rebrancher un jour, il suffit de * remettre la ligne ; c'est la porte qu'on ferme, pas la pièce qu'on démolit. * * ⚠ NE PAS confondre avec le motif d'assemblage au clic du parc * (`post('aiforya_role_' + a)`), où le nom complet n'existe nulle part dans le code * et où l'action EST bien appelée. Ici le nom est complet, littéral, et unique. */ add_action( 'wp_ajax_aiforya_sm_override_schema_type', array( $this, 'override_schema_type_handler' ) ); add_action( 'wp_ajax_aiforya_sm_validate_with_google', array( $this, 'validate_with_google_handler' ) ); add_action( 'wp_ajax_aiforya_sm_save_type_mappings', array( $this, 'save_type_mappings_handler' ) ); add_action( 'wp_ajax_aiforya_sm_save_settings', array( $this, 'save_settings_handler' ) ); add_action( 'wp_ajax_aiforya_sm_generate_bulk', array( $this, 'generate_bulk_handler' ) ); add_action( 'wp_ajax_aiforya_sm_generate_one', array( $this, 'generate_one_handler' ) ); add_action( 'wp_ajax_aiforya_sm_get_schema_json', array( $this, 'get_schema_json_handler' ) ); add_action( 'wp_ajax_aiforya_sm_delete_schema_for_post', array( $this, 'delete_schema_for_post_handler' ) ); // Smart Bulk Editor. add_action( 'wp_ajax_aiforya_sm_bulk_action', array( $this, 'bulk_action_handler' ) ); // Health / Optimize / Migration. add_action( 'wp_ajax_aiforya_sm_health_compute', array( $this, 'health_compute_handler' ) ); add_action( 'wp_ajax_aiforya_sm_optimize_run', array( $this, 'optimize_run_handler' ) ); add_action( 'wp_ajax_aiforya_sm_dedup_disable', array( $this, 'dedup_disable_handler' ) ); add_action( 'wp_ajax_aiforya_sm_migration_import', array( $this, 'migration_import_handler' ) ); // Règles avancées. add_action( 'wp_ajax_aiforya_sm_rules_save', array( $this, 'rules_save_handler' ) ); add_action( 'wp_ajax_aiforya_sm_rules_delete', array( $this, 'rules_delete_handler' ) ); add_action( 'wp_ajax_aiforya_sm_rules_test', array( $this, 'rules_test_handler' ) ); // Builder visuel. add_action( 'wp_ajax_aiforya_sm_builder_save_template', array( $this, 'builder_save_template_handler' ) ); add_action( 'wp_ajax_aiforya_sm_builder_delete_template', array( $this, 'builder_delete_template_handler' ) ); add_action( 'wp_ajax_aiforya_sm_builder_apply_to_post', array( $this, 'builder_apply_to_post_handler' ) ); add_action( 'wp_ajax_aiforya_sm_builder_search_posts', array( $this, 'builder_search_posts_handler' ) ); } /* ------------------ Builder visuel ------------------ */ public function builder_save_template_handler(): void { check_ajax_referer( 'aiforya_sm_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'aiforya-schema-markup' ) ), 403 ); return; } $name = isset( $_POST['name'] ) ? trim( sanitize_text_field( wp_unslash( (string) $_POST['name'] ) ) ) : ''; // JSON-LD payload. It is sanitized in depth by // Aiforya_Schema_Markup_Builder::sanitize_jsonld_payload() (JSON decode -> recursive // per-field sanitization with wp_kses / sanitize_text_field / esc_url_raw -> re-encode), // which is the only place it can be done without destroying valid JSON structure. // A flat text sanitizer here would strip legitimate characters from schema values. // At this entry point we bound the UTF-8, exactly as sanitize_text_field() does first. // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized in depth by Aiforya_Schema_Markup_Builder::sanitize_jsonld_payload() before storage; a flat sanitizer would corrupt valid JSON-LD. $jsonld = isset( $_POST['jsonld'] ) ? wp_check_invalid_utf8( wp_unslash( (string) $_POST['jsonld'] ) ) : ''; if ( $name === '' ) { wp_send_json_error( array( 'message' => __( 'Template name is required.', 'aiforya-schema-markup' ) ), 400 ); return; } if ( $jsonld === '' ) { wp_send_json_error( array( 'message' => __( 'JSON-LD is required.', 'aiforya-schema-markup' ) ), 400 ); return; } $result = Aiforya_Schema_Markup_Builder::get_instance()->save_template( $name, $jsonld ); if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => $result->get_error_message() ), 400 ); return; } wp_send_json_success( array( 'template' => $result, 'message' => __( 'Template saved.', 'aiforya-schema-markup' ) ) ); } public function builder_delete_template_handler(): void { check_ajax_referer( 'aiforya_sm_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'aiforya-schema-markup' ) ), 403 ); return; } $id = isset( $_POST['id'] ) ? absint( $_POST['id'] ) : 0; if ( $id <= 0 ) { wp_send_json_error( array( 'message' => __( 'Invalid template ID.', 'aiforya-schema-markup' ) ), 400 ); return; } $ok = Aiforya_Schema_Markup_Builder::get_instance()->delete_template( $id ); if ( ! $ok ) { wp_send_json_error( array( 'message' => __( 'Template not found.', 'aiforya-schema-markup' ) ), 404 ); return; } wp_send_json_success( array( 'message' => __( 'Template deleted.', 'aiforya-schema-markup' ) ) ); } public function builder_apply_to_post_handler(): void { check_ajax_referer( 'aiforya_sm_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'aiforya-schema-markup' ) ), 403 ); return; } $post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0; // Same JSON-LD payload as save_template above: sanitized in depth by // Aiforya_Schema_Markup_Builder::sanitize_jsonld_payload() (decode -> recursive // per-field sanitization -> re-encode) before it is stored as schema_json. // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized in depth by Aiforya_Schema_Markup_Builder::sanitize_jsonld_payload() before storage; a flat sanitizer would corrupt valid JSON-LD. $jsonld = isset( $_POST['jsonld'] ) ? wp_check_invalid_utf8( wp_unslash( (string) $_POST['jsonld'] ) ) : ''; if ( $post_id <= 0 ) { wp_send_json_error( array( 'message' => __( 'Invalid post ID.', 'aiforya-schema-markup' ) ), 400 ); return; } if ( $jsonld === '' ) { wp_send_json_error( array( 'message' => __( 'JSON-LD is required.', 'aiforya-schema-markup' ) ), 400 ); return; } $result = Aiforya_Schema_Markup_Builder::get_instance()->apply_to_post( $post_id, $jsonld ); if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => $result->get_error_message() ), 400 ); return; } wp_send_json_success( $result ); } public function builder_search_posts_handler(): void { check_ajax_referer( 'aiforya_sm_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'aiforya-schema-markup' ) ), 403 ); return; } $q = isset( $_POST['q'] ) ? trim( sanitize_text_field( wp_unslash( (string) $_POST['q'] ) ) ) : ''; $limit = isset( $_POST['limit'] ) ? absint( $_POST['limit'] ) : 20; $posts = Aiforya_Schema_Markup_Builder::get_instance()->search_posts( $q, $limit ); wp_send_json_success( array( 'posts' => $posts ) ); } /* ------------------ Règles avancées ------------------ */ public function rules_save_handler(): void { check_ajax_referer( 'aiforya_sm_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'aiforya-schema-markup' ) ), 403 ); return; } // JSON rule payload: decoded below, then every field is sanitized by // Aiforya_Schema_Markup_Rules::save_rule() (sanitize_text_field, integer casts and // in_array whitelists) before it is written to the option. // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized field by field in Aiforya_Schema_Markup_Rules::save_rule() before storage; a flat sanitizer would corrupt valid JSON. $rule_json = isset( $_POST['rule'] ) ? wp_unslash( (string) $_POST['rule'] ) : ''; $rule = json_decode( $rule_json, true ); if ( ! is_array( $rule ) ) { wp_send_json_error( array( 'message' => __( 'Invalid JSON rule.', 'aiforya-schema-markup' ) ), 400 ); return; } $saved = Aiforya_Schema_Markup_Rules::get_instance()->save_rule( $rule ); wp_send_json_success( array( 'rule' => $saved, 'message' => __( 'Rule saved.', 'aiforya-schema-markup' ) ) ); } public function rules_delete_handler(): void { check_ajax_referer( 'aiforya_sm_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'aiforya-schema-markup' ) ), 403 ); return; } $id = isset( $_POST['rule_id'] ) ? absint( $_POST['rule_id'] ) : 0; if ( ! $id ) { wp_send_json_error( array( 'message' => __( 'Invalid rule ID.', 'aiforya-schema-markup' ) ), 400 ); return; } Aiforya_Schema_Markup_Rules::get_instance()->delete_rule( $id ); wp_send_json_success( array( 'message' => __( 'Rule deleted.', 'aiforya-schema-markup' ) ) ); } public function rules_test_handler(): void { check_ajax_referer( 'aiforya_sm_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'aiforya-schema-markup' ) ), 403 ); return; } $service = Aiforya_Schema_Markup_Rules::get_instance(); $rules = $service->get_rules(); // Test sur 30 derniers posts publiés. $posts = get_posts( array( 'post_type' => 'any', 'post_status' => 'publish', 'posts_per_page' => 30, 'fields' => 'ids' ) ); $matches = array(); foreach ( (array) $posts as $pid ) { $type = $service->resolve_for_post( (int) $pid ); if ( $type ) { $matches[] = array( 'post_id' => (int) $pid, 'title' => get_the_title( (int) $pid ), 'schema_type' => $type, ); } } wp_send_json_success( array( 'rules_count' => count( $rules ), 'tested_posts' => count( $posts ), 'matched' => count( $matches ), 'matches' => array_slice( $matches, 0, 10 ), 'meta_keys' => $service->discover_meta_keys( 60 ), ) ); } /* ------------------ Health Score ------------------ */ public function health_compute_handler(): void { check_ajax_referer( 'aiforya_sm_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'aiforya-schema-markup' ) ), 403 ); return; } $report = Aiforya_Schema_Markup_Health::get_instance()->compute_score(); // On force aussi le snapshot du jour. Aiforya_Schema_Markup_Health::get_instance()->daily_snapshot(); wp_send_json_success( $report ); } public function health_history_handler(): void { check_ajax_referer( 'aiforya_sm_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'aiforya-schema-markup' ) ), 403 ); return; } $days = isset( $_POST['days'] ) ? max( 7, min( 365, (int) $_POST['days'] ) ) : 90; $rows = Aiforya_Schema_Markup_Health::get_instance()->get_history( $days ); wp_send_json_success( array( 'rows' => $rows ) ); } /* ------------------ One-Click Optimizer ------------------ */ public function optimize_run_handler(): void { check_ajax_referer( 'aiforya_sm_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'aiforya-schema-markup' ) ), 403 ); return; } $batch = isset( $_POST['batch_size'] ) ? max( 5, min( 50, (int) $_POST['batch_size'] ) ) : 25; $stats = Aiforya_Schema_Markup_Optimizer::get_instance()->one_click_optimize( $batch ); wp_send_json_success( array_merge( $stats, array( 'message' => sprintf( /* translators: %1$d : valeur insérée dynamiquement, %2$d : valeur insérée dynamiquement, %3$d : valeur insérée dynamiquement. */ __( '%1$d schemas optimized, %2$d failures, %3$d remaining.', 'aiforya-schema-markup' ), (int) $stats['ok'], (int) $stats['fail'], (int) ( $stats['remaining'] ?? 0 ) ), ) ) ); } /* ------------------ Dedup ------------------ */ public function dedup_disable_handler(): void { check_ajax_referer( 'aiforya_sm_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'aiforya-schema-markup' ) ), 403 ); return; } $plugin = isset( $_POST['plugin'] ) ? sanitize_key( wp_unslash( (string) $_POST['plugin'] ) ) : ''; $enable = ! empty( $_POST['enable'] ); if ( ! in_array( $plugin, array( 'yoast', 'rankmath', 'aioseo', 'schema_pro' ), true ) ) { wp_send_json_error( array( 'message' => __( 'Invalid plugin.', 'aiforya-schema-markup' ) ), 400 ); return; } $key = 'aiforya_sm_dedup_' . $plugin . '_disabled'; update_option( $key, $enable ? 1 : 0 ); wp_send_json_success( array( 'plugin' => $plugin, 'disabled' => $enable, 'message' => $enable ? __( 'Concurrent Schema output disabled. AIFORYA will take over on the next page load.', 'aiforya-schema-markup' ) : __( 'Concurrent Schema output re-enabled.', 'aiforya-schema-markup' ), ) ); } /* ------------------ Migration ------------------ */ public function migration_import_handler(): void { check_ajax_referer( 'aiforya_sm_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'aiforya-schema-markup' ) ), 403 ); return; } $source = isset( $_POST['source'] ) ? sanitize_key( wp_unslash( (string) $_POST['source'] ) ) : ''; if ( $source === '' ) { wp_send_json_error( array( 'message' => __( 'Source plugin required.', 'aiforya-schema-markup' ) ), 400 ); return; } $r = Aiforya_Schema_Markup_Migration::get_instance()->import_from( $source ); wp_send_json_success( array_merge( $r, array( 'message' => sprintf( /* translators: %1$d : valeur insérée dynamiquement, %2$s : valeur insérée dynamiquement, %3$d : valeur insérée dynamiquement. */ __( '%1$d schemas imported from %2$s, %3$d failures.', 'aiforya-schema-markup' ), (int) $r['ok'], $source, (int) $r['fail'] ), ) ) ); } /** * Smart Bulk Editor — actions en masse sur N posts sélectionnés. * action ∈ { regenerate, validate, delete, change_type }. */ public function bulk_action_handler(): void { check_ajax_referer( 'aiforya_sm_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'aiforya-schema-markup' ) ), 403 ); return; } $action = isset( $_POST['bulk_action'] ) ? sanitize_key( wp_unslash( (string) $_POST['bulk_action'] ) ) : ''; $post_ids = array(); if ( isset( $_POST['post_ids'] ) && is_array( $_POST['post_ids'] ) ) { $post_ids = array_map( 'absint', wp_unslash( $_POST['post_ids'] ) ); $post_ids = array_values( array_filter( $post_ids ) ); } $new_type = isset( $_POST['new_type'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['new_type'] ) ) : ''; if ( empty( $action ) || empty( $post_ids ) ) { wp_send_json_error( array( 'message' => __( 'Invalid action or selection.', 'aiforya-schema-markup' ) ), 400 ); return; } if ( ! in_array( $action, array( 'regenerate', 'validate', 'delete', 'change_type' ), true ) ) { wp_send_json_error( array( 'message' => __( 'Unsupported action.', 'aiforya-schema-markup' ) ), 400 ); return; } // Cap pour éviter timeout PHP : on traite au max 50 posts par appel. $batch = array_slice( $post_ids, 0, 50 ); $deferred = array_slice( $post_ids, 50 ); $db = Aiforya_Schema_Markup_Db::get_instance(); $stats = array( 'ok' => 0, 'fail' => 0, 'skipped' => 0 ); $messages = array(); if ( $action === 'change_type' ) { if ( empty( $new_type ) ) { wp_send_json_error( array( 'message' => __( 'New type required for change_type.', 'aiforya-schema-markup' ) ), 400 ); return; } $valid_types = Aiforya_Schema_Markup_Detector::supported_schema_types(); if ( ! in_array( $new_type, $valid_types, true ) ) { wp_send_json_error( array( 'message' => sprintf( /* translators: %s : valeur insérée dynamiquement. */ __( 'Invalid Schema.org type: %s', 'aiforya-schema-markup' ), $new_type ) ), 400 ); return; } $generator = Aiforya_Schema_Markup_Generator::get_instance(); foreach ( $batch as $pid ) { $existing = $db->get_schema_by_post_id( $pid ); if ( ! $existing ) { $stats['skipped']++; continue; } $json = $generator->generate_json_ld( $pid, $new_type ); if ( is_wp_error( $json ) ) { $stats['fail']++; continue; } $r = $db->save_or_update_schema( array( 'post_id' => $pid, 'schema_type' => $new_type, 'schema_json' => $json, 'is_manual_override' => 1, ) ); if ( is_wp_error( $r ) ) { $stats['fail']++; continue; } $generator->clear_cache_for_post( $pid ); $stats['ok']++; } } elseif ( $action === 'regenerate' ) { $cron = Aiforya_Schema_Markup_Cron::get_instance(); foreach ( $batch as $pid ) { $r = $cron->generate_for_one_post( $pid, true ); if ( is_wp_error( $r ) ) { $stats['fail']++; continue; } $stats['ok']++; } } elseif ( $action === 'validate' ) { foreach ( $batch as $pid ) { $schema = $db->get_schema_by_post_id( $pid ); if ( ! $schema || empty( $schema->schema_json ) ) { $stats['skipped']++; continue; } $validation = self::validate_jsonld_locally( (string) $schema->schema_json ); if ( method_exists( $db, 'log_validation' ) ) { $db->log_validation( array( 'schema_id' => $schema->id, 'post_id' => $pid, 'status' => $validation['status'], 'response' => wp_json_encode( $validation ), 'error_count' => count( $validation['errors'] ), 'warning_count' => count( $validation['warnings'] ), ) ); } $stats['ok']++; } } elseif ( $action === 'delete' ) { foreach ( $batch as $pid ) { $r = $db->delete_schema_for_post( $pid ); if ( $r ) { $stats['ok']++; } else { $stats['fail']++; } } } wp_send_json_success( array( 'action' => $action, 'ok' => $stats['ok'], 'fail' => $stats['fail'], 'skipped' => $stats['skipped'], 'deferred' => count( $deferred ), 'next_ids' => $deferred, 'message' => sprintf( /* translators: %1$d : valeur insérée dynamiquement, %2$d : valeur insérée dynamiquement, %3$d : valeur insérée dynamiquement, %4$s : valeur insérée dynamiquement. */ __( '%1$d processed, %2$d failure(s), %3$d skipped%4$s.', 'aiforya-schema-markup' ), $stats['ok'], $stats['fail'], $stats['skipped'], count( $deferred ) > 0 ? ' ' . sprintf( /* translators: %d : valeur insérée dynamiquement. */ __( '— %d pending until next batch', 'aiforya-schema-markup' ), count( $deferred ) ) : '' ), ) ); } /** * Handler "Supprimer le schéma" — efface UNIQUEMENT le schema JSON-LD du post, * le post WordPress reste intact. */ public function delete_schema_for_post_handler(): void { check_ajax_referer( 'aiforya_sm_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'aiforya-schema-markup' ) ), 403 ); return; } $post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0; if ( ! $post_id ) { wp_send_json_error( array( 'message' => __( 'Invalid post ID.', 'aiforya-schema-markup' ) ), 400 ); return; } $db = Aiforya_Schema_Markup_Db::get_instance(); $generator = Aiforya_Schema_Markup_Generator::get_instance(); $r = $db->delete_schema_for_post( $post_id ); $generator->clear_cache_for_post( $post_id ); if ( ! $r ) { wp_send_json_error( array( 'message' => __( 'No schema to delete or DB error.', 'aiforya-schema-markup' ) ) ); return; } wp_send_json_success( array( 'message' => __( 'Schema deleted. The WordPress post remains intact.', 'aiforya-schema-markup' ), ) ); } /** * Handler "View JSON-LD" — récupère le JSON-LD stocké pour un post. */ public function get_schema_json_handler(): void { check_ajax_referer( 'aiforya_sm_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'aiforya-schema-markup' ) ), 403 ); return; } $post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0; if ( ! $post_id ) { wp_send_json_error( array( 'message' => __( 'Invalid post ID.', 'aiforya-schema-markup' ) ), 400 ); return; } $db = Aiforya_Schema_Markup_Db::get_instance(); $schema = $db->get_schema_by_post_id( $post_id ); if ( ! $schema || empty( $schema->schema_json ) ) { wp_send_json_error( array( 'message' => __( 'No schema stored for this post. Please regenerate first.', 'aiforya-schema-markup' ) ) ); return; } wp_send_json_success( array( 'json' => (string) $schema->schema_json, 'schema_type' => (string) ( $schema->schema_type ?? '' ), 'updated_at' => (string) ( $schema->updated_at ?? '' ), ) ); } /** * Handler "Générer schémas pour tous les posts publiés sans schéma" (bouton Dashboard). * Traite un batch (taille configurable) à la fois pour éviter timeout PHP. */ public function generate_bulk_handler(): void { check_ajax_referer( 'aiforya_sm_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'aiforya-schema-markup' ) ), 403 ); return; } $batch = isset( $_POST['batch_size'] ) ? max( 5, min( 100, (int) $_POST['batch_size'] ) ) : 25; $cron = Aiforya_Schema_Markup_Cron::get_instance(); $stats = $cron->generate_bulk( $batch ); wp_send_json_success( array( 'ok' => (int) $stats['ok'], 'fail' => (int) $stats['fail'], 'skipped' => (int) $stats['skipped'], 'remaining' => (int) $stats['remaining'], 'errors' => isset( $stats['errors'] ) ? array_values( (array) $stats['errors'] ) : array(), 'message' => sprintf( /* translators: %1$d : valeur insérée dynamiquement, %2$d : valeur insérée dynamiquement, %3$d : valeur insérée dynamiquement, %4$d : valeur insérée dynamiquement. */ __( '%1$d generated, %2$d failure(s), %3$d ignored — %4$d remaining.', 'aiforya-schema-markup' ), (int) $stats['ok'], (int) $stats['fail'], (int) $stats['skipped'], (int) $stats['remaining'] ), ) ); } /** * Handler "Générer/Régénérer ce post" (bouton ligne par ligne dans tab Schemas). */ public function generate_one_handler(): void { check_ajax_referer( 'aiforya_sm_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'aiforya-schema-markup' ) ), 403 ); return; } $post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0; if ( ! $post_id ) { wp_send_json_error( array( 'message' => __( 'Invalid post ID.', 'aiforya-schema-markup' ) ) ); return; } $cron = Aiforya_Schema_Markup_Cron::get_instance(); $result = $cron->generate_for_one_post( $post_id, true ); if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => $result->get_error_message() ) ); return; } $db = Aiforya_Schema_Markup_Db::get_instance(); $schema = $db->get_schema_by_post_id( $post_id ); wp_send_json_success( array( 'message' => __( 'Schema generated successfully.', 'aiforya-schema-markup' ), 'schema_id' => $schema ? (int) $schema->id : null, 'schema_type' => $schema ? (string) $schema->schema_type : '', ) ); } /** * Handler save des Réglages : options règle-based uniquement * (auto_inject + cache TTL + types de contenu + cleanup_on_uninstall opt-in). */ public function save_settings_handler(): void { check_ajax_referer( 'aiforya_sm_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'aiforya-schema-markup' ) ), 403 ); return; } $settings = get_option( 'aiforya_sm_settings', array() ); $advanced = get_option( 'aiforya_sm_advanced', array() ); if ( ! is_array( $settings ) ) { $settings = array(); } if ( ! is_array( $advanced ) ) { $advanced = array(); } $settings['auto_inject_enabled'] = ! empty( $_POST['auto_inject_enabled'] ) ? 1 : 0; $settings['cache_ttl_minutes'] = isset( $_POST['cache_ttl_minutes'] ) ? max( 60, (int) $_POST['cache_ttl_minutes'] ) : 1440; // Types de contenu ciblés (post types). if ( isset( $_POST['content_types'] ) && is_array( $_POST['content_types'] ) ) { $types = array_map( 'sanitize_key', wp_unslash( $_POST['content_types'] ) ); $settings['content_types'] = array_values( array_filter( $types ) ); } $advanced['cleanup_on_uninstall'] = ! empty( $_POST['cleanup_on_uninstall'] ) ? 1 : 0; update_option( 'aiforya_sm_settings', $settings, false ); update_option( 'aiforya_sm_advanced', $advanced, false ); wp_send_json_success( array( 'message' => __( 'Settings saved.', 'aiforya-schema-markup' ) ) ); } /** * Handler pour récupérer les stats du dashboard. */ public function get_dashboard_stats_handler( ): void { check_ajax_referer( 'aiforya_sm_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'aiforya-schema-markup' ) ), 403 ); return; } $db = Aiforya_Schema_Markup_Db::get_instance(); $stats = $db->get_dashboard_stats(); if ( is_wp_error( $stats ) ) { wp_send_json_error( array( 'message' => $stats->get_error_message() ), 500 ); return; } wp_send_json_success( $stats ); } /** * Handler pour la liste paginée des schémas. */ public function get_schemas_paginated_handler( ): void { check_ajax_referer( 'aiforya_sm_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'aiforya-schema-markup' ) ), 403 ); return; } $page = isset( $_POST['page'] ) ? absint( $_POST['page'] ) : 1; $per_page = isset( $_POST['per_page'] ) ? absint( $_POST['per_page'] ) : 20; $search = isset( $_POST['search'] ) ? sanitize_text_field( wp_unslash( $_POST['search'] ) ) : ''; $schema_type = isset( $_POST['schema_type'] ) ? sanitize_text_field( wp_unslash( $_POST['schema_type'] ) ) : ''; $args = array( 'offset' => ( $page - 1 ) * $per_page, 'limit' => $per_page, ); if ( ! empty( $search ) ) { $args['search'] = $search; } if ( ! empty( $schema_type ) ) { $args['schema_type'] = $schema_type; } $db = Aiforya_Schema_Markup_Db::get_instance(); $schemas = $db->get_schemas_for_listing( $args ); $total = $db->count_schemas( $args ); if ( is_wp_error( $schemas ) ) { wp_send_json_error( array( 'message' => $schemas->get_error_message() ), 500 ); return; } wp_send_json_success( array( 'schemas' => $schemas, 'total' => $total, 'page' => $page, 'per_page' => $per_page, 'total_pages' => ceil( $total / $per_page ), ) ); } /** * Handler pour changer manuellement le type d'un schéma. */ public function override_schema_type_handler( ): void { check_ajax_referer( 'aiforya_sm_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'aiforya-schema-markup' ) ), 403 ); return; } $post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0; $new_type = isset( $_POST['schema_type'] ) ? sanitize_text_field( wp_unslash( $_POST['schema_type'] ) ) : ''; if ( ! $post_id || empty( $new_type ) ) { wp_send_json_error( array( 'message' => __( 'Invalid parameters.', 'aiforya-schema-markup' ) ), 400 ); return; } $db = Aiforya_Schema_Markup_Db::get_instance(); $existing = $db->get_schema_by_post_id( $post_id ); if ( ! $existing ) { wp_send_json_error( array( 'message' => __( 'Schema not found.', 'aiforya-schema-markup' ) ), 404 ); return; } // Regénérer le schéma avec le nouveau type require_once AIFORYA_SM_PATH . 'includes/class-aiforya-schema-markup-generator.php'; $generator = Aiforya_Schema_Markup_Generator::get_instance(); $json_ld = $generator->generate_json_ld( $post_id, $new_type ); if ( is_wp_error( $json_ld ) ) { wp_send_json_error( array( 'message' => $json_ld->get_error_message() ), 500 ); return; } $result = $db->save_or_update_schema( array( 'post_id' => $post_id, 'schema_type' => $new_type, 'schema_json' => $json_ld, 'is_manual_override' => 1, ) ); if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => $result->get_error_message() ), 500 ); return; } $generator->clear_cache_for_post( $post_id ); wp_send_json_success( array( 'message' => __( 'Schema type updated successfully.', 'aiforya-schema-markup' ), 'schema_id' => $result, ) ); } /** * Handler pour lancer une validation Google. */ public function validate_with_google_handler( ): void { check_ajax_referer( 'aiforya_sm_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'aiforya-schema-markup' ) ), 403 ); return; } $post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0; if ( ! $post_id ) { wp_send_json_error( array( 'message' => __( 'Invalid post ID.', 'aiforya-schema-markup' ) ), 400 ); return; } $db = Aiforya_Schema_Markup_Db::get_instance(); $schema = $db->get_schema_by_post_id( $post_id ); if ( ! $schema ) { wp_send_json_error( array( 'message' => __( 'No schema found for this post. Please generate it first.', 'aiforya-schema-markup' ) ) ); return; } // Validation locale (Google Rich Results Test API requiert OAuth Search Console). // On valide les champs requis Schema.org par type, on log le résultat, et on retourne l'URL externe // Google Rich Results Test pour validation manuelle dans un nouvel onglet. $post_url = get_permalink( $post_id ); $external_url = 'https://search.google.com/test/rich-results?url=' . rawurlencode( $post_url ); $validation = self::validate_jsonld_locally( (string) $schema->schema_json ); $status = $validation['status']; $error_count = count( $validation['errors'] ); $warning_count = count( $validation['warnings'] ); $messages = array_merge( array_map( function ( $e ) { return '[ERROR] ' . $e; }, $validation['errors'] ), array_map( function ( $w ) { return '[WARN] ' . $w; }, $validation['warnings'] ) ); // Log dans la table validation_log si la méthode existe. if ( method_exists( $db, 'log_validation' ) ) { $db->log_validation( array( 'schema_id' => $schema->id, 'post_id' => $post_id, 'status' => $status, 'response' => wp_json_encode( $validation ), 'error_count' => $error_count, 'warning_count' => $warning_count, ) ); } wp_send_json_success( array( 'status' => $status, 'error_count' => $error_count, 'warning_count' => $warning_count, 'message' => $status === 'valid' ? __( 'Local validation OK. Click the external button for official Google validation.', 'aiforya-schema-markup' ) : sprintf( /* translators: %1$d : valeur insérée dynamiquement, %2$d : valeur insérée dynamiquement. */ __( '%1$d error(s), %2$d warning(s) detected. See details below.', 'aiforya-schema-markup' ), $error_count, $warning_count ), 'messages' => $messages, 'external_url' => $external_url, 'external_label' => __( 'Test on Google Rich Results', 'aiforya-schema-markup' ), ) ); } /** * Validation locale du JSON-LD selon les champs requis Google Rich Results par type. * Référence : https://developers.google.com/search/docs/appearance/structured-data/search-gallery */ public static function validate_jsonld_locally( string $jsonld ): array { $errors = array(); $warnings = array(); $data = json_decode( $jsonld, true ); if ( null === $data || ! is_array( $data ) ) { return array( 'status' => 'error', 'errors' => array( 'Invalid or unparsable JSON-LD.' ), 'warnings' => array() ); } $type = (string) ( $data['@type'] ?? '' ); if ( $type === '' ) { return array( 'status' => 'error', 'errors' => array( 'Missing @type field — Schema.org requires it.' ), 'warnings' => array() ); } if ( empty( $data['@context'] ) ) { $warnings[] = '@context is missing — recommended: "https://schema.org".'; } // Champs requis par type (Google Rich Results requirements). $required = array( 'Article' => array( 'headline', 'image', 'datePublished', 'author', 'publisher' ), 'NewsArticle' => array( 'headline', 'image', 'datePublished', 'author', 'publisher' ), 'BlogPosting' => array( 'headline', 'image', 'datePublished', 'author', 'publisher' ), 'TechArticle' => array( 'headline', 'image', 'datePublished', 'author' ), 'Product' => array( 'name', 'image', 'description', 'offers' ), 'Recipe' => array( 'name', 'image', 'recipeIngredient', 'recipeInstructions' ), 'Event' => array( 'name', 'startDate', 'location' ), 'FAQPage' => array( 'mainEntity' ), 'QAPage' => array( 'mainEntity' ), 'HowTo' => array( 'name', 'step' ), 'VideoObject' => array( 'name', 'description', 'thumbnailUrl', 'uploadDate' ), 'LocalBusiness' => array( 'name', 'address', 'telephone' ), 'Restaurant' => array( 'name', 'address' ), 'Organization' => array( 'name', 'url' ), 'Person' => array( 'name' ), 'Course' => array( 'name', 'description', 'provider' ), 'JobPosting' => array( 'title', 'description', 'datePosted', 'hiringOrganization', 'jobLocation' ), 'Review' => array( 'itemReviewed', 'reviewRating', 'author' ), 'BreadcrumbList' => array( 'itemListElement' ), 'WebPage' => array( 'name' ), 'AboutPage' => array( 'name' ), 'ContactPage' => array( 'name' ), ); $req = $required[ $type ] ?? array(); foreach ( $req as $field ) { if ( ! isset( $data[ $field ] ) || ( is_array( $data[ $field ] ) && empty( $data[ $field ] ) ) || $data[ $field ] === '' ) { $errors[] = sprintf( 'Missing required field for %s: "%s"', $type, $field ); } } // Validation spécifique pour les types qui ont des sous-structures. if ( $type === 'Product' && isset( $data['offers'] ) && is_array( $data['offers'] ) ) { $offers = isset( $data['offers'][0] ) ? $data['offers'] : array( $data['offers'] ); foreach ( $offers as $idx => $offer ) { if ( ! is_array( $offer ) ) continue; foreach ( array( 'price', 'priceCurrency' ) as $f ) { if ( empty( $offer[ $f ] ) ) { $errors[] = sprintf( 'offers[%d].%s missing for Product.', $idx, $f ); } } if ( empty( $offer['availability'] ) ) { $warnings[] = sprintf( 'offers[%d].availability is recommended for Product (for example https://schema.org/InStock).', $idx ); } } } if ( $type === 'FAQPage' && isset( $data['mainEntity'] ) && is_array( $data['mainEntity'] ) ) { $entities = isset( $data['mainEntity'][0] ) ? $data['mainEntity'] : array( $data['mainEntity'] ); if ( count( $entities ) < 2 ) { $warnings[] = 'FAQPage: Google recommends at least 2 question/answer pairs.'; } foreach ( $entities as $idx => $q ) { if ( ! is_array( $q ) ) continue; if ( ( $q['@type'] ?? '' ) !== 'Question' ) { $errors[] = sprintf( 'mainEntity[%d].@type must be "Question".', $idx ); } if ( empty( $q['name'] ) || empty( $q['acceptedAnswer']['text'] ) ) { $errors[] = sprintf( 'mainEntity[%d]: "name" (the question) and "acceptedAnswer.text" (the answer) are required.', $idx ); } } } if ( $type === 'HowTo' && isset( $data['step'] ) && is_array( $data['step'] ) ) { if ( count( $data['step'] ) < 2 ) { $warnings[] = 'HowTo: Google recommends at least 2 steps.'; } } if ( $type === 'Event' && isset( $data['startDate'] ) ) { // ISO 8601 check basique if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}/', (string) $data['startDate'] ) ) { $warnings[] = 'Event.startDate should use the ISO 8601 format (for example 2026-12-31).'; } } $status = empty( $errors ) ? ( empty( $warnings ) ? 'valid' : 'warning' ) : 'error'; return array( 'status' => $status, 'errors' => $errors, 'warnings' => $warnings, '@type' => $type, ); } /** * Handler pour sauvegarder les mappings de types. */ public function save_type_mappings_handler( ): void { check_ajax_referer( 'aiforya_sm_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'aiforya-schema-markup' ) ), 403 ); return; } // Format FLAT cohérent avec Activator + Detector : // schema_type[post]=Article + auto_generate[post]=1 → 2 options DB séparées. // Both arrays are sanitized element by element in the two foreach loops below // (sanitize_key on every key, sanitize_text_field / integer cast on every value). // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- every key and value is sanitized in the foreach loop below. $schema_type_raw = isset( $_POST['schema_type'] ) && is_array( $_POST['schema_type'] ) ? wp_unslash( $_POST['schema_type'] ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- every key and value is sanitized in the foreach loop below. $auto_generate_raw = isset( $_POST['auto_generate'] ) && is_array( $_POST['auto_generate'] ) ? wp_unslash( $_POST['auto_generate'] ) : array(); $sanitized_mappings = array(); foreach ( $schema_type_raw as $post_type => $schema_type ) { $pt = sanitize_key( (string) $post_type ); $st = sanitize_text_field( (string) $schema_type ); if ( $pt !== '' && $st !== '' ) { $sanitized_mappings[ $pt ] = $st; } } $sanitized_auto = array(); foreach ( $auto_generate_raw as $post_type => $val ) { $pt = sanitize_key( (string) $post_type ); if ( $pt !== '' ) { $sanitized_auto[ $pt ] = ! empty( $val ) ? 1 : 0; } } update_option( 'aiforya_sm_type_mappings', $sanitized_mappings, false ); update_option( 'aiforya_sm_auto_generate', $sanitized_auto, false ); $updated = true; if ( $updated === false ) { // Option might already have the same value $current = get_option( 'aiforya_sm_type_mappings', array() ); if ( $current === $sanitized_mappings ) { wp_send_json_success( array( 'message' => __( 'Type mappings saved (no changes).', 'aiforya-schema-markup' ), ) ); return; } wp_send_json_error( array( 'message' => __( 'Error while saving mappings.', 'aiforya-schema-markup' ) ), 500 ); return; } wp_send_json_success( array( 'message' => __( 'Type mappings saved successfully.', 'aiforya-schema-markup' ), 'mappings' => $sanitized_mappings, ) ); } }