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). /** * RestTextMatch * * REST endpoints for the Text Match Scanner. * Mirrors the museumlite-intelligence AJAX endpoints but uses * WordPress REST API instead of admin-ajax.php. */ class RestTextMatch { public function __construct( private \Clusterlane\Core\Plugin $plugin ) {} public function register_routes(): void { register_rest_route( 'clusterlane/v1', '/tms/posts', [ 'methods' => 'GET', 'callback' => [ $this, 'get_post_ids' ], 'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(), ] ); register_rest_route( 'clusterlane/v1', '/tms/scan', [ 'methods' => 'POST', 'callback' => [ $this, 'scan_post' ], 'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(), ] ); // Quick wins for a whole cluster, server-side: members × members in // time-boxed chunks (was one request per member from the browser — // 870 requests for an 870-article cluster). register_rest_route( 'clusterlane/v1', '/tms/scan-cluster', [ 'methods' => 'POST', 'callback' => [ $this, 'scan_cluster' ], 'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(), ] ); // Text Match page: many source articles per request, time-boxed // (was one request per article). register_rest_route( 'clusterlane/v1', '/tms/scan-batch', [ 'methods' => 'POST', 'callback' => [ $this, 'scan_batch' ], 'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(), ] ); register_rest_route( 'clusterlane/v1', '/tms/apply', [ 'methods' => 'POST', 'callback' => [ $this, 'apply_link' ], 'permission_callback' => fn() => \Clusterlane\Core\Capabilities::rest_links(), ] ); // Persist a skipped match so it never resurfaces. register_rest_route( 'clusterlane/v1', '/tms/unlink', [ 'methods' => 'POST', 'callback' => [ $this, 'unlink' ], 'permission_callback' => fn() => \Clusterlane\Core\Capabilities::rest_links(), ] ); register_rest_route( 'clusterlane/v1', '/tms/skip', [ 'methods' => 'POST', 'callback' => [ $this, 'skip_match' ], 'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(), ] ); // Clear all skipped matches (allow them to resurface). // Clear ALL skipped pairs — requires manage_options since this is a // destructive, site-wide action that affects every editor's workflow, // not just the current user's. A regular editor can only add skips, // not wipe the entire list for everyone. register_rest_route( 'clusterlane/v1', '/tms/skip', [ 'methods' => 'DELETE', 'callback' => [ $this, 'clear_skipped' ], 'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_manage(), ] ); } public function get_post_ids( \WP_REST_Request $request ): \WP_REST_Response { $scan_cat = (int) ( $request->get_param( 'scan_cat' ) ?? 0 ); $ids = $this->plugin->get('text_match')->get_all_post_ids( $scan_cat ); global $wpdb; $skipped = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}clusterlane_tms_skipped" ); // phpcs:ignore return rest_ensure_response( [ 'ids' => $ids, 'total' => count( $ids ), 'skipped' => $skipped ] ); } /** * POST tms/scan-cluster { key, cursor } → matches between the cluster's * own articles, scanned in chunks of about 4 seconds. The client calls * again with next_cursor until it is null. */ public function scan_cluster( \WP_REST_Request $request ): \WP_REST_Response { global $wpdb; $p = (array) $request->get_json_params(); $key = sanitize_key( (string) ( $p['key'] ?? '' ) ); $cursor = max( 0, (int) ( $p['cursor'] ?? 0 ) ); $cluster = $key ? $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}clusterlane_clusters WHERE cluster_key = %s AND is_active = 1", $key ) ) : null; $engine = $this->plugin->get( 'cluster_engine' ); if ( ! $cluster || ! $engine ) return new \WP_REST_Response( [ 'error' => 'Cluster not found' ], 404 ); $members = array_map( 'intval', $engine->get_member_post_ids( $cluster ) ); sort( $members ); $total = count( $members ); $tm = $this->plugin->get( 'text_match' ); $settings = $this->plugin->get( 'settings' ); if ( $settings ) $tm->set_extra_stop_words( $settings->tms_stop_words() ); $tm->set_candidate_ids( $members ); _prime_post_caches( array_slice( $members, $cursor, 200 ), false, false ); $skipped = []; foreach ( (array) $wpdb->get_results( "SELECT source_id, target_id FROM {$wpdb->prefix}clusterlane_tms_skipped" ) as $r ) { $skipped[ (int) $r->source_id . '_' . (int) $r->target_id ] = true; } $start = microtime( true ); $matches = []; $i = $cursor; for ( ; $i < $total; $i++ ) { if ( microtime( true ) - $start > 4.0 ) break; // time box — the client continues $pid = $members[ $i ]; $result = $tm->scan( $pid, max( 1, $total ), 0 ); if ( ! $result['matches'] ) continue; $title = html_entity_decode( wp_strip_all_tags( get_the_title( $pid ) ), ENT_QUOTES, 'UTF-8' ); // JSON: plain text, the client escapes it foreach ( $result['matches'] as $m ) { if ( isset( $skipped[ $pid . '_' . (int) $m['target_id'] ] ) ) continue; $m['source_id'] = $pid; $m['source_title'] = $title; $matches[] = $m; } } $tm->set_candidate_ids( null ); if ( $matches ) { $ids = array_values( array_unique( array_map( 'intval', array_column( $matches, 'target_id' ) ) ) ); $rows = $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 ), OBJECT_K ); foreach ( $matches as &$mm ) { $r = $rows[ $mm['target_id'] ] ?? null; $mm['target_role'] = $r ? (string) $r->content_class : ''; $mm['target_inbound'] = $r ? (int) $r->inbound_links : 0; } unset( $mm ); $matches = \Clusterlane\Core\Thumbs::add( $matches, 'target_id', 'target_thumb' ); $matches = \Clusterlane\Core\Thumbs::add( $matches, 'source_id', 'source_thumb' ); } return rest_ensure_response( [ 'matches' => $matches, 'scanned' => $i, 'total' => $total, 'next_cursor' => $i < $total ? $i : null, ] ); } /** * POST tms/scan-batch { ids: [..], match_cat } → unlinked mentions for as * many of the given source articles as fit in about 4 seconds, in order. * Returns { results: [{post_id, post_title, matches}], processed }; the * client continues from ids[processed]. */ public function scan_batch( \WP_REST_Request $request ): \WP_REST_Response { global $wpdb; $p = (array) $request->get_json_params(); $ids = array_slice( array_values( array_filter( array_map( 'intval', (array) ( $p['ids'] ?? [] ) ) ) ), 0, 500 ); $match_cat = (int) ( $p['match_cat'] ?? 0 ); if ( ! $ids ) return new \WP_REST_Response( [ 'error' => 'Missing ids' ], 400 ); $tm = $this->plugin->get( 'text_match' ); $settings = $this->plugin->get( 'settings' ); if ( $settings ) $tm->set_extra_stop_words( $settings->tms_stop_words() ); if ( $match_cat > 0 ) $tm->set_match_cat_id( $match_cat ); _prime_post_caches( $ids, false, false ); $skipped = []; foreach ( (array) $wpdb->get_results( $wpdb->prepare( "SELECT source_id, target_id FROM {$wpdb->prefix}clusterlane_tms_skipped WHERE source_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ")", $ids ) ) as $r ) { $skipped[ (int) $r->source_id . '_' . (int) $r->target_id ] = true; } $start = microtime( true ); $results = []; $all = []; $done = 0; foreach ( $ids as $pid ) { if ( $done > 0 && microtime( true ) - $start > 4.0 ) break; // time box — the client continues $done++; $res = $tm->scan( $pid, PHP_INT_MAX, 0 ); $matches = array_values( array_filter( $res['matches'], fn( $m ) => ! isset( $skipped[ $pid . '_' . (int) $m['target_id'] ] ) ) ); if ( ! $matches ) continue; foreach ( $matches as &$m ) { $m['source_id'] = $pid; } unset( $m ); $results[] = [ 'post_id' => $pid, 'post_title' => html_entity_decode( wp_strip_all_tags( get_the_title( $pid ) ), ENT_QUOTES, 'UTF-8' ), 'matches' => $matches ]; array_push( $all, ...$matches ); } // Target role + inbound links, and thumbnails — once for the whole batch. if ( $all ) { $tids = array_values( array_unique( array_map( 'intval', array_column( $all, 'target_id' ) ) ) ); $rows = $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( $tids ), '%d' ) ) . ")", $tids ), OBJECT_K ); $turl = \Clusterlane\Core\Thumbs::urls( $tids ); $surl = \Clusterlane\Core\Thumbs::urls( array_column( $results, 'post_id' ) ); foreach ( $results as &$g ) { foreach ( $g['matches'] as &$m ) { $r = $rows[ $m['target_id'] ] ?? null; $m['target_role'] = $r ? (string) $r->content_class : ''; $m['target_inbound'] = $r ? (int) $r->inbound_links : 0; $m['target_thumb'] = $turl[ (int) $m['target_id'] ] ?? ''; $m['source_thumb'] = $surl[ (int) $g['post_id'] ] ?? ''; } unset( $m ); } unset( $g ); } return rest_ensure_response( [ 'results' => $results, 'processed' => $done ] ); } public function scan_post( \WP_REST_Request $request ): \WP_REST_Response { global $wpdb; $post_id = (int) ( $request->get_json_params()['post_id'] ?? 0 ); $batch_size = (int) ( $request->get_json_params()['batch_size'] ?? 0 ); if ( $batch_size <= 0 ) $batch_size = PHP_INT_MAX; // 0 = every article as a possible target $offset = (int) ( $request->get_json_params()['offset'] ?? 0 ); $match_cat = (int) ( $request->get_json_params()['match_cat'] ?? 0 ); if ( ! $post_id ) { return new \WP_REST_Response( [ 'error' => 'Missing post_id' ], 400 ); } // Load skipped pairs for this source post. $skipped_table = $wpdb->prefix . 'clusterlane_tms_skipped'; $skipped_targets = $wpdb->get_col( $wpdb->prepare( 'SELECT target_id FROM %i WHERE source_id = %d', $skipped_table, $post_id ) ); $skipped_targets = array_map( 'intval', $skipped_targets ); $post = get_post( $post_id ); $tm = $this->plugin->get('text_match'); // Inject site-specific stop words from Settings. $settings = $this->plugin->get('settings'); if ( $settings ) { $tm->set_extra_stop_words( $settings->tms_stop_words() ); } // Restrict candidate pool to a specific category if requested. if ( $match_cat > 0 ) { $tm->set_match_cat_id( $match_cat ); } $result = $tm->scan( $post_id, $batch_size, $offset ); // Filter out persistently skipped matches. $matches = array_values( array_filter( $result['matches'], fn( $m ) => ! in_array( (int) $m['target_id'], $skipped_targets, true ) ) ); // Target role + inbound links: linking to an orphan is the most valuable fix. if ( $matches ) { $ids = array_values( array_map( 'intval', array_column( $matches, 'target_id' ) ) ); $rows = $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 ), OBJECT_K ); foreach ( $matches as &$mm ) { $r = $rows[ $mm['target_id'] ] ?? null; $mm['target_role'] = $r ? (string) $r->content_class : ''; $mm['target_inbound'] = $r ? (int) $r->inbound_links : 0; } unset( $mm ); } $matches = \Clusterlane\Core\Thumbs::add( $matches, 'target_id', 'target_thumb' ); if ( $matches ) { $src_thumb = \Clusterlane\Core\Thumbs::url( $post_id ); foreach ( $matches as &$mm ) { $mm['source_thumb'] = $src_thumb; } unset( $mm ); } $next_offset = $offset + $result['scanned']; $more = $next_offset < $result['total_candidates']; return rest_ensure_response( [ 'post_id' => $post_id, 'post_title' => $post ? $post->post_title : '', 'matches' => $matches, 'more' => $more, 'offset' => $next_offset, 'total' => $result['total_candidates'], ] ); } public function apply_link( \WP_REST_Request $request ): \WP_REST_Response { $params = $request->get_json_params(); $source_id = (int) ( $params['source_id'] ?? 0 ); $match_text = sanitize_text_field( $params['match_text'] ?? '' ); $target_url = esc_url_raw( $params['target_url'] ?? '' ); if ( ! $source_id || ! $match_text || ! $target_url ) { return new \WP_REST_Response( [ 'error' => 'Missing parameters' ], 400 ); } // Verify that source post exists and is published. $source_post = get_post( $source_id ); if ( ! $source_post || $source_post->post_status !== 'publish' ) { return new \WP_REST_Response( [ 'error' => 'Source post not found' ], 404 ); } // Object-level check: writing into an article requires edit rights on it. if ( ! current_user_can( 'edit_post', $source_id ) ) { return new \WP_REST_Response( [ 'error' => 'You are not allowed to edit this article' ], 403 ); } // Page-builder articles render from the builder's own data — refuse // instead of reporting a success that never shows on the site. $builder = \Clusterlane\Core\Compat::page_builder( $source_id ); if ( $builder ) { return new \WP_REST_Response( [ 'error' => \Clusterlane\Core\Compat::page_builder_message( $builder ), 'page_builder' => $builder ], 409 ); } $editing = \Clusterlane\Core\Compat::locked_by( $source_id ); if ( $editing ) { return new \WP_REST_Response( [ 'error' => \Clusterlane\Core\Compat::locked_message( $editing ) ], 409 ); } // Reject external URLs — TMS only inserts internal links. if ( strpos( $target_url, home_url() ) !== 0 || ! \Clusterlane\Core\Multisite::is_internal_url( $target_url ) ) { return new \WP_REST_Response( [ 'error' => 'Target URL must be on this site' ], 400 ); } $ok = $this->plugin->get('text_match')->apply_link( $source_id, $match_text, $target_url ); if ( ! $ok ) { return new \WP_REST_Response( [ 'error' => 'Could not apply link — text may already be linked or not found.', ], 422 ); } // Surface a non-blocking warning when the content was written in // Gutenberg. The link IS inserted correctly in most cases, but // block-editor posts should ideally be reviewed in the editor // afterwards to confirm the change looks right. $target = $this->refresh_after_change( $source_id, $target_url, ! empty( $params['quick'] ) ); \Clusterlane\Core\Activity::log( \Clusterlane\Core\Activity::LINK_ADDED, 'text_match', $source_id, (int) ( $target['id'] ?? url_to_postid( $target_url ) ), $match_text ); $warning = has_blocks( $source_post->post_content ) ? __( 'This article uses the block editor. The link was applied — please verify it in the editor to make sure the formatting looks right.', 'clusterlane' ) : null; return rest_ensure_response( [ 'success' => true, 'message' => sprintf( 'Link applied: "%s" → %s', $match_text, $target_url ), 'warning' => $warning, 'target' => $target, ] ); } /** * POST clusterlane/v1/tms/unlink — Undo right after Apply. */ public function unlink( \WP_REST_Request $request ): \WP_REST_Response { $p = $request->get_json_params(); $source_id = (int) ( $p['source_id'] ?? 0 ); $match_text = (string) ( $p['match_text'] ?? '' ); $target_url = esc_url_raw( (string) ( $p['target_url'] ?? '' ) ); if ( ! $source_id || $match_text === '' || $target_url === '' ) { return new \WP_REST_Response( [ 'error' => 'Missing parameters' ], 400 ); } if ( ! current_user_can( 'edit_post', $source_id ) ) { return new \WP_REST_Response( [ 'error' => 'You are not allowed to edit this article' ], 403 ); } $editing = \Clusterlane\Core\Compat::locked_by( $source_id ); if ( $editing ) { return new \WP_REST_Response( [ 'error' => \Clusterlane\Core\Compat::locked_message( $editing ) ], 409 ); } if ( ! $this->plugin->get( 'text_match' )->remove_link( $source_id, $match_text, $target_url ) ) { return new \WP_REST_Response( [ 'error' => __( 'The link could not be found any more — it may have been edited.', 'clusterlane' ) ], 409 ); } $target = $this->refresh_after_change( $source_id, $target_url, ! empty( $p['quick'] ) ); \Clusterlane\Core\Activity::undo_last( \Clusterlane\Core\Activity::LINK_ADDED, $source_id, (int) ( $target['id'] ?? url_to_postid( $target_url ) ), 'text_match' ); return rest_ensure_response( [ 'success' => true, 'target' => $target ] ); } /** * Keep the link graph and the target's metrics current after Apply/Undo, * so orphan counts and roles update without a new full scan. * * @return array{id:int, role:string, inbound:int}|null */ private function refresh_after_change( int $source_id, string $target_url, bool $quick = false ): ?array { $graph = $this->plugin->get( 'link_graph' ); if ( $graph ) { $graph->save_links( $source_id, $graph->extract_links( $source_id ) ); } $target_id = url_to_postid( $target_url ); if ( ! $target_id ) return null; // Apply all (0.98.184): the link graph is up to date; the target's counts // follow when Metrics runs at the end of the run (Scanner::refresh_counts), // instead of re-reading its published page after every link. if ( $quick ) return [ 'id' => $target_id ]; $scanner = $this->plugin->get( 'scanner' ); if ( $scanner ) $scanner->scan_post( $target_id, true ); global $wpdb; $r = $wpdb->get_row( $wpdb->prepare( "SELECT content_class, inbound_links FROM {$wpdb->prefix}clusterlane_post_metrics WHERE post_id = %d", $target_id ) ); return [ 'id' => $target_id, 'role' => $r ? (string) $r->content_class : '', 'inbound' => $r ? (int) $r->inbound_links : 0 ]; } public function skip_match( \WP_REST_Request $request ): \WP_REST_Response { global $wpdb; $params = $request->get_json_params(); $source_id = (int) ( $params['source_id'] ?? 0 ); $target_id = (int) ( $params['target_id'] ?? 0 ); if ( ! $source_id || ! $target_id ) { return new \WP_REST_Response( [ 'error' => 'Missing source_id or target_id' ], 400 ); } $table = $wpdb->prefix . 'clusterlane_tms_skipped'; $wpdb->query( $wpdb->prepare( 'INSERT IGNORE INTO %i (source_id, target_id, skipped_at) VALUES (%d, %d, %s)', $table, $source_id, $target_id, current_time( 'mysql', true ) ) ); return rest_ensure_response( [ 'success' => true ] ); } public function clear_skipped( \WP_REST_Request $request ): \WP_REST_Response { global $wpdb; $wpdb->query( "TRUNCATE TABLE {$wpdb->prefix}clusterlane_tms_skipped" ); return rest_ensure_response( [ 'success' => true, 'message' => 'All skipped matches cleared.' ] ); } }