prefix . 'il_pilares') whose names and column lists // are constants under plugin control. Generic WordPress checks // for direct DB queries, caching and prepared-SQL interpolation // don't apply meaningfully here. See docs/LICENSING.md for the // full architecture rationale. class FLINK_Link_Stats { const TABLE = 'il_link_stats'; const VERSION = '1.0'; // ───────────────────────────────────────────────────────────────── // INSTALL // ───────────────────────────────────────────────────────────────── public static function maybe_install() { global $wpdb; $table = $wpdb->prefix . self::TABLE; // Two conditions for "skip install": // (1) Version option already matches the current schema version. // (2) The table physically exists in the database. // Without check (2), an installation can get stuck if the version // option was set but the table got dropped externally — or, in // this plugin's case, if the version option was written under one // table name (flink_link_stats) and we later switched to another // (il_link_stats). We want maybe_install() to re-run in that case. $installed = get_option( 'flink_link_stats_version', '' ); if ( $installed === self::VERSION ) { $exists = $wpdb->get_var( $wpdb->prepare( "SHOW TABLES LIKE %s", $table ) ); if ( $exists ) return; } $charset = $wpdb->get_charset_collate(); $sql = "CREATE TABLE IF NOT EXISTS {$table} ( post_id BIGINT UNSIGNED NOT NULL, post_title VARCHAR(500) NOT NULL DEFAULT '', post_url VARCHAR(500) NOT NULL DEFAULT '', post_status VARCHAR(20) NOT NULL DEFAULT 'publish', post_date DATETIME NOT NULL DEFAULT '1970-01-01', out_rules SMALLINT UNSIGNED NOT NULL DEFAULT 0, out_semantic SMALLINT UNSIGNED NOT NULL DEFAULT 0, in_rules SMALLINT UNSIGNED NOT NULL DEFAULT 0, in_semantic SMALLINT UNSIGNED NOT NULL DEFAULT 0, out_rules_data MEDIUMTEXT DEFAULT NULL, out_semantic_data MEDIUMTEXT DEFAULT NULL, in_rules_data MEDIUMTEXT DEFAULT NULL, in_semantic_data MEDIUMTEXT DEFAULT NULL, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (post_id), KEY idx_total_links (out_rules, out_semantic, in_rules, in_semantic) ) {$charset};"; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta( $sql ); update_option( 'flink_link_stats_version', self::VERSION ); } // ───────────────────────────────────────────────────────────────── // UPDATE STATS FOR A SINGLE POST (called after every link change) // ───────────────────────────────────────────────────────────────── /** * Scan the HTML content of $post_id, extract all plugin links, * and update the cache table. * * Uses a single-pass approach: match ALL tags containing the marker * attribute, then extract href from each matched tag. This avoids * double-counting (two regexes catching the same tag) and undercounting * (dedup removing legitimate duplicate anchors). * * Also updates the INCOMING stats of every post that $post_id links to. */ public static function update_post( $post_id ) { $post_id = (int) $post_id; $post = get_post( $post_id ); if ( ! $post ) return; $content = $post->post_content; // ── Extract outgoing rule links (data-il="1") ──────────────── // Single regex: match any that contains data-il="1" or data-il='1' $out_rules = array(); if ( preg_match_all( '/]*data-il=["\']1["\'][^>]*)>(.*?)<\/a>/is', $content, $matches, PREG_SET_ORDER ) ) { foreach ( $matches as $m ) { $attrs = $m[1]; $anchor = wp_strip_all_tags( $m[2] ); // Extract href from the attributes $url = ''; if ( preg_match( '/href=["\']([^"\']+)["\']/', $attrs, $href_m ) ) { $url = esc_url_raw( $href_m[1] ); } if ( $url ) { $out_rules[] = array( 'url' => $url, 'anchor' => $anchor ); } } } // ── Extract outgoing semantic links (data-il-s="1") ────────── $out_semantic = array(); if ( preg_match_all( '/]*data-il-s=["\']1["\'][^>]*)>(.*?)<\/a>/is', $content, $matches, PREG_SET_ORDER ) ) { foreach ( $matches as $m ) { $attrs = $m[1]; $anchor = wp_strip_all_tags( $m[2] ); $url = ''; if ( preg_match( '/href=["\']([^"\']+)["\']/', $attrs, $href_m ) ) { $url = esc_url_raw( $href_m[1] ); } if ( $url ) { $out_semantic[] = array( 'url' => $url, 'anchor' => $anchor ); } } } // Enrich outgoing links with destination post info foreach ( $out_rules as &$link ) { $dest_id = url_to_postid( $link['url'] ); $link['post_id'] = $dest_id; $link['post_title'] = $dest_id ? get_the_title( $dest_id ) : ''; } unset( $link ); foreach ( $out_semantic as &$link ) { $dest_id = url_to_postid( $link['url'] ); $link['post_id'] = $dest_id; $link['post_title'] = $dest_id ? get_the_title( $dest_id ) : ''; // Add score from semantic DB if available if ( $dest_id && class_exists( 'FLINK_Semantic_DB' ) ) { global $wpdb; $score = $wpdb->get_var( $wpdb->prepare( "SELECT score FROM {$wpdb->prefix}il_semantic_links WHERE source_id = %d AND target_id = %d AND anchor_text != '__analyzed__'", $post_id, $dest_id ) ); $link['score'] = (int) $score; } } unset( $link ); // ── Save to cache ──────────────────────────────────────────── global $wpdb; $table = $wpdb->prefix . self::TABLE; $wpdb->query( $wpdb->prepare( "INSERT INTO {$table} (post_id, post_title, post_url, post_status, post_date, out_rules, out_semantic, out_rules_data, out_semantic_data) VALUES (%d, %s, %s, %s, %s, %d, %d, %s, %s) ON DUPLICATE KEY UPDATE post_title = VALUES(post_title), post_url = VALUES(post_url), post_status = VALUES(post_status), out_rules = VALUES(out_rules), out_semantic = VALUES(out_semantic), out_rules_data = VALUES(out_rules_data), out_semantic_data = VALUES(out_semantic_data), updated_at = NOW()", $post_id, $post->post_title, get_permalink( $post_id ), $post->post_status, $post->post_date, count( $out_rules ), count( $out_semantic ), wp_json_encode( $out_rules ), wp_json_encode( $out_semantic ) ) ); // ── Update INCOMING stats of destination posts ──────────────── // Collect all destination post IDs $dest_ids = array(); foreach ( $out_rules as $link ) { if ( ! empty( $link['post_id'] ) ) $dest_ids[] = (int) $link['post_id']; } foreach ( $out_semantic as $link ) { if ( ! empty( $link['post_id'] ) ) $dest_ids[] = (int) $link['post_id']; } $dest_ids = array_unique( $dest_ids ); foreach ( $dest_ids as $dest_id ) { self::update_incoming( $dest_id ); } } /** * Recalculate INCOMING links for a post by scanning all other posts' content. * This is the inverse lookup: "who links to me?" */ public static function update_incoming( $dest_post_id ) { $dest_post_id = (int) $dest_post_id; $dest_post = get_post( $dest_post_id ); if ( ! $dest_post ) return; $dest_url = get_permalink( $dest_post_id ); $dest_url_trail = trailingslashit( $dest_url ); global $wpdb; $table = $wpdb->prefix . self::TABLE; // Search for posts containing links to this URL $like_il = '%' . $wpdb->esc_like( 'data-il=' ) . '%'; $like_u1 = '%' . $wpdb->esc_like( 'href="' . $dest_url . '"' ) . '%'; $like_u2 = '%' . $wpdb->esc_like( 'href="' . $dest_url_trail . '"' ) . '%'; $posts = $wpdb->get_results( $wpdb->prepare( "SELECT ID, post_title, post_content FROM {$wpdb->posts} WHERE post_status IN ('publish','draft') AND ID != %d AND post_content LIKE %s AND ( post_content LIKE %s OR post_content LIKE %s ) LIMIT 200", $dest_post_id, $like_il, $like_u1, $like_u2 ) ); $in_rules = array(); $in_semantic = array(); foreach ( $posts as $source ) { $source_url = get_permalink( $source->ID ); // ── Rule links: find ALL tags, then check href ── if ( preg_match_all( '/]*data-il=["\']1["\'][^>]*)>(.*?)<\/a>/is', $source->post_content, $matches, PREG_SET_ORDER ) ) { foreach ( $matches as $m ) { if ( preg_match( '/href=["\']([^"\']+)["\']/', $m[1], $href_m ) ) { $href = esc_url_raw( $href_m[1] ); // Check if this href points to our destination (with or without trailing slash) if ( trailingslashit( $href ) === $dest_url_trail || $href === $dest_url ) { $in_rules[] = array( 'source_id' => (int) $source->ID, 'source_title' => $source->post_title, 'source_url' => $source_url, 'anchor' => wp_strip_all_tags( $m[2] ), ); } } } } // ── Semantic links: find ALL tags, then check href ── if ( preg_match_all( '/]*data-il-s=["\']1["\'][^>]*)>(.*?)<\/a>/is', $source->post_content, $matches, PREG_SET_ORDER ) ) { foreach ( $matches as $m ) { if ( preg_match( '/href=["\']([^"\']+)["\']/', $m[1], $href_m ) ) { $href = esc_url_raw( $href_m[1] ); if ( trailingslashit( $href ) === $dest_url_trail || $href === $dest_url ) { $in_semantic[] = array( 'source_id' => (int) $source->ID, 'source_title' => $source->post_title, 'source_url' => $source_url, 'anchor' => wp_strip_all_tags( $m[2] ), ); } } } } } // Enrich semantic incoming with scores foreach ( $in_semantic as &$link ) { if ( class_exists( 'FLINK_Semantic_DB' ) ) { $score = $wpdb->get_var( $wpdb->prepare( "SELECT score FROM {$wpdb->prefix}il_semantic_links WHERE source_id = %d AND target_id = %d AND anchor_text != '__analyzed__'", $link['source_id'], $dest_post_id ) ); $link['score'] = (int) $score; } } unset( $link ); // Save incoming counts $wpdb->query( $wpdb->prepare( "INSERT INTO {$table} (post_id, post_title, post_url, post_status, post_date, in_rules, in_semantic, in_rules_data, in_semantic_data) VALUES (%d, %s, %s, %s, %s, %d, %d, %s, %s) ON DUPLICATE KEY UPDATE post_title = VALUES(post_title), post_url = VALUES(post_url), in_rules = VALUES(in_rules), in_semantic = VALUES(in_semantic), in_rules_data = VALUES(in_rules_data), in_semantic_data = VALUES(in_semantic_data), updated_at = NOW()", $dest_post_id, $dest_post->post_title, get_permalink( $dest_post_id ), $dest_post->post_status, $dest_post->post_date, count( $in_rules ), count( $in_semantic ), wp_json_encode( $in_rules ), wp_json_encode( $in_semantic ) ) ); } // ───────────────────────────────────────────────────────────────── // FULL RECALCULATION (all posts) // ───────────────────────────────────────────────────────────────── /** * Recalculate stats for all published posts. * Called from the admin "Recalcular" button. * Processes in batches to avoid timeout. */ public static function recalculate_batch( $batch_size = 20, $offset = 0 ) { global $wpdb; // On first batch, clean up deleted posts from link_stats table if ( $offset === 0 ) { $stats_table = $wpdb->prefix . self::TABLE; $wpdb->query( "DELETE s FROM {$stats_table} s LEFT JOIN {$wpdb->posts} p ON p.ID = s.post_id WHERE p.ID IS NULL" ); } $origen_tipo = get_option( 'flink_origen_tipo', 'any' ); if ( $origen_tipo !== 'any' ) { $types = array_map( 'sanitize_key', explode( ',', $origen_tipo ) ); } else { $types = array( 'post', 'page' ); } // Build a "%s, %s, ..." placeholder list for the post types so the // values go through wpdb::prepare() instead of being concatenated // straight into the query. $type_placeholders = implode( ', ', array_fill( 0, count( $types ), '%s' ) ); // COUNT query. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $type_placeholders only contains %s tokens; the actual values are bound via prepare(). $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_status IN ('publish','draft') AND post_type IN ({$type_placeholders})", $types ) ); // ID batch query: same placeholders + the LIMIT/OFFSET values. $batch_args = array_merge( $types, array( $batch_size, $offset ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $type_placeholders holds a dynamic count of %s tokens that the static analyser cannot expand; all values (types + limit + offset) are bound via the $batch_args array. $posts = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_status IN ('publish','draft') AND post_type IN ({$type_placeholders}) ORDER BY ID ASC LIMIT %d OFFSET %d", $batch_args ) ); foreach ( $posts as $pid ) { self::update_post( (int) $pid ); } return array( 'processed' => count( $posts ), 'offset' => $offset + count( $posts ), 'total' => $total, 'done' => ( $offset + count( $posts ) >= $total ), ); } // ───────────────────────────────────────────────────────────────── // QUERIES // ───────────────────────────────────────────────────────────────── /** * Get all posts with their link stats for the "Éxitos" table. * Only returns posts that have at least one link (in or out). */ public static function get_all( $orderby = 'total', $order = 'DESC', $search = '' ) { global $wpdb; $table = $wpdb->prefix . self::TABLE; $search_where = ''; if ( $search ) { $search_where = $wpdb->prepare( "AND s.post_title LIKE %s", '%' . $wpdb->esc_like( $search ) . '%' ); } $order_clause = 'ORDER BY (s.out_rules + s.out_semantic + s.in_rules + s.in_semantic) DESC'; if ( $orderby === 'in_total' ) $order_clause = 'ORDER BY (s.in_rules + s.in_semantic) ' . $order; if ( $orderby === 'out_total' ) $order_clause = 'ORDER BY (s.out_rules + s.out_semantic) ' . $order; if ( $orderby === 'date' ) $order_clause = 'ORDER BY s.post_date ' . $order; if ( $orderby === 'title' ) $order_clause = 'ORDER BY s.post_title ' . $order; return $wpdb->get_results( "SELECT s.*, (s.out_rules + s.out_semantic) AS out_total, (s.in_rules + s.in_semantic) AS in_total, (s.out_rules + s.out_semantic + s.in_rules + s.in_semantic) AS link_total FROM {$table} s WHERE (s.out_rules + s.out_semantic + s.in_rules + s.in_semantic) > 0 {$search_where} {$order_clause} LIMIT 500" ); } /** * Get detail data (JSON) for a specific post and link type. */ public static function get_detail( $post_id, $type ) { global $wpdb; $table = $wpdb->prefix . self::TABLE; $column = ''; switch ( $type ) { case 'out_rules': $column = 'out_rules_data'; break; case 'out_semantic': $column = 'out_semantic_data'; break; case 'in_rules': $column = 'in_rules_data'; break; case 'in_semantic': $column = 'in_semantic_data'; break; default: return array(); } $raw = $wpdb->get_var( $wpdb->prepare( "SELECT {$column} FROM {$table} WHERE post_id = %d", $post_id ) ); return json_decode( $raw ?: '[]', true ) ?: array(); } /** * Get summary totals for the header stats. */ public static function get_summary() { global $wpdb; $table = $wpdb->prefix . self::TABLE; return $wpdb->get_row( "SELECT COUNT(*) AS total_posts, SUM(out_rules) AS total_out_rules, SUM(out_semantic) AS total_out_semantic, SUM(in_rules) AS total_in_rules, SUM(in_semantic) AS total_in_semantic, SUM(out_rules + out_semantic + in_rules + in_semantic) AS total_links, SUM(CASE WHEN in_rules + in_semantic = 0 THEN 1 ELSE 0 END) AS orphan_posts FROM {$table} WHERE (out_rules + out_semantic + in_rules + in_semantic) > 0" ); } }