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). /** * TextMatch * * Scans post content for unlinked mentions of other post titles. * Ported from museumlite-intelligence text-match-engine.php with * improvements: OOP interface, slug cache integration, mb_-safe * string handling throughout. */ class TextMatch { /* ------------------------------------------------------------------ Constants ------------------------------------------------------------------ */ const MIN_KEYWORD_LEN = 4; const PROXIMITY_WINDOW = 200; const SNIPPET_CONTEXT = 80; const STOP_WORDS = [ 'the', 'and', 'for', 'with', 'from', 'that', 'this', 'have', 'will', 'city', 'town', 'area', 'park', 'tour', 'food', 'wine', 'beer', 'art', 'film', 'life', 'home', 'land', 'lake', 'view', 'walk', 'time', 'year', 'day', 'old', 'new', 'best', 'top', 'map', 'sea', 'way', 'use', 'must', 'also', 'more', 'most', 'some', 'into', 'been', 'when', ]; /** Extra stop words added at runtime (from Settings). */ private array $extra_stop_words = []; /** Extra stop phrases (multi-word plain entries) — matched against whole titles. */ private array $extra_stop_phrases = []; /** Extra stop word regex patterns (entries starting with /). */ private array $extra_stop_patterns = []; /** Keywords per title — built once per request, not once per source article (0.98.170). */ private array $kw_cache = []; public function set_extra_stop_words( array $entries ): void { $this->kw_cache = []; $this->cidx = []; $this->extra_stop_words = []; $this->extra_stop_phrases = []; $this->extra_stop_patterns = []; foreach ( $entries as $entry ) { $entry = trim( $entry ); if ( $entry === '' ) continue; // Detect regex: must start and end with / (optionally with flags like /i) if ( preg_match( '#^/.+/[imsxuADU]*$#', $entry ) ) { $this->extra_stop_patterns[] = $entry; } else { $normalized = strtolower( $entry ); $parts = preg_split( '/\s+/', $normalized, -1, PREG_SPLIT_NO_EMPTY ); if ( count( $parts ) > 1 ) { // Multi-word entry → treat as a phrase matched against whole titles. $this->extra_stop_phrases[] = $normalized; } else { // Single word → classic stop-word behaviour. if ( $parts[0] !== '' ) $this->extra_stop_words[] = $parts[0]; } } } } private function is_stop_word( string $word ): bool { $w = strtolower( $word ); if ( in_array( $w, self::STOP_WORDS, true ) ) return true; if ( in_array( $w, $this->extra_stop_words, true ) ) return true; foreach ( $this->extra_stop_patterns as $pattern ) { if ( @preg_match( $pattern, $word ) ) return true; } return false; } /** * Returns true if $title matches a multi-word stop phrase (case-insensitive). * The stripped (pre-separator) title is passed in, so "Royal Stockholm: ..." * is already reduced to "Royal Stockholm" before this check. */ private function is_stop_phrase( string $title ): bool { $lower = strtolower( $title ); foreach ( $this->extra_stop_phrases as $phrase ) { if ( $lower === $phrase ) return true; } return false; } /** * Check whether an entire title should be skipped based on regex patterns. * Plain stop words only suppress individual words; patterns can suppress whole titles. */ private function title_matches_stop_pattern( string $title ): bool { foreach ( $this->extra_stop_patterns as $pattern ) { if ( @preg_match( $pattern, $title ) ) return true; } return false; } /* ------------------------------------------------------------------ Public API ------------------------------------------------------------------ */ /** * Scan a single post for unlinked mentions of other post titles. * * @param int $source_id Post to scan. * @param int $batch_size Max candidates per call. * @param int $offset Candidate offset for pagination. * @return array { matches, total_candidates, scanned, offset } */ public function scan( int $source_id, int $batch_size = 100, int $offset = 0 ): array { $post = get_post( $source_id ); if ( ! $post || Scope::excludes( $post ) ) { // unpublished, protected, or a product hidden from the shop return [ 'matches' => [], 'total_candidates' => 0, 'scanned' => 0, 'offset' => $offset ]; } [ $content, $content_text, $free_text ] = self::prepare( (string) $post->post_content ); // Build set of already-linked targets as "type:id" keys. // extract_linked_ids() now returns strings like "post:42" or "term:7" // so that post IDs and term IDs (independent sequences in WordPress) // never collide when we check whether a candidate has been linked. $already_linked_keys = $this->extract_linked_ids( $content ); $already_linked_keys[] = 'post:' . $source_id; // never link to self // Also pull from clusterlane_links for authoritative, scanner-backed coverage. // Filter on target_type='post' so term IDs don't pollute this set. global $wpdb; $db_post_ids = $wpdb->get_col( $wpdb->prepare( "SELECT target_id FROM {$wpdb->prefix}clusterlane_links WHERE source_id = %d AND target_type = 'post' AND link_type = 'inline'", // a pillar box or a theme card is no reason to skip a link in the text $source_id ) ); foreach ( $db_post_ids as $pid ) { $already_linked_keys[] = 'post:' . (int) $pid; } $already_linked_keys = array_unique( $already_linked_keys ); // Candidates (multilingual sites: the source's own language, 0.98.168), with an index // from each title's first word to its position — built once per request (0.98.173). $idx = $this->candidate_index( Lang::of( $source_id ) ); $all_posts = $idx['list']; $total = count( $all_posts ); $end = min( $total, $offset + max( 0, $batch_size ) ); $linked = []; foreach ( $already_linked_keys as $k ) if ( strncmp( $k, 'post:', 5 ) === 0 ) $linked[ (int) substr( $k, 5 ) ] = true; $matches = []; // Speed (0.98.170): the text in lower case, to skip titles that do not occur at all // before running the exact pattern, and the article's link texts, read once. $free_lc = self::lc( $free_text ); $anchors = self::anchor_texts( $content ); // Word index (0.98.173): every run of letters in the text, as the pattern's own // boundary class sees it — a hash lookup per title instead of a string search // through the whole text (a 15,000-page shop has 15,000 titles to try). $words = self::word_set( $free_lc ); // Only titles whose first word is in the text (or that start with no letter) can match; // look the text's words up in the index instead of trying every title. Same order and // batches as trying them all: positions in [offset, end), ascending. $pick = []; foreach ( $idx['any'] as $i ) if ( $i >= $offset && $i < $end ) $pick[] = $i; foreach ( $words as $w => $_ ) { if ( ! isset( $idx['tok'][ $w ] ) ) continue; foreach ( $idx['tok'][ $w ] as $i ) if ( $i >= $offset && $i < $end ) $pick[] = $i; } sort( $pick ); foreach ( $pick as $i ) { $candidate = $all_posts[ $i ]; // Skip if already linked (by DB record or href found in content). if ( isset( $linked[ (int) $candidate->ID ] ) ) continue; // Skip if the candidate's title already appears as anchor text // anywhere in the content — handles term-archive links and // partial title matches (e.g. anchor is a prefix of the post title). $keyword = $idx['kw'][ $i ]; foreach ( $keyword['toks'] ?? [] as $t ) if ( ! isset( $words[ $t ] ) ) continue 2; // a word of the name is not in the text if ( ! self::may_occur( $free_lc, $keyword ) ) continue; // cheap test first — the pattern would not match either if ( $this->title_already_linked( $candidate->post_title, $content, $anchors ) ) continue; $hit = $this->find_match( $content_text, $free_text, $keyword ); if ( ! $hit ) continue; $matches[] = [ 'source_id' => $source_id, 'target_id' => (int) $candidate->ID, 'target_title' => $candidate->post_title, 'target_url' => get_permalink( $candidate->ID ), 'keyword' => $keyword['display'], 'match_text' => $hit['match_text'], 'snippet' => $hit['snippet'], 'hit_at' => $hit['hit_at'], // where in the snippet the linked words are (chars) 'hit_len' => $hit['hit_len'], 'char_offset' => $hit['offset'], ]; } return [ 'matches' => $matches, 'total_candidates' => $total, 'scanned' => max( 0, $end - $offset ), 'offset' => $offset, ]; } /** * Content as TextMatch reads it: [html without captions/shortcodes/quotes/alt, * plain text, plain text with existing links' words blanked out]. The two * texts have the same length, so offsets line up — a mention is only * offered where it is NOT already inside a link. */ private static function prepare( string $content ): array { // Strip content sections where links can't be placed or are // misleading: figure captions, [caption] shortcodes, blockquotes, // image alt attributes, and all remaining shortcodes (whose // attributes like name="..." are a common source of false positives). $content = preg_replace( '/\[caption[^\]]*\].*?\[\/caption\]/is', '', $content ); $content = strip_shortcodes( $content ); $content = preg_replace( '/]*>.*?<\/figcaption>/is', '', $content ); $content = preg_replace( '/]*>.*?<\/blockquote>/is', '', $content ); // Code, scripts, form controls and comments are not text to link (0.98.158, HtmlPlaces::no_go_ranges). $content = preg_replace( '/<(script|style|code|pre|textarea|button|select|svg)\b[^>]*>.*?<\/\1>/is', '', $content ); $content = preg_replace( '//s', '', $content ); $content = preg_replace( '/alt=["\'][^"\']*["\']/i', '', $content ); return [ $content, self::plain( $content ), self::plain( self::mask_anchors( $content ) ) ]; } /** * Reverse scan: other articles that mention $target_id's title (same * keyword rules as scan()) without linking to it. Same row shape as * scan(), with source_* filled — Apply goes through tms/apply as usual. * * A LIKE on the keyword narrows the sources first, so this stays fast * on large sites; every hit is then checked exactly like a forward scan. */ public function scan_for_target( int $target_id, int $limit = 30 ): array { $target = get_post( $target_id ); if ( ! $target || Scope::excludes( $target ) ) return []; $keyword = $this->build_keyword( $target->post_title, $target->post_type === Shop::TYPE ); if ( ! $keyword ) return []; global $wpdb; $like = '%' . $wpdb->esc_like( wp_strip_all_tags( html_entity_decode( (string) $keyword['display'], ENT_QUOTES, 'UTF-8' ) ) ) . '%'; // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- SQL assembled from code-only fragments (no values); every value is a placeholder passed to $wpdb->prepare(). $rows = $wpdb->get_results( $wpdb->prepare( "SELECT p.ID, p.post_title FROM {$wpdb->posts} p WHERE " . Scope::sql( 'p' ) . " AND " . Scope::type_sql( 'p' ) . " AND " . Lang::sql( Lang::of( $target_id ), 'p' ) . " AND p.ID != %d AND p.post_content LIKE %s ORDER BY p.ID ASC LIMIT 300", $target_id, $like ) ); // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared if ( ! $rows ) return []; $linking = array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT source_id FROM {$wpdb->prefix}clusterlane_links WHERE target_id = %d AND target_type = 'post' AND link_type = 'inline'", $target_id ) ) ); $target_url = get_permalink( $target_id ); $out = []; foreach ( $rows as $r ) { $sid = (int) $r->ID; if ( in_array( $sid, $linking, true ) ) continue; $src = get_post( $sid ); if ( ! $src ) continue; [ $content, $text, $free ] = self::prepare( (string) $src->post_content ); if ( in_array( 'post:' . $target_id, $this->extract_linked_ids( $content ), true ) ) continue; if ( $this->title_already_linked( $target->post_title, $content ) ) continue; $hit = $this->find_match( $text, $free, $keyword ); if ( ! $hit ) continue; $out[] = [ 'source_id' => $sid, 'source_title' => $r->post_title, 'source_url' => get_permalink( $sid ), 'target_id' => $target_id, 'target_title' => $target->post_title, 'target_url' => $target_url, 'keyword' => $keyword['display'], 'match_text' => $hit['match_text'], 'snippet' => $hit['snippet'], 'hit_at' => $hit['hit_at'], 'hit_len' => $hit['hit_len'], 'char_offset' => $hit['offset'], ]; if ( count( $out ) >= $limit ) break; } return $out; } /** * Apply a link to the first unlinked occurrence of $match_text in $source_id. */ public function apply_link( int $source_id, string $match_text, string $target_url ): bool { $post = get_post( $source_id ); if ( ! $post ) return false; $match_text = trim( $match_text ); $anchor = '' . esc_html( $match_text ) . ''; // Elementor article: link the phrase inside its Text Editor widgets. if ( class_exists( ElementorWriter::class ) && ElementorWriter::is_elementor_post( $source_id ) && \Clusterlane\Core\Edition::has( 'elementor' ) && apply_filters( 'clusterlane_elementor_write', true ) ) { $data = ElementorWriter::replace_first( $source_id, fn( string $html ): string => $this->replace_outside_anchors( $html, $match_text, $anchor ) ); if ( ! is_array( $data ) ) return false; return ElementorWriter::save( $source_id, $data ) === true; } $new_content = $this->replace_outside_anchors( $post->post_content, $match_text, $anchor ); if ( $new_content === $post->post_content ) return false; // wp_update_post() expects slashed data (it runs wp_unslash() before saving) — // without wp_slash() every backslash in the article would be stripped. $result = wp_update_post( wp_slash( [ 'ID' => $source_id, 'post_content' => $new_content ] ), true ); return $result && ! is_wp_error( $result ); } /** * Get all published post IDs for the scanner JS batch loop. * * @return int[] */ public function get_all_post_ids( int $cat_id = 0 ): array { $args = Scope::query_args( [ 'post_type' => Scope::post_types(), 'post_status' => 'publish', 'posts_per_page' => -1, 'fields' => 'ids', 'orderby' => 'ID', 'order' => 'ASC', 'no_found_rows' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ] ); if ( $cat_id > 0 ) { $args['cat'] = $cat_id; } return get_posts( $args ); } /* ------------------------------------------------------------------ Private helpers ------------------------------------------------------------------ */ /** Load all published posts (ID + title), optionally filtered by category. Cached per request. */ private int $match_cat_id = 0; public function set_match_cat_id( int $id ): void { $this->match_cat_id = $id; } /** @var int[]|null Restrict link targets to these posts (a cluster's members). */ private ?array $candidate_ids = null; public function set_candidate_ids( ?array $ids ): void { $this->candidate_ids = $ids === null ? null : array_map( 'intval', $ids ); $this->cand_sig = $ids === null ? '*' : md5( implode( ',', $this->candidate_ids ) ); } private function get_candidate_posts(): array { if ( $this->candidate_ids !== null ) { // Filter the (cached) full list — keeps the site-wide order and titles. $keep = array_flip( $this->candidate_ids ); $all = $this->get_candidate_posts_all(); return array_values( array_filter( $all, fn( $r ) => isset( $keep[ (int) $r->ID ] ) ) ); } return $this->get_candidate_posts_all(); } private function get_candidate_posts_all(): array { static $cache = []; $key = $this->match_cat_id; if ( isset( $cache[ $key ] ) ) return $cache[ $key ]; global $wpdb; if ( $key > 0 ) { // Filter by category via term_relationships. // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- SQL assembled from code-only fragments (no values); every value is a placeholder passed to $wpdb->prepare(). $cache[ $key ] = $wpdb->get_results( $wpdb->prepare( "SELECT p.ID, p.post_title, p.post_type FROM {$wpdb->posts} p INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id WHERE " . Scope::sql( 'p' ) . " AND " . Scope::type_sql( 'p' ) . " AND tt.taxonomy = 'category' AND tt.term_id = %d ORDER BY p.ID ASC", $key ) ); // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared } else { // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- SQL assembled from code-only fragments (no values); every value is a placeholder passed to $wpdb->prepare(). $cache[ $key ] = $wpdb->get_results( $wpdb->prepare( 'SELECT ID, post_title, post_type FROM %i WHERE ' . Scope::sql() . ' AND ' . Scope::type_sql() . ' ORDER BY ID ASC', $wpdb->posts ) ); // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared } return $cache[ $key ]; } /** * Build a keyword descriptor from a post title. * * @return array|null { type, words, pattern, display } */ private function build_keyword( string $title, bool $whole_name = false ): ?array { // Check if the whole title is suppressed by a regex stop pattern. if ( $this->title_matches_stop_pattern( $title ) ) return null; // Strip subtitle after separator. foreach ( [ ': ', ' – ', ' — ', ' - ' ] as $sep ) { if ( strpos( $title, $sep ) !== false ) { $title = trim( substr( $title, 0, strpos( $title, $sep ) ) ); break; } } // Suppress if the (stripped) title matches a plain multi-word stop phrase. if ( $this->is_stop_phrase( $title ) ) return null; $words = preg_split( '/\s+/', $title, -1, PREG_SPLIT_NO_EMPTY ); if ( count( $words ) === 1 ) { $w = strtolower( $words[0] ); if ( strlen( $w ) < self::MIN_KEYWORD_LEN || $this->is_stop_word( $w ) ) { return null; } return [ 'type' => 'exact', 'words' => $words, 'pattern' => '/(? $title, ]; } // Products (0.98.172): the whole name, exactly — "Old Town Mug No. 1" is a // name, not a topic to look for word by word. if ( count( $words ) <= 4 || ( $whole_name && count( $words ) <= 8 ) ) { return [ 'type' => 'exact', 'words' => $words, 'pattern' => '/(? $title, ]; } // Long title — proximity match on significant words. $sig = array_values( array_filter( $words, fn( $w ) => strlen( $w ) >= self::MIN_KEYWORD_LEN && ! $this->is_stop_word( $w ) ) ); if ( count( $sig ) < 3 ) return null; return [ 'type' => 'proximity', 'words' => $sig, 'display' => implode( ' + ', $sig ), ]; } /** * Text of HTML without tags (script/style removed), NOT trimmed — so a * masked copy (mask_anchors) keeps exactly the same byte offsets. */ private static function plain( string $html ): string { $html = preg_replace( '@<(script|style)[^>]*?>.*?@si', '', $html ); return strip_tags( $html ); // phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags -- wp_strip_all_tags() trims, which would shift the offsets } /** Replace the visible words inside every … with spaces of equal byte length. */ private static function mask_anchors( string $html ): string { return preg_replace_callback( '/(]*>)(.*?)(<\/a>)/is', function ( $m ) { $inner = preg_replace_callback( '/[^<>]+(?=<|$)/', fn( $t ) => str_repeat( ' ', strlen( $t[0] ) ), $m[2] ); return $m[1] . $inner . $m[3]; }, $html ); } /** * First mention of keyword that is not already inside a link. * Searches $free (links blanked) and reads the words from $text. */ private function find_match( string $text, string $free, array $keyword ): ?array { if ( $keyword['type'] === 'exact' ) { if ( ! preg_match( $keyword['pattern'], $free, $m, PREG_OFFSET_CAPTURE ) ) return null; $offset = $m[0][1]; $match_text = substr( $text, $offset, strlen( $m[0][0] ) ); return [ 'match_text' => $match_text, 'snippet' => $this->snippet( $text, $offset, strlen( $match_text ) ), 'offset' => $offset, ] + $this->snippet_hit( $text, $offset, strlen( $match_text ) ); } // Proximity mode (words searched outside existing links too). $offsets = []; foreach ( $keyword['words'] as $w ) { $pat = '/(? self::PROXIMITY_WINDOW ) return null; $match_text = substr( $text, $min, $max - $min ); return [ 'match_text' => $match_text, 'snippet' => $this->snippet( $text, $min, $max - $min ), 'offset' => $min, ] + $this->snippet_hit( $text, $min, $max - $min ); } /** * Returns true if $pos (byte offset in $html) sits inside a shortcode tag, * i.e. between [ and ] where the bracket pair is not a closing shortcode. * This prevents linking text that appears as a shortcode attribute value, * e.g. [ml_featured_hotel name="Hotel Sven Vintappare"]. */ private function is_inside_shortcode( string $html, int $pos ): bool { $before = substr( $html, 0, $pos ); $last_open = strrpos( $before, '[' ); $last_close = strrpos( $before, ']' ); if ( $last_open === false ) return false; if ( $last_close !== false && $last_close > $last_open ) return false; // We're between [ and the next ]. Confirm the ] actually closes this bracket. $after = substr( $html, $pos ); $next_close = strpos( $after, ']' ); return $next_close !== false; } /** * Where the matched words sit inside snippet() — in characters — so the UI * can mark exactly the occurrence that Apply will link (not every repeat). * * @return array{hit_at:int, hit_len:int} */ private function snippet_hit( string $text, int $offset, int $len ): array { $start = max( 0, $offset - self::SNIPPET_CONTEXT ); $pre = substr( $text, $start, $offset - $start ); if ( $start > 0 ) $pre = '…' . ltrim( $pre ); $dec = fn( string $x ): string => self::tidy( self::decode( $x ) ); // Code points (not UTF-16 units) — the JS slices with Array.from(). return [ 'hit_at' => mb_strlen( $dec( $pre ), 'UTF-8' ), 'hit_len' => mb_strlen( $dec( substr( $text, $offset, $len ) ), 'UTF-8' ), ]; } /** * Undo a Text Match link: turn the first $text * that Clusterlane inserted back into plain text. */ public function remove_link( int $source_id, string $match_text, string $target_url ): bool { $post = get_post( $source_id ); if ( ! $post ) return false; // Match the exact anchor Clusterlane writes; the words are compared // case-insensitively and put back exactly as they stand in the // article (apply_link() keeps the article's own spelling). $rx = '/(' . preg_quote( esc_html( trim( $match_text ) ), '/' ) . ')<\/a>/iu'; $undo = fn( string $html ): string => (string) preg_replace( $rx, '$1', $html, 1 ); if ( class_exists( ElementorWriter::class ) && ElementorWriter::is_elementor_post( $source_id ) && \Clusterlane\Core\Edition::has( 'elementor' ) && apply_filters( 'clusterlane_elementor_write', true ) ) { $data = ElementorWriter::replace_first( $source_id, $undo ); if ( ! is_array( $data ) ) return false; return ElementorWriter::save( $source_id, $data ) === true; } $new = $undo( $post->post_content ); if ( $new === $post->post_content ) return false; $result = wp_update_post( wp_slash( [ 'ID' => $source_id, 'post_content' => $new ] ), true ); return $result && ! is_wp_error( $result ); } /** * Decode HTML entities for display — repeatedly, because imported or * pasted content is sometimes double-encoded ("&amp;" → "&" → "&"). */ private static function decode( string $x ): string { for ( $i = 0; $i < 3; $i++ ) { $d = html_entity_decode( $x, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); if ( $d === $x ) break; $x = $d; } return $x; } /** Build a readable context snippet. */ private function snippet( string $text, int $offset, int $len ): string { $start = max( 0, $offset - self::SNIPPET_CONTEXT ); $end = min( strlen( $text ), $offset + $len + self::SNIPPET_CONTEXT ); $snippet = substr( $text, $start, $end - $start ); if ( $start > 0 ) $snippet = '…' . ltrim( $snippet ); if ( $end < strlen( $text ) ) $snippet = rtrim( $snippet ) . '…'; // Readable text: "&" → "&" (snippet_hit() counts on the same decoded text). return self::tidy( self::decode( $snippet ) ); } /** * Block boundaries in a snippet (0.98.185): a heading and the paragraph after it * were joined into one sentence ("Getting there Entry is included…"). A line * break after a heading-like line becomes " · ", after a full stop a space. * snippet_hit() applies the same, so the marked words stay in place. */ private static function tidy( string $x ): string { $x = (string) preg_replace_callback( '/(\S?)[ \t]*\R\s*/u', function ( $m ) { if ( $m[1] === '' ) return ' '; return $m[1] . ( preg_match( '/[.!?:;…,]/u', $m[1] ) ? ' ' : ' · ' ); }, $x ); return $x; } /** * Replace first occurrence of $needle outside tags. * Uses mb_-safe string ops throughout. */ private function replace_outside_anchors( string $html, string $needle, string $replacement ): string { // Every occurrence is tried in order (not just the first two), with the // same rules as the scan, so Apply links the mention the scan showed: // - not inside an existing … // - not inside an HTML tag (e.g. data-* attributes) or a shortcode // - not inside
,
or [caption] (skipped by the scan too) // - whole words only $needle = trim( $needle ); if ( $needle === '' ) return $html; $pattern = '/(?].*?<\/a>)/is', $html, -1, PREG_SPLIT_DELIM_CAPTURE ); foreach ( $parts as $i => &$part ) { if ( $i % 2 === 1 ) continue; // an existing link if ( ! preg_match_all( $pattern, $part, $hits, PREG_OFFSET_CAPTURE ) ) continue; // Regions the scan ignores (byte ranges within this part). $skip = []; foreach ( [ '/]*>.*?<\/blockquote>/is', '/]*>.*?<\/figcaption>/is', '/\[caption[^\]]*\].*?\[\/caption\]/is' ] as $rx ) { if ( preg_match_all( $rx, $part, $rm, PREG_OFFSET_CAPTURE ) ) { foreach ( $rm[0] as $r ) $skip[] = [ $r[1], $r[1] + strlen( $r[0] ) ]; } } // Scripts, styles, code, form controls, buttons, comments (0.98.158). $skip = array_merge( $skip, HtmlPlaces::no_go_ranges( $part ) ); foreach ( $hits[0] as $h ) { $pos = $h[1]; // byte offset $before = substr( $part, 0, $pos ); $lo = strrpos( $before, '<' ); $lc = strrpos( $before, '>' ); if ( $lo !== false && ( $lc === false || $lo > $lc ) ) continue; // inside a tag if ( $this->is_inside_shortcode( $part, $pos ) ) continue; // inside a shortcode (byte offset, like substr() in the check) foreach ( $skip as $r ) { if ( $pos >= $r[0] && $pos < $r[1] ) continue 2; } // quote / caption // Link the words exactly as written in the article (the match is // case-insensitive, so the needle's casing may differ). $link = str_replace( '>' . esc_html( $needle ) . '', '>' . $h[0] . '', $replacement ); $part = substr( $part, 0, $pos ) . $link . substr( $part, $pos + strlen( $h[0] ) ); unset( $part ); return implode( '', $parts ); } } unset( $part ); return $html; } /** * Returns true if $title already appears as the text node of an tag * in $html — regardless of which URL the href points to. */ /** * Check whether $title is already used as anchor text for a link in $html. * * Two cases are handled: * 1. Exact match — the anchor text equals $title exactly. * 2. Partial match — one string is a leading prefix of the other (≥10 chars). * Catches the common case where a category archive is linked as * "Science Museums in Stockholm" while the candidate post is titled * "Science Museums in Stockholm: Innovation & Discovery". */ private function title_already_linked( string $title, string $html, ?array $anchors = null ): bool { foreach ( $anchors ?? self::anchor_texts( $html ) as $anchor ) { // Exact match (case-insensitive). if ( strcasecmp( $anchor, $title ) === 0 ) return true; // Partial/prefix match — one is a leading substring of the other. $min = min( strlen( $anchor ), strlen( $title ) ); if ( $min >= 10 && strncasecmp( $anchor, $title, $min ) === 0 ) return true; } return false; } /** The visible texts of every link in $html (trimmed, non-empty). */ private static function anchor_texts( string $html ): array { preg_match_all( '/]*>(.*?)<\/a>/is', $html, $m ); return array_values( array_filter( array_map( fn( $a ) => trim( wp_strip_all_tags( $a ) ), $m[1] ), fn( $a ) => $a !== '' ) ); } /** Lower case for the pre-test (multibyte when available). */ private static function lc( string $s ): string { return function_exists( 'mb_strtolower' ) ? mb_strtolower( $s, 'UTF-8' ) : strtolower( $s ); } /** Candidate indexes per request: list signature → [list, kw, tok → positions, any]. */ private array $cidx = []; private string $cand_sig = '*'; /** * The candidate list (in scope, optionally one language) and, for each title with a * keyword, its position under its first word ('tok') — or under 'any' when the name * starts with no letter (always tried). Titles without a keyword are left out: they * never match. (0.98.173) * @return array{list: object[], kw: array, tok: array, any: int[]} */ private function candidate_index( string $lang ): array { $key = $this->match_cat_id . '|' . $this->cand_sig . '|' . $lang; if ( isset( $this->cidx[ $key ] ) ) return $this->cidx[ $key ]; $list = $this->get_candidate_posts(); if ( $lang !== '' ) { $keep = array_flip( Lang::filter_ids( array_map( fn( $r ) => (int) $r->ID, $list ), $lang ) ); $list = array_values( array_filter( $list, fn( $r ) => isset( $keep[ (int) $r->ID ] ) ) ); } $out = [ 'list' => $list, 'kw' => [], 'tok' => [], 'any' => [] ]; $df = []; // in how many names each word occurs — each name is filed under its rarest word foreach ( $list as $i => $c ) { $k = $this->keyword( $c->post_title, ( $c->post_type ?? '' ) === Shop::TYPE ); if ( ! $k ) continue; $out['kw'][ $i ] = $k; foreach ( $k['toks'] ?? [] as $t ) $df[ $t ] = ( $df[ $t ] ?? 0 ) + 1; } foreach ( $out['kw'] as $i => $k ) { if ( empty( $k['toks'] ) ) { $out['any'][] = $i; continue; } $best = $k['toks'][0]; foreach ( $k['toks'] as $t ) if ( $df[ $t ] < $df[ $best ] ) $best = $t; $out['tok'][ $best ][] = $i; } return $this->cidx[ $key ] = $out; } /** build_keyword(), once per title per request. */ private function keyword( string $title, bool $product = false ): ?array { $ck = ( $product ? "p\x1f" : '' ) . $title; if ( ! array_key_exists( $ck, $this->kw_cache ) ) { $k = $this->build_keyword( $title, $product ); // The literal words the pattern looks for, in lower case: if one is missing // from the text, the (case-insensitive) pattern cannot match there either. if ( $k ) { $k['needles'] = $k['type'] === 'exact' ? [ self::lc( $k['display'] ) ] : array_map( [ self::class, 'lc' ], $k['words'] ); // Every run of letters in the name is a whole run of letters in the text // wherever the pattern matches: inside the name it is bounded by the name's // own non-letters, and at its ends the pattern demands a non-letter. So each // is an exact word_set() key, and all must be present for a match. $toks = []; foreach ( $k['needles'] as $n ) { if ( preg_match_all( '/' . self::LETTERS . '+/u', $n, $fm ) ) foreach ( $fm[0] as $t ) $toks[ $t ] = true; } if ( $toks ) $k['toks'] = array_keys( $toks ); } $this->kw_cache[ $ck ] = $k; } return $this->kw_cache[ $ck ]; } /** The letters the patterns treat as word characters (their lookbehind/lookahead class), lower case. */ private const LETTERS = '[a-zſß×à-öø-ÿ]'; // = [a-zA-ZÀ-öø-ÿ] under /iu, after mb_strtolower (ß, × and the long s fold into it) /** Every maximal run of LETTERS in a lower-cased text, as keys. */ private static function word_set( string $text_lc ): array { return array_flip( preg_split( '/(?:(?!' . self::LETTERS . ').)+/su', $text_lc, -1, PREG_SPLIT_NO_EMPTY ) ?: [] ); } /** False only when the keyword cannot occur in the (lower-cased) text. */ private static function may_occur( string $text_lc, array $keyword ): bool { foreach ( $keyword['needles'] ?? [] as $n ) { if ( $n !== '' && strpos( $text_lc, $n ) === false ) return false; } return true; } /** * Extract already-linked target identifiers from HTML content. * * Returns "type:id" strings (e.g. "post:42", "term:7") so that post IDs * and term IDs — independent numeric sequences in WordPress — can never * collide when TMS checks whether a candidate has already been linked. * * @return string[] */ private function extract_linked_ids( string $html ): array { $keys = []; preg_match_all( '/href=["\'"]([^"\'"]+)["\']/i', $html, $m ); foreach ( $m[1] as $url ) { // The same links recur across articles — resolve each URL once per request (0.98.173). if ( array_key_exists( $url, $this->url_keys ) ) { if ( $this->url_keys[ $url ] !== '' ) $keys[] = $this->url_keys[ $url ]; continue; } $before = count( $keys ); $this->resolve_linked_url( $url, $keys ); $this->url_keys[ $url ] = count( $keys ) > $before ? end( $keys ) : ''; } return array_unique( $keys ); } /** URL → "type:id" per request, for extract_linked_ids(). */ private array $url_keys = []; /** Adds the "type:id" key $url points at (if any) to $keys. */ private function resolve_linked_url( string $url, array &$keys ): void { foreach ( [ $url ] as $url ) { if ( ! \Clusterlane\Core\Multisite::is_internal_url( $url ) ) continue; $full = \Clusterlane\Core\Multisite::absolutize( \Clusterlane\Core\Multisite::normalize_url( $url ) ); // Try post first (most common case); custom post types on plain permalinks too. $post_id = url_to_postid( $full ) ?: ( strpos( $full, '?' ) !== false ? \Clusterlane\Core\LinkGraph::query_var_id( $full ) : 0 ); if ( $post_id ) { $keys[] = 'post:' . $post_id; continue; } // By slug, current or old (WordPress redirects an old one) — as the link graph does (0.98.167). $old = \Clusterlane\Core\LinkGraph::slug_id( (string) preg_replace( '/\.html?$/i', '', basename( rtrim( (string) wp_parse_url( $full, PHP_URL_PATH ), '/' ) ) ) ); if ( $old ) { $keys[] = 'post:' . $old; continue; } // Try term archive — check category and post_tag by slug. $slug = basename( rtrim( wp_parse_url( $full, PHP_URL_PATH ), '/' ) ); foreach ( [ 'category', 'post_tag' ] as $tax ) { $term = get_term_by( 'slug', $slug, $tax ); if ( $term && ! is_wp_error( $term ) ) { $keys[] = 'term:' . $term->term_id; break; } } } } }