'GET', 'callback' => array( $this, 'index' ), 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), 'args' => array( 'page' => array( 'type' => 'integer', 'default' => 1, ), 'per_page' => array( 'type' => 'integer', 'default' => 20, 'maximum' => 100, ), 'type' => array( 'type' => 'string', 'default' => 'any', ), 'search' => array( 'type' => 'string' ), 'orderby' => array( 'type' => 'string', 'default' => 'modified', 'enum' => array( 'modified', 'title', 'geo', 'seo' ), ), 'order' => array( 'type' => 'string', 'default' => 'desc', 'enum' => array( 'asc', 'desc' ), ), 'filter' => array( 'type' => 'string', 'default' => '', 'enum' => array( '', 'no_desc', 'no_title', 'no_keyword', 'thin', 'weak_geo', 'weak_seo', 'noindex', 'excluded', 'unscored' ), ), 'lang' => array( 'type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_text_field', ), // Narrowing a 40,000-post magazine to one section before // anybody starts paging through it. 'tax' => array( 'type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_key', ), 'term' => array( 'type' => 'string', 'default' => '', // NOT 'sanitize_title' by name. WordPress calls a // sanitize_callback with three arguments — value, // request, param — and sanitize_title()'s second // parameter is $fallback_title, so an empty term makes // it return the WP_REST_Request object. The next // (string) cast then fatals. A closure that takes one // argument is the whole fix. 'sanitize_callback' => static fn( $value ): string => sanitize_title( (string) $value ), ), 'status' => array( 'type' => 'string', 'default' => '', 'enum' => array( '', 'publish', 'draft', 'pending', 'future', 'private' ), ), ), ) ); // The library overview: one grouped query behind a short cache, so a // site with 40,000 posts can open this screen. register_rest_route( self::NAMESPACE, '/content/library', array( 'methods' => 'GET', 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), 'callback' => static fn( WP_REST_Request $request ) => new WP_REST_Response( array( 'types' => \AiRankSuite\Modules\ContentAnalyzer\Services\Library::overview( (bool) $request->get_param( 'fresh' ) ), ) ), 'args' => array( 'fresh' => array( 'type' => 'boolean', 'default' => false ), ), ) ); register_rest_route( self::NAMESPACE, '/content/(?P\d+)', array( 'methods' => 'GET', 'callback' => array( $this, 'show' ), 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), ) ); // The head of one page, reconstructed from the same helpers the front // end uses. Read-only and cheap, so it rides the reports capability. register_rest_route( self::NAMESPACE, '/content/(?P\d+)/head', array( 'methods' => 'GET', 'callback' => array( $this, 'head_meta' ), 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), ) ); // The head the server really sends. This one makes an outbound HTTP // request, so it is rate limited — a user holding down a refresh button // should not be able to point the site at itself in a loop. register_rest_route( self::NAMESPACE, '/content/(?P\d+)/head/live', array( 'methods' => 'GET', 'callback' => array( $this, 'head_live' ), 'permission_callback' => static function () { if ( ! current_user_can( 'manage_options' ) ) { return self::forbidden(); } return self::spend( 'headlive', 60 ); }, ) ); // One page against every requirement, in one answer. register_rest_route( self::NAMESPACE, '/content/(?P\d+)/report', array( 'methods' => 'GET', 'callback' => array( $this, 'page_report' ), 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), ) ); // Word counts across the site. register_rest_route( self::NAMESPACE, '/content/words', array( 'methods' => 'GET', 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), 'callback' => static fn( WP_REST_Request $request ) => new WP_REST_Response( \AiRankSuite\Modules\ContentAnalyzer\Services\Words::stats( (bool) $request->get_param( 'fresh' ) ) + \AiRankSuite\Modules\ContentAnalyzer\Services\Words::extremes() ), 'args' => array( 'fresh' => array( 'type' => 'boolean', 'default' => false ), ), ) ); register_rest_route( self::NAMESPACE, '/content/(?P\d+)/analyze', array( 'methods' => 'POST', 'callback' => array( $this, 'reanalyze' ), 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), ) ); register_rest_route( self::NAMESPACE, '/content/(?P\d+)/meta', array( 'methods' => 'PUT', 'callback' => array( $this, 'update_meta' ), 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), 'args' => array( 'seo_title' => array( 'type' => 'string', // resolve_title() runs template variables on the per-post // override — imports from Yoast and Rank Math bring them in — // so %category% has to survive the trip. 'sanitize_callback' => array( \AiRankSuite\Support\Sanitize::class, 'template' ), ), 'seo_desc' => array( 'type' => 'string', 'sanitize_callback' => array( \AiRankSuite\Support\Sanitize::class, 'template_multiline' ), ), 'exclude' => array( 'type' => 'boolean' ), 'keyword' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), 'robots' => array( 'type' => 'array' ), 'keywords' => array( 'type' => 'object' ), 'slug' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_title', ), 'canonical' => array( 'type' => 'string', // NOT sanitize_text_field: it strips %XX octets, and a // non-Latin slug is stored percent-encoded. esc_url_raw at // the write is the correct and sufficient guard. 'sanitize_callback' => 'esc_url_raw', ), 'breadcrumb_title' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), 'previews' => array( 'type' => 'object' ), 'social' => array( 'type' => 'object' ), ), ) ); register_rest_route( self::NAMESPACE, '/content/bulk', array( 'methods' => 'PUT', 'callback' => array( $this, 'bulk_update' ), 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), 'args' => array( 'items' => array( 'type' => 'array', 'required' => true, ), ), ) ); register_rest_route( self::NAMESPACE, '/content/csv', array( array( 'methods' => 'GET', 'callback' => array( $this, 'export_csv' ), 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), 'args' => array( 'after' => array( 'type' => 'integer', 'default' => 0, 'minimum' => 0 ), 'per_page' => array( 'type' => 'integer', 'default' => 500, 'minimum' => 1, 'maximum' => 500 ), ), ), array( 'methods' => 'POST', 'callback' => array( $this, 'import_csv' ), 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), 'args' => array( 'rows' => array( 'type' => 'array', 'required' => true, ), ), ), ) ); } /** * Save titles/descriptions/keywords for many posts at once. */ public function bulk_update( WP_REST_Request $request ): WP_REST_Response { $saved = 0; foreach ( (array) $request->get_param( 'items' ) as $item ) { if ( ! is_array( $item ) || empty( $item['id'] ) ) { continue; } $post = get_post( (int) $item['id'] ); if ( ! $post instanceof WP_Post || ! current_user_can( 'edit_post', $post->ID ) ) { continue; } if ( array_key_exists( 'seo_title', $item ) ) { update_post_meta( $post->ID, '_airanksuite_seo_title', \AiRankSuite\Support\Sanitize::template( (string) $item['seo_title'] ) ); } if ( array_key_exists( 'seo_desc', $item ) ) { update_post_meta( $post->ID, '_airanksuite_seo_desc', \AiRankSuite\Support\Sanitize::template_multiline( (string) $item['seo_desc'] ) ); } if ( array_key_exists( 'keyword', $item ) ) { update_post_meta( $post->ID, '_airanksuite_focus_keyword', sanitize_text_field( (string) $item['keyword'] ) ); } $this->analyzer->analyze( $post ); ++$saved; } return new WP_REST_Response( array( 'saved' => $saved ) ); } /** * Export every post's SEO fields as CSV rows. */ /** * One page of the SEO-field export, by ID cursor. * * The export used to load two thousand posts — and, for each, the full * stored analysis — into one response, which on a ten-thousand-page site * silently returned the first fifth and on a shared host ran out of memory * first. Now the client asks for pages of up to 500 rows after a cursor * and stitches them; each row is the six fields the spreadsheet shows, * nothing heavier. `total` lets the client show progress and prove the * file is complete. */ public function export_csv( WP_REST_Request $request ): WP_REST_Response { global $wpdb; $types = \AiRankSuite\Support\Inventory::eligible_types(); $statuses = array( 'publish', 'draft', 'pending', 'future', 'private' ); $after = max( 0, (int) $request->get_param( 'after' ) ); $per_page = max( 1, min( 500, (int) ( $request->get_param( 'per_page' ) ?: 500 ) ) ); $t_ph = implode( ',', array_fill( 0, count( $types ), '%s' ) ); $s_ph = implode( ',', array_fill( 0, count( $statuses ), '%s' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(ID) FROM {$wpdb->posts} WHERE post_type IN ({$t_ph}) AND post_status IN ({$s_ph})", ...array_merge( $types, $statuses ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $ids = array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE ID > %d AND post_type IN ({$t_ph}) AND post_status IN ({$s_ph}) ORDER BY ID ASC LIMIT %d", ...array_merge( array( $after ), $types, $statuses, array( $per_page ) ) ) ) ); if ( ! empty( $ids ) ) { // One round trip for every row's meta and object cache. _prime_post_caches( $ids, false, true ); } $rows = array(); foreach ( $ids as $id ) { $post = get_post( $id ); if ( ! $post ) { continue; } // Same rule as the list and find(): no row for a draft or private // post its reader could not open. The cursor runs on ids, so a // skipped row costs the export nothing. if ( 'publish' !== $post->post_status && ! current_user_can( 'read_post', $post->ID ) ) { continue; } $rows[] = array( 'id' => $post->ID, 'title' => self::plain_title( $post ), 'url' => (string) get_permalink( $post ), 'seo_title' => (string) get_post_meta( $post->ID, '_airanksuite_seo_title', true ), 'seo_desc' => (string) get_post_meta( $post->ID, '_airanksuite_seo_desc', true ), 'keyword' => (string) get_post_meta( $post->ID, '_airanksuite_focus_keyword', true ), ); } $last = empty( $ids ) ? null : (int) end( $ids ); return new WP_REST_Response( array( 'rows' => $rows, 'next' => count( $ids ) === $per_page ? $last : null, 'total' => $total, ) ); } /** * Import CSV rows (id + optional seo_title/seo_desc/keyword). */ public function import_csv( WP_REST_Request $request ): WP_REST_Response { return $this->bulk_update( $request instanceof WP_REST_Request ? $this->as_items( $request ) : $request ); } /** * Normalize CSV rows into the shape bulk_update expects. */ private function as_items( WP_REST_Request $request ): WP_REST_Request { $request->set_param( 'items', (array) $request->get_param( 'rows' ) ); return $request; } /** * Meta-query fragments for the "show me only the broken ones" filters. * * Each returns a clause list; scores live in postmeta already, so filtering * and sorting by them stays a single indexed query rather than a scan. * * @return array */ private function filter_clause( string $filter ): array { return match ( $filter ) { 'no_desc' => array( 'relation' => 'OR', array( 'key' => '_airanksuite_seo_desc', 'compare' => 'NOT EXISTS' ), array( 'key' => '_airanksuite_seo_desc', 'value' => '', 'compare' => '=' ), ), 'no_title' => array( 'relation' => 'OR', array( 'key' => '_airanksuite_seo_title', 'compare' => 'NOT EXISTS' ), array( 'key' => '_airanksuite_seo_title', 'value' => '', 'compare' => '=' ), ), 'no_keyword' => array( 'relation' => 'OR', array( 'key' => '_airanksuite_focus_keyword', 'compare' => 'NOT EXISTS' ), array( 'key' => '_airanksuite_focus_keyword', 'value' => '', 'compare' => '=' ), ), 'weak_geo' => array( array( 'key' => '_airanksuite_geo_score', 'value' => 60, 'compare' => '<', 'type' => 'NUMERIC' ), ), 'weak_seo' => array( array( 'key' => '_airanksuite_seo_score', 'value' => 60, 'compare' => '<', 'type' => 'NUMERIC' ), ), 'excluded' => array( array( 'key' => '_airanksuite_exclude', 'value' => '1', 'compare' => '=' ), ), 'noindex' => array( array( 'key' => '_airanksuite_robots', 'value' => 'noindex', 'compare' => 'LIKE' ), ), // Never analysed. The free edition has no bulk queue — that module // is paid — so its "score every page" button walks this list and // calls the per-post analyzer, which is free and local. Without a // way to ask for the unscored rows it would have to page the whole // site to find them. 'unscored' => array( array( 'key' => '_airanksuite_geo_score', 'compare' => 'NOT EXISTS' ), ), default => array(), }; } public function index( WP_REST_Request $request ): WP_REST_Response { // The one definition of "a page" (Support\Inventory): technical types — // Elementor/The7 header, footer and popup templates, blocks, WooCommerce // internals — are never rows here, so this list and every counter agree. $types = 'any' === $request['type'] ? \AiRankSuite\Support\Inventory::eligible_types() : array( sanitize_key( (string) $request['type'] ) ); $types = array_diff( $types, array( 'attachment' ) ); $filter = (string) ( $request['filter'] ?? '' ); $order = 'asc' === strtolower( (string) $request['order'] ) ? 'ASC' : 'DESC'; $status = (string) ( $request['status'] ?? '' ); $args = array( 'post_type' => $types, 'post_status' => '' !== $status ? array( $status ) : array( 'publish', 'draft', 'pending', 'future', 'private' ), 'posts_per_page' => min( 100, max( 1, (int) $request['per_page'] ) ), 'paged' => max( 1, (int) $request['page'] ), 's' => (string) ( $request['search'] ?? '' ), // Title only. WordPress' default search runs LIKE over the whole // post body, which on a hundred-thousand-post site is a full table // scan per keystroke; a title match is what an editor is typing for. 'search_columns' => array( 'post_title' ), 'orderby' => 'modified', 'order' => $order, ); // Sorting by a score means sorting by postmeta, which requires the meta // to exist — a page that was never analyzed has no score to rank by, so // it drops out of a score-sorted list by definition. Say it with the // query rather than inventing a zero. $meta_key = match ( (string) $request['orderby'] ) { 'geo' => '_airanksuite_geo_score', 'seo' => '_airanksuite_seo_score', default => '', }; if ( '' !== $meta_key ) { $args['meta_key'] = $meta_key; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key $args['orderby'] = 'meta_value_num'; } elseif ( 'title' === (string) $request['orderby'] ) { $args['orderby'] = 'title'; } $tax = (string) ( $request['tax'] ?? '' ); $term = (string) ( $request['term'] ?? '' ); if ( '' !== $tax && '' !== $term && taxonomy_exists( $tax ) ) { $args['tax_query'] = array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query array( 'taxonomy' => $tax, 'field' => 'slug', 'terms' => $term, ), ); } $clause = $this->filter_clause( $filter ); if ( ! empty( $clause ) ) { // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query $args['meta_query'] = $clause; } // "Thin" is a word count. The analyzer stores it as its own meta since // the words feature shipped, so on any site analysed since then the // filter is a numeric meta comparison the database can page. Only a // site whose analyses all predate that falls back to reading the // first hundred analyses and filtering in PHP. $thin = 'thin' === $filter; if ( $thin ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $has_words = (bool) $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM {$wpdb->postmeta} WHERE meta_key = %s LIMIT 1", \AiRankSuite\Modules\ContentAnalyzer\Services\Words::META ) ); if ( $has_words ) { $thin = false; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query $args['meta_query'] = array( array( 'key' => \AiRankSuite\Modules\ContentAnalyzer\Services\Words::META, 'value' => 300, 'type' => 'NUMERIC', 'compare' => '<', ), ); } else { $args['posts_per_page'] = 100; $args['paged'] = 1; } } // On a multilingual site the list is otherwise every language at once, // which on a 1,000-page site is 1,000 rows the editor cannot act on. $lang = (string) ( $request['lang'] ?? '' ); $args = \AiRankSuite\Support\Multilingual::scope_query( $args, $lang ); // The unfiltered default path — every plain page view — must not pay // SQL_CALC_FOUND_ROWS: on a 50,000-post site that count alone cost // 286 ms per request. The total is one indexed COUNT, cached for a // minute. Every filtered path still counts exactly (its result set // is small, so found_posts is cheap there). $plain_total = '' === (string) $args['s'] && '' === $lang && empty( $args['tax_query'] ) && empty( $args['meta_query'] ) && empty( $args['post__in'] ) && ! isset( $args['meta_key'] ) && ! $thin; if ( $plain_total ) { $args['no_found_rows'] = true; } $query = \AiRankSuite\Support\Multilingual::in_language( $lang, static fn() => new WP_Query( $args ) ); $items = array(); foreach ( $query->posts as $post ) { // The list reaches drafts and private posts, and a row is the // title and the SEO fields. The same rule as find(): the reports // capability does not make somebody else's draft readable. if ( 'publish' !== $post->post_status && ! current_user_can( 'read_post', $post->ID ) ) { continue; } $items[] = $this->row( $post ); } if ( $plain_total ) { global $wpdb; $cache_key = 'content_total_' . md5( wp_json_encode( array( $args['post_type'], $args['post_status'] ) ) ); $total = wp_cache_get( $cache_key, 'airanksuite' ); if ( false === $total ) { $types = array_values( (array) $args['post_type'] ); $statuses = array_values( (array) $args['post_status'] ); // One %s per value, so the lists are prepared like any other // value rather than escaped by hand and pasted into the string. $in_types = implode( ',', array_fill( 0, count( $types ), '%s' ) ); $in_status = implode( ',', array_fill( 0, count( $statuses ), '%s' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE post_type IN ({$in_types}) AND post_status IN ({$in_status})", $wpdb->posts, ...$types, ...$statuses ) ); wp_cache_set( $cache_key, $total, 'airanksuite', 60 ); } $total = (int) $total; $pages = (int) ceil( $total / max( 1, (int) $args['posts_per_page'] ) ); } else { $total = (int) $query->found_posts; $pages = (int) $query->max_num_pages; } if ( $thin ) { $items = array_values( array_filter( $items, static fn( $row ) => (int) $row['words'] < 300 ) ); $total = count( $items ); $pages = 1; } $response = new WP_REST_Response( $items ); $response->header( 'X-WP-Total', (string) $total ); $response->header( 'X-WP-TotalPages', (string) $pages ); return $response; } public function show( WP_REST_Request $request ): WP_REST_Response|WP_Error { $post = $this->find( $request ); if ( $post instanceof WP_Error ) { return $post; } return new WP_REST_Response( $this->detail( $post ) ); } /** * Everything this page puts in , grouped and checked. */ public function head_meta( WP_REST_Request $request ): WP_REST_Response|WP_Error { $post = $this->find( $request ); if ( $post instanceof WP_Error ) { return $post; } return new WP_REST_Response( \AiRankSuite\Modules\Seo\Services\HeadMeta::plan( $post ) ); } /** * The head as actually delivered, diffed against the plan. */ public function head_live( WP_REST_Request $request ): WP_REST_Response|WP_Error { $post = $this->find( $request ); if ( $post instanceof WP_Error ) { return $post; } if ( 'publish' !== $post->post_status ) { return new WP_REST_Response( array( 'ok' => false, 'reason' => __( 'This page is not published, so there is nothing to fetch yet.', 'ai-rank-suite' ), ) ); } $plan = \AiRankSuite\Modules\Seo\Services\HeadMeta::plan( $post ); return new WP_REST_Response( \AiRankSuite\Modules\Seo\Services\HeadMeta::live( (string) $plan['url'], (array) $plan['tags'] ) ); } /** * Every requirement for one page, and whether it is met. */ public function page_report( WP_REST_Request $request ): WP_REST_Response|WP_Error { $post = $this->find( $request ); if ( $post instanceof WP_Error ) { return $post; } return new WP_REST_Response( \AiRankSuite\Modules\ContentAnalyzer\Services\PageReport::build( $post ) ); } public function reanalyze( WP_REST_Request $request ): WP_REST_Response|WP_Error { // Analysis writes scores onto the post, so it is a write. $post = $this->find_editable( $request ); if ( $post instanceof WP_Error ) { return $post; } $this->analyzer->analyze( $post ); return new WP_REST_Response( $this->detail( $post ) ); } public function update_meta( WP_REST_Request $request ): WP_REST_Response|WP_Error { $post = $this->find_editable( $request ); if ( $post instanceof WP_Error ) { return $post; } if ( $request->has_param( 'seo_title' ) ) { update_post_meta( $post->ID, '_airanksuite_seo_title', (string) $request['seo_title'] ); } if ( $request->has_param( 'seo_desc' ) ) { update_post_meta( $post->ID, '_airanksuite_seo_desc', (string) $request['seo_desc'] ); } // The slug is an SEO field: it is in the URL, in the search result and // in every link anyone ever made to this page. Editing it used to mean // leaving this screen for the WordPress editor — and doing it there // without a redirect breaks those links, which is the reason this // plugin has auto-redirect in the first place. wp_update_post fires the // same hooks, so the redirect is written by the module that owns it. if ( $request->has_param( 'slug' ) ) { $slug = sanitize_title( (string) $request['slug'] ); if ( '' !== $slug && $slug !== $post->post_name ) { $updated = wp_update_post( array( 'ID' => $post->ID, 'post_name' => $slug ), true ); if ( $updated instanceof WP_Error ) { return $updated; } $post = get_post( $post->ID ); } } if ( $request->has_param( 'keywords' ) ) { \AiRankSuite\Modules\ContentAnalyzer\Services\KeywordAnalyzer::save( $post->ID, (array) $request->get_param( 'keywords' ) ); } if ( $request->has_param( 'keyword' ) ) { update_post_meta( $post->ID, '_airanksuite_focus_keyword', (string) $request['keyword'] ); } if ( $request->has_param( 'robots' ) ) { $allowed = array_values( array_intersect( array_map( 'sanitize_key', (array) $request['robots'] ), \AiRankSuite\Modules\Robots\RobotsModule::DIRECTIVES ) ); if ( empty( $allowed ) ) { delete_post_meta( $post->ID, '_airanksuite_robots' ); } else { update_post_meta( $post->ID, '_airanksuite_robots', $allowed ); } } if ( $request->has_param( 'canonical' ) ) { $canonical = trim( (string) $request['canonical'] ); if ( '' === $canonical ) { delete_post_meta( $post->ID, \AiRankSuite\Support\PostSeo::CANONICAL ); } else { update_post_meta( $post->ID, \AiRankSuite\Support\PostSeo::CANONICAL, esc_url_raw( $canonical ) ); } } if ( $request->has_param( 'breadcrumb_title' ) ) { $crumb = sanitize_text_field( (string) $request['breadcrumb_title'] ); if ( '' === $crumb ) { delete_post_meta( $post->ID, \AiRankSuite\Support\PostSeo::CRUMB_TITLE ); } else { update_post_meta( $post->ID, \AiRankSuite\Support\PostSeo::CRUMB_TITLE, $crumb ); } } if ( $request->has_param( 'previews' ) ) { \AiRankSuite\Support\PostSeo::save_previews( $post->ID, (array) $request->get_param( 'previews' ) ); } if ( $request->has_param( 'social' ) ) { \AiRankSuite\Support\PostSeo::save_social( $post->ID, (array) $request->get_param( 'social' ) ); } if ( $request->has_param( 'exclude' ) ) { update_post_meta( $post->ID, '_airanksuite_exclude', $request['exclude'] ? '1' : '' ); delete_transient( \AiRankSuite\Modules\LlmsTxt\LlmsTxtModule::CACHE_KEY ); delete_transient( \AiRankSuite\Modules\LlmsTxt\LlmsTxtModule::CACHE_KEY_FULL ); } $this->analyzer->analyze( $post ); return new WP_REST_Response( $this->detail( $post ) ); } private function find( WP_REST_Request $request ): WP_Post|WP_Error { $post = get_post( (int) $request['id'] ); $ok = array( 'publish', 'draft', 'pending', 'future', 'private' ); if ( ! $post instanceof WP_Post || ! in_array( $post->post_status, $ok, true ) ) { return new WP_Error( 'airanksuite_content_not_found', __( 'Post not found.', 'ai-rank-suite' ), array( 'status' => 404 ) ); } /* * The plugin capability is not a licence to read every post. * * airanksuite_view_reports can be granted to a role WordPress would not let * near somebody else's private draft, and this screen shows the title, * the first paragraphs, the readability and the snippet preview. So the * object check WordPress would make is made here too, and an * unreadable post is "not found" rather than "forbidden": whether a * given id exists is itself information. */ if ( 'publish' !== $post->post_status && ! current_user_can( 'read_post', $post->ID ) ) { return new WP_Error( 'airanksuite_content_not_found', __( 'Post not found.', 'ai-rank-suite' ), array( 'status' => 404 ) ); } return $post; } /** * The same post, for a route that is about to write to it. * * airanksuite_edit_optimizations says "this person edits SEO fields"; it does not * say which posts. WordPress answers that per object, and until this * existed a role with the plugin capability could PATCH the title, * description, slug and robots of any post on the site, including one * WordPress would refuse to open for them. */ private function find_editable( WP_REST_Request $request ): WP_Post|WP_Error { $post = $this->find( $request ); if ( $post instanceof WP_Error ) { return $post; } if ( ! current_user_can( 'edit_post', $post->ID ) ) { return new WP_Error( 'airanksuite_cannot_edit_post', __( 'You cannot edit this post.', 'ai-rank-suite' ), array( 'status' => rest_authorization_required_code() ) ); } return $post; } /** * Compact relative time: 3m, 5h, 12d, 8mo, 2y. */ private static function ago( int $timestamp ): string { // A draft WordPress created but nobody ever saved has post_modified // 0000-00-00, which arrives here as the epoch and renders as "57y ago". // It has not been sitting there since 1970; it has never been saved. if ( $timestamp <= 0 || $timestamp < 946684800 ) { return __( 'never saved', 'ai-rank-suite' ); } $seconds = max( 0, time() - $timestamp ); if ( $seconds < HOUR_IN_SECONDS ) { /* translators: %d: minutes. */ return sprintf( __( '%dm ago', 'ai-rank-suite' ), max( 1, (int) round( $seconds / MINUTE_IN_SECONDS ) ) ); } if ( $seconds < DAY_IN_SECONDS ) { /* translators: %d: hours. */ return sprintf( __( '%dh ago', 'ai-rank-suite' ), (int) round( $seconds / HOUR_IN_SECONDS ) ); } if ( $seconds < 60 * DAY_IN_SECONDS ) { /* translators: %d: days. */ return sprintf( __( '%dd ago', 'ai-rank-suite' ), (int) round( $seconds / DAY_IN_SECONDS ) ); } if ( $seconds < YEAR_IN_SECONDS ) { /* translators: %d: months. */ return sprintf( __( '%dmo ago', 'ai-rank-suite' ), (int) round( $seconds / ( 30 * DAY_IN_SECONDS ) ) ); } /* translators: %d: years. */ return sprintf( __( '%dy ago', 'ai-rank-suite' ), (int) round( $seconds / YEAR_IN_SECONDS ) ); } /** @return array */ private function row( WP_Post $post ): array { // Read-only: a list must never trigger an analysis (see stored()). $analysis = $this->analyzer->stored( $post ); $issues = null === $analysis ? 0 : count( array_filter( (array) $analysis['checks'], static fn( $c ) => 'pass' !== $c['status'] ) ); return array( 'id' => $post->ID, 'title' => self::plain_title( $post ), 'type' => $post->post_type, // The list shows drafts now, so it has to be able to say so. 'status' => (string) get_post_status( $post ), 'lang' => \AiRankSuite\Support\Multilingual::post_language( $post->ID ), 'url' => (string) get_permalink( $post ), 'edit_url' => (string) get_edit_post_link( $post->ID, 'raw' ), // The three fields an editor fills in, as flags — the list shows // them as dots so a gap is visible without opening the row. 'has_title' => '' !== trim( (string) get_post_meta( $post->ID, '_airanksuite_seo_title', true ) ), /* * The SEO title itself, not only whether one exists. * * The list has always shown get_the_title(), which is the WordPress * title and not the one a search engine prints. So somebody could * rewrite a page's SEO title, save it, and watch the row not move — * correctly, because that column was never showing the field they * had edited. It is reported here so the row can show the change. */ 'seo_title' => (string) get_post_meta( $post->ID, '_airanksuite_seo_title', true ), 'has_desc' => '' !== trim( (string) get_post_meta( $post->ID, '_airanksuite_seo_desc', true ) ), 'has_keyword' => '' !== trim( (string) get_post_meta( $post->ID, '_airanksuite_focus_keyword', true ) ), 'words' => (int) ( $analysis['words'] ?? 0 ), // Null, not zero: "never analysed" and "scored zero" are different // facts and the list renders them differently. 'seo_score' => $analysis['seo_score'] ?? null, 'seo_breakdown' => $analysis['seo_breakdown'] ?? null, 'geo_score' => $analysis['geo_score'] ?? null, 'issues' => $issues, 'analyzed_at' => (int) ( $analysis['analyzed_at'] ?? 0 ), // The stored result predates the last edit, so the scores describe // an older version of this page. 'stale' => ! empty( $analysis['stale'] ), // Freshness is a ranking signal for every answer engine, so it // belongs in the list rather than three clicks away. 'modified' => (int) get_post_modified_time( 'U', true, $post ), // Compact rather than "14 hours ago": the column is 120px and a // wrapped two-line cell in every row is what makes a table look // broken even when the numbers are right. 'modified_human' => self::ago( (int) get_post_modified_time( 'U', true, $post ) ), 'status' => (string) get_post_status( $post ), ); } /** * Advanced per-post overrides plus the rendered social card, so the editor * can show exactly what Facebook and X will display. * * @return array */ private function advanced( WP_Post $post ): array { $title = \AiRankSuite\Modules\Seo\SeoModule::resolve_title( $post ); // The same resolver the front end uses, so the preview cannot disagree // with the head. Reimplementing the precedence here is how the preview // ended up showing an unresolved %date% while the page shipped a date. $desc = \AiRankSuite\Modules\Seo\SeoModule::resolve_description( $post ); return array( 'canonical' => (string) get_post_meta( $post->ID, \AiRankSuite\Support\PostSeo::CANONICAL, true ), 'cornerstone' => (bool) get_post_meta( $post->ID, '_airanksuite_cornerstone', true ), 'links_in' => (int) get_post_meta( $post->ID, '_airanksuite_links_in', true ), 'links_out' => (int) get_post_meta( $post->ID, '_airanksuite_links_out', true ), 'canonical_auto' => (string) get_permalink( $post ), 'breadcrumb_title' => (string) get_post_meta( $post->ID, \AiRankSuite\Support\PostSeo::CRUMB_TITLE, true ), 'previews' => \AiRankSuite\Support\PostSeo::previews( $post->ID ), 'preview_options' => \AiRankSuite\Support\PostSeo::preview_options(), 'social' => \AiRankSuite\Support\PostSeo::social( $post->ID ), 'social_card' => \AiRankSuite\Support\PostSeo::social_card( $post, $title, $desc ), 'serp' => array( 'title' => $title, 'desc' => $desc, 'url' => \AiRankSuite\Support\PostSeo::canonical( $post ), 'site' => (string) get_bloginfo( 'name' ), // The AI-answer preview paraphrases the summary when there is // one, because that is the text an assistant is most likely to // quote — the meta description is written for a 155-character // snippet, the summary for being repeated. 'summary' => (string) get_post_meta( $post->ID, '_airanksuite_ai_summary', true ), ), ); } /** @return array */ private function detail( WP_Post $post ): array { return array_merge( $this->row( $post ), array( 'checks' => $this->analyzer->get( $post )['checks'], // The section catalogue travels with the checks rather than // being hard-coded in the client: a check whose section the UI // does not know about is a check that silently disappears. 'sections' => \AiRankSuite\Modules\ContentAnalyzer\Services\Checks::sections(), 'seo_title' => (string) get_post_meta( $post->ID, '_airanksuite_seo_title', true ), 'seo_desc' => (string) get_post_meta( $post->ID, '_airanksuite_seo_desc', true ), 'excerpt' => (string) $post->post_excerpt, 'exclude' => (bool) get_post_meta( $post->ID, '_airanksuite_exclude', true ), 'keyword' => (string) get_post_meta( $post->ID, '_airanksuite_focus_keyword', true ), 'keywords' => \AiRankSuite\Modules\ContentAnalyzer\Services\KeywordAnalyzer::get( $post->ID ), 'coverage' => $this->analyzer->get( $post )['coverage'] ?? array( 'rows' => array(), 'summary' => array() ), 'max_keywords' => \AiRankSuite\Modules\ContentAnalyzer\Services\KeywordAnalyzer::MAX_PER_GROUP, 'robots' => (array) ( get_post_meta( $post->ID, '_airanksuite_robots', true ) ?: array() ), 'slug' => (string) $post->post_name, 'edit_url' => (string) get_edit_post_link( $post->ID, 'raw' ), ), $this->advanced( $post ) ); } /** * The title as text, not as markup. * * get_the_title() returns what the theme would print: texturised, so a * hyphen between spaces comes back as "–", and escaped. The admin * renders titles as text nodes, which showed that entity literally in the * drawer header. Decoding here means every screen gets the same plain * string and none of them has to know how WordPress stores a dash. */ private static function plain_title( \WP_Post $post ): string { return html_entity_decode( wp_strip_all_tags( (string) get_the_title( $post ) ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ); } }