add_action( 'rest_api_init', $this, 'register_routes' ); } /** * Registers every REST route under the abds-seo/v1 namespace. */ public function register_routes(): void { register_rest_route( self::NAMESPACE_V1, '/content-analysis/(?P\d+)', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_content_analysis' ), 'permission_callback' => array( $this, 'can_edit_post' ), 'args' => array( 'post_id' => array( 'validate_callback' => fn( $value ) => is_numeric( $value ), ), 'keyword' => array( 'required' => false, 'sanitize_callback' => 'sanitize_text_field', ), ), ) ); register_rest_route( self::NAMESPACE_V1, '/content-analysis-preview', array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( $this, 'get_content_analysis_preview' ), 'permission_callback' => array( $this, 'can_edit_post' ), 'args' => array( 'post_id' => array( 'required' => true, 'validate_callback' => fn( $value ) => is_numeric( $value ), ), 'keyword' => array( 'required' => false, 'sanitize_callback' => 'sanitize_text_field', ), 'title' => array( 'required' => false, 'sanitize_callback' => 'sanitize_text_field', ), 'post_title' => array( 'required' => false, 'sanitize_callback' => 'sanitize_text_field', ), 'description' => array( 'required' => false, 'sanitize_callback' => 'sanitize_text_field', ), 'content' => array( 'required' => false, 'sanitize_callback' => 'wp_kses_post', ), ), ) ); register_rest_route( self::NAMESPACE_V1, '/keyword-scores', array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( $this, 'get_keyword_scores' ), 'permission_callback' => array( $this, 'can_edit_post' ), 'args' => array( 'post_id' => array( 'required' => true, 'validate_callback' => fn( $value ) => is_numeric( $value ), ), 'keywords' => array( 'required' => true, 'validate_callback' => fn( $value ) => is_array( $value ), ), 'title' => array( 'required' => false, 'sanitize_callback' => 'sanitize_text_field', ), 'post_title' => array( 'required' => false, 'sanitize_callback' => 'sanitize_text_field', ), 'description' => array( 'required' => false, 'sanitize_callback' => 'sanitize_text_field', ), 'content' => array( 'required' => false, 'sanitize_callback' => 'wp_kses_post', ), ), ) ); register_rest_route( self::NAMESPACE_V1, '/technical-audit/(?P\d+)', array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( $this, 'run_technical_audit' ), 'permission_callback' => array( $this, 'can_edit_post' ), 'args' => array( 'post_id' => array( 'validate_callback' => fn( $value ) => is_numeric( $value ), ), ), ) ); register_rest_route( self::NAMESPACE_V1, '/migration/status', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_migration_status' ), 'permission_callback' => array( $this, 'can_manage_plugin' ), ) ); register_rest_route( self::NAMESPACE_V1, '/migration/preview', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'preview_migration' ), 'permission_callback' => array( $this, 'can_manage_plugin' ), 'args' => array( 'source' => array( 'required' => true, 'sanitize_callback' => 'sanitize_key', ), ), ) ); register_rest_route( self::NAMESPACE_V1, '/migration/run', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'run_migration' ), 'permission_callback' => array( $this, 'can_manage_plugin' ), 'args' => array( 'source' => array( 'required' => true, 'sanitize_callback' => 'sanitize_key', ), ), ) ); register_rest_route( self::NAMESPACE_V1, '/migration/deactivate-source', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'deactivate_source_plugin' ), // Deactivates another plugin, so it also requires core activate_plugins (not just manage_abds_seo). 'permission_callback' => array( $this, 'can_manage_plugin_activation' ), 'args' => array( 'source' => array( 'required' => true, 'sanitize_callback' => 'sanitize_key', ), ), ) ); register_rest_route( self::NAMESPACE_V1, '/dashboard/summary', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_dashboard_summary' ), 'permission_callback' => array( $this, 'can_manage_plugin' ), ) ); register_rest_route( self::NAMESPACE_V1, '/reports', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_reports' ), 'permission_callback' => array( $this, 'can_manage_plugin' ), 'args' => array( 'date_from' => array( 'sanitize_callback' => 'sanitize_text_field' ), 'date_to' => array( 'sanitize_callback' => 'sanitize_text_field' ), 'post_type' => array( 'sanitize_callback' => 'sanitize_key' ), 'min_score' => array( 'sanitize_callback' => 'absint' ), 'max_score' => array( 'sanitize_callback' => 'absint' ), 'page' => array( 'sanitize_callback' => 'absint' ), 'per_page' => array( 'sanitize_callback' => 'absint' ), 'orderby' => array( 'sanitize_callback' => 'sanitize_key' ), 'order' => array( 'sanitize_callback' => 'sanitize_key' ), ), ) ); register_rest_route( self::NAMESPACE_V1, '/changes-log/(?P\d+)', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_changes_log' ), 'permission_callback' => array( $this, 'can_edit_post' ), 'args' => array( 'post_id' => array( 'validate_callback' => fn( $value ) => is_numeric( $value ), ), ), ) ); register_rest_route( self::NAMESPACE_V1, '/priority', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_priority_list' ), 'permission_callback' => array( $this, 'can_manage_plugin' ), 'args' => array( 'limit' => array( 'type' => 'integer', 'default' => 20, 'minimum' => 1, 'maximum' => 100, 'sanitize_callback' => 'absint', ), ), ) ); register_rest_route( self::NAMESPACE_V1, '/changes-log/(?P\d+)/undo', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'undo_change' ), 'permission_callback' => array( $this, 'can_edit_change_log_post' ), 'args' => array( 'id' => array( 'validate_callback' => fn( $value ) => is_numeric( $value ), ), ), ) ); } /** * Permission callback for /changes-log/{id}/undo: current user can edit * the post the referenced changes-log row belongs to. {id} identifies * the log row itself, not a post, so its post_id has to be looked up * first. * * @param WP_REST_Request $request REST request. * @return bool */ public function can_edit_change_log_post( WP_REST_Request $request ) { global $wpdb; $id = (int) $request->get_param( 'id' ); $table = $wpdb->prefix . 'abds_seo_changes_log'; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table is built only from $wpdb->prefix plus a hardcoded literal, never user input; $id is passed through $wpdb->prepare(). Runs once per undo permission check, not a cacheable read path. $post_id = (int) $wpdb->get_var( $wpdb->prepare( "SELECT post_id FROM {$table} WHERE id = %d", $id ) ); if ( ! $post_id ) { return false; } return current_user_can( ABDS_SEO_Activator::CONTENT_CAPABILITY ) && current_user_can( 'edit_post', $post_id ); } /** * GET /content-analysis/{post_id}: runs the rule-based content analyzer. * Also includes the pre-publish-warning threshold alongside the score * it's compared against, rather than the Gutenberg sidebar hardcoding * its own copy of a value that's meant to be tunable via the * abds_seo_prepublish_threshold filter without a code release. * * @param WP_REST_Request $request REST request. * @return WP_REST_Response */ public function get_content_analysis( WP_REST_Request $request ) { $post_id = (int) $request->get_param( 'post_id' ); $keyword = (string) $request->get_param( 'keyword' ); if ( '' === $keyword ) { $keyword = (string) get_post_meta( $post_id, '_abds_seo_focus_keyword', true ); } $analyzer = new ABDS_SEO_Content_Analyzer(); $result = $analyzer->analyze( $post_id, $keyword ); /** * Filters the content_score threshold below which the Gutenberg * pre-publish panel warns the user. A starting heuristic, not a * researched number — meant to be recalibrated via this filter once * real usage data exists, without needing a plugin release. * * @param int $threshold Minimum acceptable content_score (0-100). Default 60. */ $result['prepublish_threshold'] = (int) apply_filters( 'abds_seo_prepublish_threshold', 60 ); return rest_ensure_response( $result ); } /** * POST /content-analysis-preview: live, debounced re-analysis of * in-progress edits — title/description/content/focus-keyword, any * subset — against a real post's other, still-saved fields, WITHOUT * writing anything to wp_abds_seo_page_scores. Powers the Gutenberg * sidebar's and Classic Editor meta box's live score/recommendations * feedback while the user is still typing, not yet saved. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function get_content_analysis_preview( WP_REST_Request $request ) { $post_id = (int) $request->get_param( 'post_id' ); $keyword = (string) $request->get_param( 'keyword' ); if ( '' === $keyword ) { $keyword = (string) get_post_meta( $post_id, '_abds_seo_focus_keyword', true ); } $overrides = array(); foreach ( array( 'title', 'post_title', 'description', 'content' ) as $field ) { $value = $request->get_param( $field ); if ( null !== $value ) { $overrides[ $field ] = (string) $value; } } $analyzer = new ABDS_SEO_Content_Analyzer(); $result = $analyzer->preview( $post_id, $keyword, $overrides ); $result['prepublish_threshold'] = (int) apply_filters( 'abds_seo_prepublish_threshold', 60 ); return rest_ensure_response( $result ); } /** * POST /keyword-scores: per-keyword chip scores for 3.3's multi-keyword * UI (Pro; the free plugin exposes this route regardless — the write * side, not the read side, is where secondary keywords are gated, per * ABDS_SEO_Secondary_Keywords). Same draft-field-override shape as * /content-analysis-preview, plus `keywords`, an array scored against * the SAME resolved fields in one pass (see * ABDS_SEO_Content_Analyzer::score_keywords()). * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function get_keyword_scores( WP_REST_Request $request ) { $post_id = (int) $request->get_param( 'post_id' ); $keywords = (array) $request->get_param( 'keywords' ); $overrides = array(); foreach ( array( 'title', 'post_title', 'description', 'content' ) as $field ) { $value = $request->get_param( $field ); if ( null !== $value ) { $overrides[ $field ] = (string) $value; } } $analyzer = new ABDS_SEO_Content_Analyzer(); return rest_ensure_response( $analyzer->score_keywords( $post_id, $keywords, $overrides ) ); } /** * POST/PUT /technical-audit/{post_id}: runs the technical scanner on one post. * * @param WP_REST_Request $request REST request. * @return WP_REST_Response */ public function run_technical_audit( WP_REST_Request $request ) { $post_id = (int) $request->get_param( 'post_id' ); $scanner = new ABDS_SEO_Site_Scanner(); return rest_ensure_response( $scanner->scan_post( $post_id ) ); } /** * GET /migration/status: lists active source plugins and whether * migration has completed. * * @return WP_REST_Response */ public function get_migration_status() { $manager = new ABDS_SEO_Migration_Manager(); $adapters = array_map( fn( $adapter ) => array( 'slug' => $adapter->get_slug(), 'name' => $adapter->get_name(), ), $manager->get_active_adapters() ); return rest_ensure_response( array( 'active_source_plugins' => $adapters, 'migration_completed' => $manager->is_migration_completed(), ) ); } /** * GET /migration/preview: dry-run preview of a migration source. * * @param WP_REST_Request $request REST request; expects a `source` param. * @return WP_REST_Response|WP_Error */ public function preview_migration( WP_REST_Request $request ) { $source = (string) $request->get_param( 'source' ); $manager = new ABDS_SEO_Migration_Manager(); $adapter = $this->find_adapter( $manager, $source ); if ( ! $adapter ) { return new WP_Error( 'abds_seo_unknown_source', __( 'Unknown SEO plugin for migration.', 'abds-seo' ), array( 'status' => 404 ) ); } return rest_ensure_response( $manager->preview( $adapter ) ); } /** * POST /migration/run: runs a migration from the given source. * * @param WP_REST_Request $request REST request; expects a `source` param. * @return WP_REST_Response|WP_Error */ public function run_migration( WP_REST_Request $request ) { $source = (string) $request->get_param( 'source' ); $manager = new ABDS_SEO_Migration_Manager(); $adapter = $this->find_adapter( $manager, $source ); if ( ! $adapter ) { return new WP_Error( 'abds_seo_unknown_source', __( 'Unknown SEO plugin for migration.', 'abds-seo' ), array( 'status' => 404 ) ); } return rest_ensure_response( $manager->migrate( $adapter ) ); } /** * POST /migration/deactivate-source: deactivates a migrated-from source plugin. * * @param WP_REST_Request $request REST request; expects a `source` param. * @return WP_REST_Response|WP_Error */ public function deactivate_source_plugin( WP_REST_Request $request ) { $source = (string) $request->get_param( 'source' ); $manager = new ABDS_SEO_Migration_Manager(); $adapter = $this->find_adapter( $manager, $source ); if ( ! $adapter ) { return new WP_Error( 'abds_seo_unknown_source', __( 'Unknown SEO plugin for migration.', 'abds-seo' ), array( 'status' => 404 ) ); } return rest_ensure_response( array( 'deactivated' => $adapter->deactivate_self() ) ); } /** * Finds a migration adapter by its slug. * * @param ABDS_SEO_Migration_Manager $manager Migration manager. * @param string $slug Adapter slug, e.g. "yoast". * @return ABDS_SEO_Migration_Adapter|null */ protected function find_adapter( ABDS_SEO_Migration_Manager $manager, string $slug ): ?ABDS_SEO_Migration_Adapter { foreach ( $manager->get_adapters() as $adapter ) { if ( $adapter->get_slug() === $slug ) { return $adapter; } } return null; } /** * GET /dashboard/summary: average technical/content scores and the * most recent scan, for the wp-admin dashboard. * * @return WP_REST_Response */ public function get_dashboard_summary() { global $wpdb; $page_scores_table = $wpdb->prefix . 'abds_seo_page_scores'; $scans_table = $wpdb->prefix . 'abds_seo_scans'; // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table names are built only from $wpdb->prefix plus a hardcoded literal, never user input; $wpdb->prepare() cannot parameterize identifiers. This REST route is only used to render the low-traffic admin dashboard, so the small aggregate queries are cheap enough to run uncached on each request. $avg_technical = $wpdb->get_var( "SELECT AVG(technical_score) FROM {$page_scores_table}" ); $avg_content = $wpdb->get_var( "SELECT AVG(content_score) FROM {$page_scores_table}" ); $last_scan = $wpdb->get_row( "SELECT * FROM {$scans_table} ORDER BY id DESC LIMIT 1" ); // phpcs:enable return rest_ensure_response( array( 'avg_technical_score' => $avg_technical ? round( (float) $avg_technical, 1 ) : null, 'avg_content_score' => $avg_content ? round( (float) $avg_content, 1 ) : null, 'last_scan' => $last_scan, ) ); } /** * GET /reports: paginated, filtered content-score report — one * aggregated SQL query per request, built and executed by * ABDS_SEO_Reports so this stays in sync with the CSV export and the * server-rendered reports admin page rather than three separate query * implementations drifting apart. * * @param WP_REST_Request $request REST request. * @return WP_REST_Response */ public function get_reports( WP_REST_Request $request ) { $args = array( 'date_from' => (string) $request->get_param( 'date_from' ), 'date_to' => (string) $request->get_param( 'date_to' ), 'post_type' => (string) $request->get_param( 'post_type' ), 'min_score' => $request->get_param( 'min_score' ), 'max_score' => $request->get_param( 'max_score' ), 'page' => $request->get_param( 'page' ), 'per_page' => $request->get_param( 'per_page' ), 'orderby' => (string) $request->get_param( 'orderby' ), 'order' => (string) $request->get_param( 'order' ), ); return rest_ensure_response( ( new ABDS_SEO_Reports() )->query( $args ) ); } /** * GET /changes-log/{post_id}: lists the most recent tracked-meta * changes (SEO title/description/focus keyword) for a post. * * @param WP_REST_Request $request REST request. * @return WP_REST_Response */ public function get_changes_log( WP_REST_Request $request ) { global $wpdb; $post_id = (int) $request->get_param( 'post_id' ); $table = $wpdb->prefix . 'abds_seo_changes_log'; // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table is built only from $wpdb->prefix plus a hardcoded literal, never user input; $post_id/limit are passed through $wpdb->prepare(). Runs once per sidebar history-panel load, not a cacheable read path. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, user_id, change_type, before_value, after_value, created_at FROM {$table} WHERE post_id = %d ORDER BY created_at DESC, id DESC", $post_id ) ); // phpcs:enable $entries = array_map( function ( $row ) { $user = get_userdata( (int) $row->user_id ); return array( 'id' => (int) $row->id, 'change_type' => $row->change_type, 'before_value' => $row->before_value, 'after_value' => $row->after_value, 'created_at' => $row->created_at, 'user_name' => $user ? $user->display_name : '', ); }, $rows ); return rest_ensure_response( $entries ); } /** * GET /priority: pages needing attention, worst first — technical * issues and content score weighted by real GSC traffic where it's * available (see ABDS_SEO_Priority_Service). The same ranking the * weekly digest mails out, so the two always agree. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function get_priority_list( WP_REST_Request $request ) { $service = new ABDS_SEO_Priority_Service(); return rest_ensure_response( $service->get_attention_list( (int) $request->get_param( 'limit' ) ) ); } /** * POST /changes-log/{id}/undo: writes a logged change's before_value * back into the post meta key it came from, the same way that field is * written normally (update_post_meta()) — not through the post-revision * API, which this table has no relationship to. * * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error */ public function undo_change( WP_REST_Request $request ) { global $wpdb; $id = (int) $request->get_param( 'id' ); $table = $wpdb->prefix . 'abds_seo_changes_log'; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table is built only from $wpdb->prefix plus a hardcoded literal, never user input; $id is passed through $wpdb->prepare(). Runs once per undo click, not a cacheable read path. $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ) ); if ( ! $row ) { return new WP_Error( 'abds_seo_change_not_found', __( 'Change-history entry not found.', 'abds-seo' ), array( 'status' => 404 ) ); } $meta_key = ABDS_SEO_Changes_Log::meta_key_for_change_type( $row->change_type ); if ( ! $meta_key ) { return new WP_Error( 'abds_seo_unknown_change_type', __( 'Unknown change type.', 'abds-seo' ), array( 'status' => 400 ) ); } update_post_meta( (int) $row->post_id, $meta_key, $row->before_value ); return rest_ensure_response( array( 'post_id' => (int) $row->post_id, 'change_type' => $row->change_type, 'restored_value' => $row->before_value, ) ); } }