prepare(): values and ID lists as placeholders, table and // column names as %i. Fragments added to a query are code (Scope::sql()) or are prepared // where they are built (Scope::type_sql(), Lang::sql(), SeoMeta joins). /** * RestArticleInspector * * REST endpoints for the Article Inspector page. * * GET clusterlane/v1/inspector/articles — paged, filterable list ("Articles to review") * GET clusterlane/v1/inspector/search — autocomplete post search * GET clusterlane/v1/inspector/post/{id} — full article facts * POST clusterlane/v1/inspector/tms-scan — mini TMS scan for one post */ class RestArticleInspector { public function __construct( private \Clusterlane\Core\Plugin $plugin ) {} public function register_routes(): void { register_rest_route( 'clusterlane/v1', '/inspector/articles', [ 'methods' => 'GET', 'callback' => [ $this, 'list_articles' ], 'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(), ] ); register_rest_route( 'clusterlane/v1', '/inspector/search', [ 'methods' => 'GET', 'callback' => [ $this, 'search_posts' ], 'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(), ] ); register_rest_route( 'clusterlane/v1', '/inspector/post/(?P\d+)', [ 'methods' => 'GET', 'callback' => [ $this, 'get_post_data' ], 'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(), 'args' => [ 'id' => [ 'validate_callback' => fn( $v ) => is_numeric( $v ) ], ], ] ); register_rest_route( 'clusterlane/v1', '/inspector/tms-scan', [ 'methods' => 'POST', 'callback' => [ $this, 'tms_scan' ], 'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(), ] ); } /* ------------------------------------------------------------------ Articles to review — paged list, "needs attention" first ------------------------------------------------------------------ */ /** * GET clusterlane/v1/inspector/articles * page, per_page (≤ 50) * q — title contains * cluster — cluster_key; role is then the cluster-relative role * role — orphan | light | support | pillar * meta — missing | weak (meta description) | title (SEO title too long) | ai (poor AI * review, 0.98.155) | dup (same title or description as another article); see SeoMeta * sort — attention (default) | meta | title | score | inbound | outbound | words | scanned * (meta: missing description, then weak, then long title — SEO Titles' order) * seo — 1: add each article's SEO title/description, URL and edit rights, * plus the page context (source, write policy, suffix) — SEO Titles, 0.98.154 * order — asc | desc (ignored for attention) * * Reads Clusterlane's own metric tables with one paged query, so it scales * to large sites (no WP_Query over every post). */ public function list_articles( \WP_REST_Request $request ): \WP_REST_Response { global $wpdb; $settings = $this->plugin->get( 'settings' ); // export=1 (Export CSV): rows with URLs, no thumbnails, up to 1,000 per request — // the browser fetches page after page (0.98.169; one request for everything ran // into PHP's memory limit on very big sites). $export = (bool) $request->get_param( 'export' ); $per_page = $export ? min( 1000, max( 50, (int) ( $request->get_param( 'per_page' ) ?: 1000 ) ) ) : min( 50, max( 5, (int) ( $request->get_param( 'per_page' ) ?: 25 ) ) ); $page = max( 1, (int) ( $request->get_param( 'page' ) ?: 1 ) ); $q = trim( sanitize_text_field( (string) $request->get_param( 'q' ) ) ); // include=1,2,3 — only these IDs (used to prune "Recently inspected"). $include = array_values( array_filter( array_map( 'absint', explode( ',', (string) $request->get_param( 'include' ) ) ) ) ); $cluster = sanitize_key( (string) $request->get_param( 'cluster' ) ); $role = sanitize_key( (string) $request->get_param( 'role' ) ); $meta = sanitize_key( (string) $request->get_param( 'meta' ) ); $meta = in_array( $meta, [ 'missing', 'weak', 'title', 'ai', 'dup' ], true ) ? $meta : ''; $with_seo = (bool) $request->get_param( 'seo' ); $sort = sanitize_key( (string) ( $request->get_param( 'sort' ) ?: 'attention' ) ); $order = strtolower( (string) $request->get_param( 'order' ) ) === 'asc' ? 'ASC' : 'DESC'; $m = $wpdb->prefix . 'clusterlane_post_metrics'; $cpc = $wpdb->prefix . 'clusterlane_cluster_post_class'; $cl = $wpdb->prefix . 'clusterlane_clusters'; $types = array_values( array_filter( array_map( 'sanitize_key', (array) ( $settings ? $settings->post_types() : [ 'post' ] ) ) ) ) ?: [ 'post' ]; /* * The list is filtered, sorted and counted by several queries that share * pieces. Each piece is [ SQL with placeholders, its values ]; $sql() joins * pieces in order and every query goes through $wpdb->prepare() — request * values never become part of the SQL text. What is written into the text * is code: table aliases, whitelisted columns, Scope::sql() (no values) and * the SeoMeta / Lang fragments, which prepare their own values. */ $sql = static function ( array ...$parts ): array { $s = ''; $a = []; foreach ( $parts as [ $p, $v ] ) { $s .= $p; array_push( $a, ...$v ); } return [ $s, $a ]; }; $and = static function ( array $conds ) use ( $sql ): array { $out = [ '', [] ]; foreach ( array_values( $conds ) as $i => $c ) { $out = $sql( $out, [ ( $i ? ' AND ' : '' ) . $c[0], $c[1] ] ); } return $out; }; // With a cluster filter the role is the role *in that cluster*; otherwise the site-wide class. $join = [ '', [] ]; $role_col = 'm.content_class'; $where = [ [ \Clusterlane\Core\Scope::sql( 'p' ), [] ], [ 'p.post_type IN (' . implode( ',', array_fill( 0, count( $types ), '%s' ) ) . ')', $types ], ]; if ( $cluster !== '' ) { $join = [ ' INNER JOIN %i r ON r.post_id = m.post_id AND r.cluster_key = %s', [ $cpc, $cluster ] ]; $role_col = 'r.content_class'; } if ( $q !== '' ) { $where[] = [ 'p.post_title LIKE %s', [ '%' . $wpdb->esc_like( $q ) . '%' ] ]; } // lang= (0.98.168): multilingual sites — one language only. $lang = sanitize_key( (string) $request->get_param( 'lang' ) ); if ( $lang !== '' && isset( \Clusterlane\Core\Lang::languages()[ $lang ] ) ) $where[] = [ \Clusterlane\Core\Lang::sql( $lang, 'p' ), [] ]; if ( $include ) { $inc = array_slice( $include, 0, 50 ); $where[] = [ 'm.post_id IN (' . implode( ',', array_fill( 0, count( $inc ), '%d' ) ) . ')', $inc ]; } // Meta description (SEO plugin / custom field, excerpt fallback) — one LEFT JOIN. $seo = \Clusterlane\Core\SeoMeta::sql_description( 'p', 'm.post_id' ); // SEO title (only needed for the long-title filter/sort) — joined as seot. $seot = ( $with_seo || in_array( $meta, [ 'title', 'dup' ], true ) || $sort === 'meta' ) ? \Clusterlane\Core\SeoMeta::sql_title( 'p', 'm.post_id' ) : null; $seo_join = [ ' ' . $seo['join'] . ( $seot ? ' ' . $seot['join'] : '' ), [] ]; // SEO Titles (0.98.155): the AI review's lower score (premium seo/check writes it) … $chk = $with_seo || $meta === 'ai' || $sort === 'meta'; if ( $chk ) { $seo_join = $sql( $seo_join, [ " LEFT JOIN ( SELECT post_id, MIN(CAST(meta_value AS UNSIGNED)) AS v, %d AS poor FROM {$wpdb->postmeta} WHERE meta_key = %s GROUP BY post_id ) chk ON chk.post_id = m.post_id", [ (int) \Clusterlane\Core\SeoMeta::CHECK_POOR, \Clusterlane\Core\SeoMeta::CHECK_MIN ], ] ); } $ai_poor = 'chk.v <= chk.poor'; $from = $sql( [ ' FROM %i m INNER JOIN %i p ON p.ID = m.post_id', [ $m, $wpdb->posts ] ], $join, $seo_join ); // … and titles/descriptions used by more than one article (site-wide, no AI). $dups = [ 't' => [], 'd' => [] ]; $dup = [ '0', [] ]; if ( $seot ) { $dup_from = $sql( [ ' FROM %i m INNER JOIN %i p ON p.ID = m.post_id', [ $m, $wpdb->posts ] ], $seo_join ); $dup_where = $and( array_slice( $where, 0, 2 ) ); // scope + post types foreach ( [ 't' => $seot['expr'], 'd' => $seo['expr'] ] as $k => $expr ) { $stmt = $sql( [ "SELECT $expr AS dup_v, COUNT(*) AS dup_n", [] ], $dup_from, [ ' WHERE ', [] ], $dup_where, [ " AND CHAR_LENGTH($expr) > 0 GROUP BY dup_v HAVING dup_n > 1 ORDER BY dup_n DESC LIMIT 500", [] ] ); $found = $wpdb->get_results( $wpdb->prepare( $stmt[0], $stmt[1] ) ); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- built from code-only fragments; every value is a placeholder foreach ( (array) $found as $d ) { $dups[ $k ][ (string) $d->dup_v ] = (int) $d->dup_n; } } $parts = []; foreach ( [ 't' => $seot['expr'], 'd' => $seo['expr'] ] as $k => $expr ) { if ( $dups[ $k ] ) { $vals = array_map( 'strval', array_keys( $dups[ $k ] ) ); $parts[] = [ "$expr IN (" . implode( ',', array_fill( 0, count( $vals ), '%s' ) ) . ')', $vals ]; } } if ( $parts ) { $dup = [ '(', [] ]; foreach ( $parts as $i => $pt ) $dup = $sql( $dup, [ ( $i ? ' OR ' : '' ) . $pt[0], $pt[1] ] ); $dup = $sql( $dup, [ ')', [] ] ); } } $where_no_meta = $where; if ( $meta === 'title' ) { $where[] = [ \Clusterlane\Core\SeoMeta::sql_title_long( $seot['expr'] ), [] ]; } elseif ( $meta === 'ai' ) { $where[] = [ $ai_poor, [] ]; } elseif ( $meta === 'dup' ) { $where[] = $dup; } elseif ( $meta !== '' ) { $where[] = [ \Clusterlane\Core\SeoMeta::sql_status( $seo['expr'], $meta ), [] ]; } $where_no_role = $and( $where ); if ( in_array( $role, [ 'orphan', 'light', 'support', 'pillar' ], true ) ) { $where[] = [ "$role_col = %s", [ $role ] ]; } $where_all = $and( $where ); $sorts = [ 'title' => 'p.post_title', 'score' => 'm.overall_score', 'inbound' => 'm.inbound_links', 'outbound' => 'm.outbound_links', 'words' => 'm.word_count', 'scanned' => 'm.last_scanned', ]; if ( $sort === 'meta' ) { // SEO Titles: a poor AI review first (lowest score), then no description, // a weak one, a long title, then A–Z. $order_sql = "( $ai_poor ) DESC, chk.v ASC, ( " . \Clusterlane\Core\SeoMeta::sql_status( $seo['expr'], 'missing' ) . ' ) DESC, ( ' . \Clusterlane\Core\SeoMeta::sql_status( $seo['expr'], 'weak' ) . ' ) DESC, ( ' . \Clusterlane\Core\SeoMeta::sql_title_long( $seot['expr'] ) . ' ) DESC, p.post_title ASC'; } else { $order_sql = isset( $sorts[ $sort ] ) ? "{$sorts[ $sort ]} $order, p.post_title ASC" // Needs attention: orphans, then lowest score, then fewest inbound links. : "( $role_col = 'orphan' ) DESC, m.overall_score ASC, m.inbound_links ASC, p.post_title ASC"; } $stmt = $sql( [ 'SELECT COUNT(*)', [] ], $from, [ ' WHERE ', [] ], $where_all ); $total = (int) $wpdb->get_var( $wpdb->prepare( $stmt[0], $stmt[1] ) ); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- built from code-only fragments; every value is a placeholder $pages = max( 1, (int) ceil( $total / $per_page ) ); $page = min( $page, $pages ); $offset = ( $page - 1 ) * $per_page; $stmt = $sql( [ "SELECT m.post_id, p.post_title, p.post_type, $role_col AS role, m.overall_score, m.inbound_links, m.outbound_links, m.word_count, m.last_scanned, m.primary_cluster, ( SELECT c.cluster_label FROM %i c WHERE c.cluster_key = m.primary_cluster LIMIT 1 ) AS cluster_label, ( SELECT COUNT(*) FROM %i x WHERE x.post_id = m.post_id ) AS cluster_count" . ( $seot ? ", {$seot['expr']} AS seo_t_v, {$seo['expr']} AS seo_d_v" : '' ), [ $cl, $cpc ] ], $from, [ ' WHERE ', [] ], $where_all, [ " ORDER BY $order_sql LIMIT %d OFFSET %d", [ $per_page, $offset ] ] ); $rows = $wpdb->get_results( $wpdb->prepare( $stmt[0], $stmt[1] ) ); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- built from code-only fragments; every value is a placeholder // Role counts for the filter chips (same filters, any role). $counts = [ 'orphan' => 0, 'light' => 0, 'support' => 0, 'pillar' => 0 ]; $stmt = $sql( [ "SELECT $role_col AS role, COUNT(*) AS n", [] ], $from, [ ' WHERE ', [] ], $where_no_role, [ " GROUP BY $role_col", [] ] ); foreach ( (array) $wpdb->get_results( $wpdb->prepare( $stmt[0], $stmt[1] ) ) as $c ) { // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- built from code-only fragments; every value is a placeholder if ( isset( $counts[ $c->role ] ) ) $counts[ $c->role ] = (int) $c->n; } // Meta chips: missing / weak for the same filters (role + search + cluster), any meta. $where_meta = $where_no_meta; if ( in_array( $role, [ 'orphan', 'light', 'support', 'pillar' ], true ) ) { $where_meta[] = [ "$role_col = %s", [ $role ] ]; } $e = $seo['expr']; $select = [ 'SELECT SUM(' . \Clusterlane\Core\SeoMeta::sql_status( $e, 'missing' ) . ') AS missing, SUM(' . \Clusterlane\Core\SeoMeta::sql_status( $e, 'weak' ) . ') AS weak', [] ]; if ( $seot ) { $select = $sql( $select, [ ', SUM(' . \Clusterlane\Core\SeoMeta::sql_title_long( $seot['expr'] ) . ') AS title_long, SUM(', [] ], $dup, [ ') AS dup', [] ] ); } if ( $chk ) { $select = $sql( $select, [ ", SUM($ai_poor) AS ai_poor, SUM(chk.v IS NOT NULL) AS ai_checked", [] ] ); } $stmt = $sql( $select, $from, [ ' WHERE ', [] ], $and( $where_meta ) ); $mc = $wpdb->get_row( $wpdb->prepare( $stmt[0], $stmt[1] ) ); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- built from code-only fragments; every value is a placeholder $items = array_map( fn( $r ) => [ 'id' => (int) $r->post_id, 'title' => html_entity_decode( get_the_title( (int) $r->post_id ) ?: $r->post_title, ENT_QUOTES, 'UTF-8' ), 'role' => (string) $r->role, 'score' => (int) $r->overall_score, 'inbound' => (int) $r->inbound_links, 'outbound' => (int) $r->outbound_links, 'words' => (int) $r->word_count, 'last_scanned' => (string) $r->last_scanned, 'cluster_label' => (string) ( $r->cluster_label ?? '' ), 'cluster_count' => (int) $r->cluster_count, 'type' => (string) $r->post_type, // 'product' on WooCommerce shops (0.98.172) ] + ( $seot ? [ // Other articles with the very same SEO title / description (0 = unique). 'dup_title' => max( 0, ( $dups['t'][ (string) $r->seo_t_v ] ?? 1 ) - 1 ), 'dup_desc' => (string) $r->seo_d_v === '' ? 0 : max( 0, ( $dups['d'][ (string) $r->seo_d_v ] ?? 1 ) - 1 ), ] : [] ), (array) $rows ); if ( \Clusterlane\Core\Lang::active() ) { $langs = \Clusterlane\Core\Lang::map( array_column( $items, 'id' ) ); foreach ( $items as &$it ) $it['lang'] = $langs[ $it['id'] ] ?? ''; unset( $it ); } if ( $export ) { foreach ( $items as &$it ) $it['url'] = (string) get_permalink( $it['id'] ); unset( $it ); } else { $items = \Clusterlane\Core\Thumbs::add( $items ); } if ( $with_seo ) { foreach ( $items as &$it ) { $post = get_post( $it['id'] ); if ( ! $post ) continue; $it['seo'] = \Clusterlane\Core\SeoMeta::get( $post ) + [ 'original' => \Clusterlane\Core\SeoMeta::original_state( $post->ID ) ]; $it['url'] = (string) get_permalink( $post ); $it['edit_url'] = current_user_can( 'edit_post', $post->ID ) ? (string) get_edit_post_link( $post->ID, 'raw' ) : ''; $it['can_edit'] = current_user_can( 'edit_post', $post->ID ); $it['undo'] = (bool) get_transient( 'clusterlane_seo_undo_' . $post->ID ); $it['check'] = \Clusterlane\Core\SeoMeta::check( $post->ID ); // AI review, if any (Pro adds 'stale') } unset( $it ); /** Premium: each row's cached AI suggestions (key 'ai', null when none are current). */ $items = (array) apply_filters( 'clusterlane_seo_list_items', $items ); } return rest_ensure_response( [ 'items' => $items, 'total' => $total, 'page' => $page, 'pages' => $pages, 'per_page' => $per_page, 'counts' => $counts, 'meta_counts' => [ 'missing' => (int) ( $mc->missing ?? 0 ), 'weak' => (int) ( $mc->weak ?? 0 ), 'title' => (int) ( $mc->title_long ?? 0 ), 'dup' => (int) ( $mc->dup ?? 0 ), 'ai' => (int) ( $mc->ai_poor ?? 0 ), 'ai_checked' => (int) ( $mc->ai_checked ?? 0 ) ], 'scope' => $cluster !== '' ? 'cluster' : 'site', // Multilingual sites: code → native name, for the language filter (empty otherwise). 'languages' => \Clusterlane\Core\Lang::active() ? array_map( fn( $l ) => $l['name'], \Clusterlane\Core\Lang::languages() ) : (object) [], ] + ( $with_seo ? [ 'seo_ctx' => self::seo_context() ] : [] ) ); } /** SEO Titles: where the fields live, whether Clusterlane may write them, and the limits. */ public static function seo_context(): array { $src = \Clusterlane\Core\SeoMeta::source(); $writable = \Clusterlane\Core\SeoMeta::can_write(); $may = \Clusterlane\Core\Capabilities::can_write_seo(); return [ 'source' => $src['label'], 'source_id' => $src['id'], 'fields' => [ 'title' => $src['title'], 'description' => $src['desc'] ], 'can_write' => $writable && $may, 'write_note' => $writable && ! $may ? \Clusterlane\Core\Capabilities::no_right_message( \Clusterlane\Core\Capabilities::SEO ) : \Clusterlane\Core\SeoMeta::write_note(), 'fallback' => \Clusterlane\Core\SeoMeta::fallback(), 'suffix' => \Clusterlane\Core\SeoMeta::suffix(), 'title_max' => \Clusterlane\Core\SeoMeta::TITLE_MAX, 'desc_min' => \Clusterlane\Core\SeoMeta::DESC_MIN, 'desc_max' => \Clusterlane\Core\SeoMeta::DESC_MAX, 'undo_min' => (int) ( \Clusterlane\Api\RestSeo::UNDO_TTL / MINUTE_IN_SECONDS ), ]; } /* ------------------------------------------------------------------ Search — autocomplete ------------------------------------------------------------------ */ public function search_posts( \WP_REST_Request $request ): \WP_REST_Response { $q = sanitize_text_field( $request->get_param( 'q' ) ?? '' ); if ( strlen( $q ) < 2 ) { return rest_ensure_response( [] ); } $posts = get_posts( \Clusterlane\Core\Scope::query_args( [ 'post_type' => \Clusterlane\Core\Scope::post_types(), 'post_status' => 'publish', 's' => $q, 'posts_per_page' => 10, 'orderby' => 'relevance', ] ) ); $results = \Clusterlane\Core\Thumbs::add( array_map( fn( $p ) => [ 'id' => $p->ID, 'title' => $p->post_title, 'url' => get_permalink( $p->ID ), ], $posts ) ); return rest_ensure_response( $results ); } /* ------------------------------------------------------------------ Full post data ------------------------------------------------------------------ */ public function get_post_data( \WP_REST_Request $request ): \WP_REST_Response { global $wpdb; $post_id = (int) $request->get_param( 'id' ); $post = get_post( $post_id ); // Same scope as everything else (published, not password-protected). if ( \Clusterlane\Core\Scope::excludes( $post ) ) { return new \WP_REST_Response( [ 'error' => __( 'This article is not analysed by Clusterlane (unpublished or password-protected).', 'clusterlane' ), 'code' => 'clusterlane_out_of_scope' ], 404 ); } // --- Basic post info --- // get_the_category() / get_the_tags() store names with HTML entities // pre-encoded (e.g. "Art & Design Museums") — decode once so the // REST response holds raw text that JS can safely escape itself. $categories = get_the_category( $post_id ); $tags = get_the_tags( $post_id ) ?: []; $decode = fn( string $s ) => html_entity_decode( $s, ENT_QUOTES, 'UTF-8' ); // --- Metrics from DB --- $metrics = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}clusterlane_post_metrics WHERE post_id = %d", $post_id ) ); // --- Clusters this post belongs to --- // Build cluster_key lists in PHP using WP's own term functions, // then do a simple IN-query — avoids fragile SUBSTRING_INDEX SQL. // Its terms in every taxonomy that has clusters, plus custom clusters (0.98.177: // product categories and other taxonomies too, not only categories and tags). $all_keys = \Clusterlane\Core\ClusterTypes::post_keys( $post_id ); $clusters = []; if ( ! empty( $all_keys ) ) { $all_keys = array_values( $all_keys ); $clusters = $wpdb->get_results( $wpdb->prepare( "SELECT c.cluster_key, c.cluster_label, c.cluster_type, c.health_score, c.archetype, cpc.content_class, cpc.inbound_links AS cluster_inbound, cpc.percentile FROM {$wpdb->prefix}clusterlane_clusters c LEFT JOIN {$wpdb->prefix}clusterlane_cluster_post_class cpc ON cpc.cluster_key = c.cluster_key AND cpc.post_id = %d WHERE c.is_active = 1 AND c.cluster_key IN (" . implode( ',', array_fill( 0, count( $all_keys ), '%s' ) ) . ") ORDER BY c.cluster_label ASC", array_merge( [ $post_id ], $all_keys ) ) ); } // --- Cluster members (for ego graph) --- // For each cluster this post belongs to, fetch sibling members // so the graph can show the full neighbourhood. $cluster_members = []; if ( ! empty( $all_keys ) ) { foreach ( $all_keys as $ck ) { // Get up to 30 members per cluster from clusterlane_cluster_post_class. $members = $wpdb->get_results( $wpdb->prepare( "SELECT cpc.post_id, p.post_title, cpc.content_class FROM {$wpdb->prefix}clusterlane_cluster_post_class cpc INNER JOIN {$wpdb->posts} p ON p.ID = cpc.post_id WHERE cpc.cluster_key = %s AND cpc.post_id != %d ORDER BY cpc.inbound_links DESC LIMIT 30", $ck, $post_id ) ); $cluster_members[ $ck ] = array_map( fn( $m ) => [ 'id' => (int) $m->post_id, 'title' => $m->post_title, 'url' => get_permalink( (int) $m->post_id ), 'content_class' => $m->content_class, ], $members ); } } // --- Inbound links (posts that link TO this one) --- $inbound_links = $wpdb->get_results( $wpdb->prepare( "SELECT l.source_id, l.link_type, p.post_title, p.guid FROM {$wpdb->prefix}clusterlane_links l INNER JOIN {$wpdb->posts} p ON p.ID = l.source_id WHERE l.target_id = %d AND l.target_type = 'post' ORDER BY p.post_title ASC LIMIT 50", $post_id ) ); // --- Outbound links (posts this one links TO) --- $outbound_links = $wpdb->get_results( $wpdb->prepare( "SELECT l.target_id, l.link_type, p.post_title, p.guid FROM {$wpdb->prefix}clusterlane_links l INNER JOIN {$wpdb->posts} p ON p.ID = l.target_id WHERE l.source_id = %d AND l.target_type = 'post' ORDER BY p.post_title ASC LIMIT 50", $post_id ) ); // Links to category/tag pages: counted in the scan's outbound total but // not articles — listed separately so the two numbers always agree. // (target_type matters: term IDs and post IDs are separate sequences.) $outbound_terms = []; foreach ( (array) $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT target_id FROM {$wpdb->prefix}clusterlane_links WHERE source_id = %d AND target_type = 'term'", $post_id ) ) as $tid ) { $t = get_term( (int) $tid ); if ( ! $t || is_wp_error( $t ) ) continue; $link = get_term_link( $t ); $outbound_terms[] = [ 'id' => (int) $t->term_id, 'name' => html_entity_decode( $t->name, ENT_QUOTES, 'UTF-8' ), 'taxonomy' => $t->taxonomy, 'url' => is_wp_error( $link ) ? '' : $link ]; } $link_counts = [ 'in' => (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(DISTINCT source_id) FROM {$wpdb->prefix}clusterlane_links WHERE target_id = %d AND target_type = 'post'", $post_id ) ), 'out_posts' => (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(DISTINCT target_id) FROM {$wpdb->prefix}clusterlane_links WHERE source_id = %d AND target_type = 'post'", $post_id ) ), 'out_terms' => count( $outbound_terms ), ]; return rest_ensure_response( [ 'post' => [ 'id' => $post->ID, 'title' => $post->post_title, 'url' => get_permalink( $post->ID ), 'edit_url' => get_edit_post_link( $post->ID, 'raw' ) ?? admin_url( 'post.php?post=' . $post->ID . '&action=edit' ), 'date' => get_the_date( 'Y-m-d', $post ), 'modified' => get_the_modified_date( 'Y-m-d', $post ), 'categories' => array_map( fn( $c ) => [ 'id' => $c->term_id, 'name' => $decode( $c->name ), 'taxonomy' => 'category' ], $categories ), 'tags' => array_map( fn( $t ) => [ 'id' => $t->term_id, 'name' => $decode( $t->name ), 'taxonomy' => 'post_tag' ], $tags ), 'thumb' => \Clusterlane\Core\Thumbs::url( $post->ID ), // WooCommerce (0.98.172): a product gets product-page advice, not article advice. 'is_product' => \Clusterlane\Core\Shop::is_product( $post ), 'short_desc_words' => \Clusterlane\Core\Shop::is_product( $post ) ? str_word_count( \Clusterlane\Core\Shop::short_description( $post ), 0, 'åäöÅÄÖéèêëàâæœùûüïîæç' ) : 0, 'featured_image_url' => has_post_thumbnail( $post->ID ) ? get_the_post_thumbnail_url( $post->ID, 'large' ) : null, ], // SEO title + meta description as search engines get them (SeoMeta). 'seo' => \Clusterlane\Core\SeoMeta::get( $post ) + [ // The saved AI review, if any (0.98.196) — read only, never an AI call. 'check' => self::seo_check( $post ), 'original' => \Clusterlane\Core\SeoMeta::original_state( $post->ID ), // 0.98.197 'title_max' => \Clusterlane\Core\SeoMeta::TITLE_MAX, 'desc_min' => \Clusterlane\Core\SeoMeta::DESC_MIN, 'desc_max' => \Clusterlane\Core\SeoMeta::DESC_MAX, ], 'metrics' => $metrics ? [ 'word_count' => (int) $metrics->word_count, 'image_count' => (int) $metrics->image_count, 'h2_count' => (int) $metrics->h2_count, 'inbound_links' => (int) $metrics->inbound_links, 'outbound_links' => (int) $metrics->outbound_links, 'overall_score' => (int) $metrics->overall_score, 'seo_score' => (int) $metrics->seo_score, 'structure_score'=> (int) $metrics->structure_score, 'content_class' => $metrics->content_class, 'last_scanned' => $metrics->last_scanned, ] : null, 'clusters' => array_map( fn( $c ) => [ 'key' => $c->cluster_key, 'label' => $c->cluster_label, 'type' => $c->cluster_type, 'health_score' => (int) $c->health_score, 'archetype' => $c->archetype, 'content_class' => $c->content_class, 'percentile' => $c->percentile !== null ? (int) $c->percentile : null, ], $clusters ), 'cluster_members' => array_map( fn( $list ) => \Clusterlane\Core\Thumbs::add( $list ), $cluster_members ), 'inbound_links' => \Clusterlane\Core\Thumbs::add( array_map( fn( $l ) => [ 'id' => (int) $l->source_id, 'title' => $l->post_title, 'url' => get_permalink( (int) $l->source_id ), 'auto' => $l->link_type === 'auto', // pillar box (Pro), not in the text 'template' => $l->link_type === 'template', // theme list or card on the published page (0.98.177) ], $inbound_links ) ), 'outbound_terms' => $outbound_terms, 'link_counts' => $link_counts, 'outbound_links' => \Clusterlane\Core\Thumbs::add( array_map( fn( $l ) => [ 'id' => (int) $l->target_id, 'title' => $l->post_title, 'url' => get_permalink( (int) $l->target_id ), 'auto' => $l->link_type === 'auto', 'template' => $l->link_type === 'template', ], $outbound_links ) ), ] ); } /* ------------------------------------------------------------------ Mini TMS scan — scan ONE post for link opportunities ------------------------------------------------------------------ */ public function tms_scan( \WP_REST_Request $request ): \WP_REST_Response { global $wpdb; $params = $request->get_json_params(); $post_id = (int) ( $params['post_id'] ?? 0 ); if ( ! $post_id ) { return new \WP_REST_Response( [ 'error' => 'Missing post_id' ], 400 ); } $tm = $this->plugin->get( 'text_match' ); $settings = $this->plugin->get( 'settings' ); if ( $settings ) { $tm->set_extra_stop_words( $settings->tms_stop_words() ); } // Load skipped pairs for this post. $skipped_table = $wpdb->prefix . 'clusterlane_tms_skipped'; $skipped_targets = array_map( 'intval', $wpdb->get_col( $wpdb->prepare( 'SELECT target_id FROM %i WHERE source_id = %d', $skipped_table, $post_id ) ) ); // Run scan — all candidates, no offset, full pass. $result = $tm->scan( $post_id, PHP_INT_MAX, 0 ); // every article (was the first 500 only) $matches = array_values( array_filter( $result['matches'], fn( $m ) => ! in_array( (int) $m['target_id'], $skipped_targets, true ) ) ); // Other articles that mention THIS article's title without linking here // (same keyword rules as the forward scan; skipped pairs left out). $reverse = array_values( array_filter( $tm->scan_for_target( $post_id, 30 ), fn( $m ) => ! $wpdb->get_var( $wpdb->prepare( 'SELECT 1 FROM %i WHERE source_id = %d AND target_id = %d', $skipped_table, (int) $m['source_id'], $post_id ) ) ) ); // Role + inbound of each outgoing target: orphans first in the list. if ( $matches ) { $ids = array_values( array_map( fn( $m ) => (int) $m['target_id'], $matches ) ); $met = []; foreach ( (array) $wpdb->get_results( $wpdb->prepare( "SELECT post_id, content_class, inbound_links FROM {$wpdb->prefix}clusterlane_post_metrics WHERE post_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ")", $ids ) ) as $r ) { $met[ (int) $r->post_id ] = $r; } foreach ( $matches as &$m ) { $r = $met[ (int) $m['target_id'] ] ?? null; $m['target_role'] = $r ? (string) $r->content_class : ''; $m['target_inbound'] = $r ? (int) $r->inbound_links : 0; } unset( $m ); } // Thumbnail of "the other article" in each row (target when this article // links out, source when another article should link here). $matches = \Clusterlane\Core\Thumbs::add( $matches, 'target_id', 'target_thumb' ); $reverse = \Clusterlane\Core\Thumbs::add( $reverse, 'source_id', 'source_thumb' ); return rest_ensure_response( [ 'post_id' => $post_id, 'outgoing_matches' => $matches, // this post mentions other titles without linking 'reverse_matches' => $reverse, // other posts mention this post's title without linking here 'total_candidates' => $result['total_candidates'], ] ); } /** The stored AI review of the SEO title and description; Pro marks it stale (filter clusterlane_seo_check_item). */ private static function seo_check( \WP_Post $post ): ?array { $c = \Clusterlane\Core\SeoMeta::check( $post->ID ); if ( ! $c ) return null; $c = (array) apply_filters( 'clusterlane_seo_check_item', $c, $post ); unset( $c['hash'] ); return $c; } }