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';
/**
* Whether the semantic-links table exists.
*
* The semantic engine is a premium module, so its table
* (il_semantic_links) is absent in the free edition. Several stats
* queries below join or sub-query that table to count semantic links;
* running those against a missing table makes the whole query fail
* (and the link map shows no rows). This helper lets each query fall
* back to a literal 0 for the semantic part when the table is absent,
* so the link map works in both editions. Result is cached per request.
*/
private static function sem_available() {
static $cache = null;
if ( $cache !== null ) return $cache;
global $wpdb;
$t = $wpdb->prefix . 'il_semantic_links';
$cache = (bool) $wpdb->get_var( $wpdb->prepare( "SHOW TABLES LIKE %s", $t ) );
return $cache;
}
/**
* TEMPORARY DEBUG LOG for the link map.
* Writes to uploads/4linking-map-debug.log when option
* 'flink_map_debug' is '1'. Used to trace why outgoing/incoming
* semantic totals can disagree. Off by default.
*/
private static function dbg_enabled() {
return get_option( 'flink_map_debug', '0' ) === '1';
}
private static function dbg_log( $msg, $ctx = array() ) {
if ( ! self::dbg_enabled() ) return;
$up = wp_upload_dir();
if ( empty( $up['basedir'] ) ) return;
$line = '[' . gmdate( 'H:i:s' ) . '] ' . $msg;
if ( $ctx ) $line .= ' ' . wp_json_encode( $ctx );
@file_put_contents( trailingslashit( $up['basedir'] ) . '4linking-map-debug.log', $line . "\n", FILE_APPEND | LOCK_EX );
}
public static function dbg_reset() {
$up = wp_upload_dir();
if ( empty( $up['basedir'] ) ) return;
@file_put_contents( trailingslashit( $up['basedir'] ) . '4linking-map-debug.log', "=== map debug · " . gmdate( 'Y-m-d H:i:s' ) . " ===\n" );
}
// ─────────────────────────────────────────────────────────────────
// 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;
// Use the canonical scan HTML: Elementor widget HTML for builder
// pages (NOT post_content too — that would double-count), or
// post_content for classic pages.
$content = self::get_scan_html( $post_id );
// ── 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 );
}
}
}
// ── Outgoing SEMANTIC links: read from il_semantic_links ─────
// Semantic links have a single source of truth: the il_semantic_links
// table (one row per A→B link, with score). Counting them by scanning
// HTML caused outgoing/incoming totals to disagree (e.g. 9 vs 18).
// Reading from the table guarantees they reconcile by construction.
$out_semantic = array();
global $wpdb;
$sem_rows = self::sem_available() ? $wpdb->get_results( $wpdb->prepare(
"SELECT target_id, anchor_text, score FROM {$wpdb->prefix}il_semantic_links
WHERE source_id = %d AND anchor_text != '__analyzed__'
AND anchor_text IS NOT NULL AND anchor_text != ''",
$post_id
) ) : array();
foreach ( $sem_rows as $r ) {
$tid = (int) $r->target_id;
$out_semantic[] = array(
'url' => $tid ? get_permalink( $tid ) : '',
'anchor' => $r->anchor_text,
'post_id' => $tid,
'post_title' => $tid ? get_the_title( $tid ) : '',
'score' => (int) $r->score,
);
}
self::dbg_log( 'update_post OUT', array(
'post_id' => $post_id,
'title' => $post->post_title,
'type' => $post->post_type,
'out_rules' => count( $out_rules ),
'out_semantic' => count( $out_semantic ),
) );
// Enrich outgoing RULE 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 );
// ── Save to cache ────────────────────────────────────────────
$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;
// Find candidate source posts. Rule/semantic links can live either in
// post_content (classic) OR in _elementor_data (Elementor builder
// pages). The old query only matched post_content, so incoming links
// from Elementor pages were never counted (e.g. a pillar showing 15
// incoming rule links but the map reporting 0). We therefore gather
// candidates from BOTH sources and scan each one's full renderable
// HTML below.
$like_il = '%' . $wpdb->esc_like( 'data-il' ) . '%';
$like_u1 = '%' . $wpdb->esc_like( 'href="' . $dest_url . '"' ) . '%';
$like_u2 = '%' . $wpdb->esc_like( 'href="' . $dest_url_trail . '"' ) . '%';
// Classic candidates (link + href present in post_content).
$classic = $wpdb->get_results( $wpdb->prepare(
"SELECT ID, post_title 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
) );
// Elementor candidates: any builder page (we scan their widget HTML
// below, since the link/href live escaped inside _elementor_data and
// can't be matched reliably with a LIKE on post_content).
$elementor = $wpdb->get_results( $wpdb->prepare(
"SELECT p.ID, p.post_title FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE pm.meta_key = '_elementor_edit_mode'
AND pm.meta_value = 'builder'
AND p.post_status IN ('publish','draft')
AND p.ID != %d
AND p.post_type NOT IN ('revision','attachment','nav_menu_item')
LIMIT 200",
$dest_post_id
) );
// Merge unique by ID.
$posts = array();
foreach ( array_merge( $classic, $elementor ) as $p ) {
$posts[ (int) $p->ID ] = $p;
}
$in_rules = array();
$in_semantic = array();
$scanned = 0;
$rule_hits_total = 0;
foreach ( $posts as $source ) {
$source_url = get_permalink( $source->ID );
// Canonical scan HTML (no double-count on Elementor pages).
$html = self::get_scan_html( $source->ID );
$scanned++;
// ── Rule links: find ALL tags, then check href ──
if ( preg_match_all(
'/]*data-il=["\']1["\'][^>]*)>(.*?)<\/a>/is',
$html, $matches, PREG_SET_ORDER
) ) {
foreach ( $matches as $m ) {
if ( preg_match( '/href=["\']([^"\']+)["\']/', $m[1], $href_m ) ) {
$href = esc_url_raw( $href_m[1] );
$rule_hits_total++;
// Robust match: compare by RESOLVED post ID (immune to
// http/https, www, trailing slash, query/fragment
// differences), with a string compare as fallback.
$href_id = url_to_postid( $href );
$is_match = ( $href_id && $href_id === $dest_post_id )
|| trailingslashit( $href ) === $dest_url_trail
|| $href === $dest_url;
if ( $is_match ) {
$in_rules[] = array(
'source_id' => (int) $source->ID,
'source_title' => $source->post_title,
'source_url' => $source_url,
'anchor' => wp_strip_all_tags( $m[2] ),
);
}
}
}
}
}
// ── Incoming SEMANTIC links: read from il_semantic_links ─────
// Single source of truth — guarantees the global incoming total
// equals the global outgoing total (every A→B row is one outgoing
// for A and one incoming for B).
$sem_in = self::sem_available() ? $wpdb->get_results( $wpdb->prepare(
"SELECT source_id, anchor_text, score FROM {$wpdb->prefix}il_semantic_links
WHERE target_id = %d AND anchor_text != '__analyzed__'
AND anchor_text IS NOT NULL AND anchor_text != ''",
$dest_post_id
) ) : array();
foreach ( $sem_in as $r ) {
$sid = (int) $r->source_id;
$in_semantic[] = array(
'source_id' => $sid,
'source_title' => $sid ? get_the_title( $sid ) : '',
'source_url' => $sid ? get_permalink( $sid ) : '',
'anchor' => $r->anchor_text,
'score' => (int) $r->score,
);
}
self::dbg_log( 'update_incoming IN', array(
'post_id' => $dest_post_id,
'title' => $dest_post->post_title,
'type' => $dest_post->post_type,
'dest_url' => $dest_url,
'candidates' => $scanned,
'rule_tags_seen' => $rule_hits_total,
'in_rules' => count( $in_rules ),
'in_semantic' => count( $in_semantic ),
) );
// 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 ) {
self::dbg_reset();
self::dbg_log( 'recalculate START' );
$stats_table = $wpdb->prefix . self::TABLE;
// Remove rows whose post no longer exists OR is no longer in a
// visible state (trashed, auto-draft, etc.). Without the status
// check, a page sent to the Trash days ago would keep showing in
// the map because the row lingers in the cache.
$wpdb->query(
"DELETE s FROM {$stats_table} s
LEFT JOIN {$wpdb->posts} p ON p.ID = s.post_id
WHERE p.ID IS NULL
OR p.post_status NOT IN ('publish','draft')"
);
// Pillar pages may be of post_type 'page', which the type filter
// below can exclude — yet they are the most important link
// destinations. Process them explicitly so their incoming links
// (and their row in the map) are always computed, regardless of
// the configured origin type.
$pil_table = $wpdb->prefix . 'il_pilares';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$pil_urls = $wpdb->get_col( "SELECT url FROM {$pil_table}" );
foreach ( (array) $pil_urls as $u ) {
$pid = url_to_postid( $u );
if ( $pid ) {
self::update_post( (int) $pid );
self::update_incoming( (int) $pid );
}
}
}
$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 );
}
$done = ( $offset + count( $posts ) >= $total );
// FINAL CONSISTENCY PASS. update_post() writes a post's outgoing
// counts and refreshes the incoming counts of its destinations. But
// across a batched run the incoming side can lag (a destination's row
// may be touched before all its sources are processed). Once every
// source has been processed, we recompute incoming for every post
// that is a destination of a semantic link or a rule link, so the
// cached in_* columns match the source of truth and the table totals
// reconcile with the summary.
if ( $done ) {
$dest_ids = array();
// Semantic endpoints (real links only) — BOTH sides, so every
// post involved in a semantic link has a row and the table totals
// match the summary exactly. Skipped entirely in the free edition,
// which has no semantic table.
if ( self::sem_available() ) {
$sem_targets = $wpdb->get_col(
"SELECT DISTINCT target_id FROM {$wpdb->prefix}il_semantic_links
WHERE anchor_text != '__analyzed__'
AND anchor_text IS NOT NULL AND anchor_text != ''"
);
foreach ( (array) $sem_targets as $t ) $dest_ids[ (int) $t ] = true;
$sem_sources = $wpdb->get_col(
"SELECT DISTINCT source_id FROM {$wpdb->prefix}il_semantic_links
WHERE anchor_text != '__analyzed__'
AND anchor_text IS NOT NULL AND anchor_text != ''"
);
foreach ( (array) $sem_sources as $sx ) $dest_ids[ (int) $sx ] = true;
}
// Also refresh incoming for every row already in the stats table
// (rule destinations etc.).
$stats_ids = $wpdb->get_col( "SELECT post_id FROM {$wpdb->prefix}" . self::TABLE );
foreach ( (array) $stats_ids as $sid ) $dest_ids[ (int) $sid ] = true;
self::dbg_log( 'final consistency pass', array( 'dest_count' => count( $dest_ids ) ) );
foreach ( array_keys( $dest_ids ) as $did ) {
if ( $did ) {
// Ensure a row exists with base data (out side) AND refresh
// the incoming side, so semantic-only targets show up too.
self::update_post( (int) $did );
self::update_incoming( (int) $did );
}
}
}
return array(
'processed' => count( $posts ),
'offset' => $offset + count( $posts ),
'total' => $total,
'done' => $done,
);
}
// ─────────────────────────────────────────────────────────────────
// 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;
$sem = $wpdb->prefix . 'il_semantic_links';
$search_where = '';
if ( $search ) {
$search_where = $wpdb->prepare( "AND s.post_title LIKE %s", '%' . $wpdb->esc_like( $search ) . '%' );
}
// Semantic counts are computed LIVE from il_semantic_links (the single
// source of truth) via correlated subqueries, NOT from the cached
// out_semantic/in_semantic columns. This guarantees that, row by row,
// every A→B link is counted once as outgoing for A and once as
// incoming for B — so the column totals always reconcile with the
// summary (no more "Sal. semántico 11 vs Ent. semántico 18"). Rule
// counts stay cached (they're scanned from HTML).
$sem_out = "(SELECT COUNT(*) FROM {$sem} z WHERE z.source_id = s.post_id AND z.anchor_text != '__analyzed__' AND z.anchor_text IS NOT NULL AND z.anchor_text != '')";
$sem_in = "(SELECT COUNT(*) FROM {$sem} z WHERE z.target_id = s.post_id AND z.anchor_text != '__analyzed__' AND z.anchor_text IS NOT NULL AND z.anchor_text != '')";
// Free edition has no semantic table: fall back to 0 so the query
// doesn't reference a missing table (which would fail entirely).
if ( ! self::sem_available() ) { $sem_out = '0'; $sem_in = '0'; }
$order_col = "(s.out_rules + {$sem_out} + s.in_rules + {$sem_in})";
$order_clause = "ORDER BY {$order_col} DESC";
if ( $orderby === 'in_total' ) $order_clause = "ORDER BY (s.in_rules + {$sem_in}) " . $order;
if ( $orderby === 'out_total' ) $order_clause = "ORDER BY (s.out_rules + {$sem_out}) " . $order;
if ( $orderby === 'date' ) $order_clause = 'ORDER BY s.post_date ' . $order;
if ( $orderby === 'title' ) $order_clause = 'ORDER BY s.post_title ' . $order;
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- subqueries reference fixed table names and bound-free constants; $search_where is prepared above.
return $wpdb->get_results(
"SELECT s.post_id, s.post_title, s.post_url, s.post_status, s.post_date,
s.out_rules, s.in_rules, s.out_rules_data, s.in_rules_data,
{$sem_out} AS out_semantic,
{$sem_in} AS in_semantic,
p.post_type AS post_type,
(s.out_rules + {$sem_out}) AS out_total,
(s.in_rules + {$sem_in}) AS in_total,
(s.out_rules + {$sem_out} + s.in_rules + {$sem_in}) AS link_total
FROM {$table} s
LEFT JOIN {$wpdb->posts} p ON p.ID = s.post_id
WHERE (s.out_rules + {$sem_out} + s.in_rules + {$sem_in}) > 0
AND p.ID IS NOT NULL
AND p.post_status IN ('publish','draft')
{$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;
$post_id = (int) $post_id;
// Semantic detail comes from il_semantic_links (same source as the
// counters in get_all), so the modal list always matches the cell
// number. Rule detail still comes from the cached JSON columns.
if ( $type === 'out_semantic' || $type === 'in_semantic' ) {
// Free edition has no semantic table: nothing to show.
if ( ! self::sem_available() ) return array();
$sem = $wpdb->prefix . 'il_semantic_links';
if ( $type === 'out_semantic' ) {
$rows = $wpdb->get_results( $wpdb->prepare(
"SELECT target_id AS other_id, anchor_text, score FROM {$sem}
WHERE source_id = %d AND anchor_text != '__analyzed__'
AND anchor_text IS NOT NULL AND anchor_text != ''",
$post_id
) );
} else {
$rows = $wpdb->get_results( $wpdb->prepare(
"SELECT source_id AS other_id, anchor_text, score FROM {$sem}
WHERE target_id = %d AND anchor_text != '__analyzed__'
AND anchor_text IS NOT NULL AND anchor_text != ''",
$post_id
) );
}
$out = array();
foreach ( $rows as $r ) {
$oid = (int) $r->other_id;
if ( $type === 'out_semantic' ) {
$out[] = array(
'post_id' => $oid,
'post_title' => $oid ? get_the_title( $oid ) : '',
'url' => $oid ? get_permalink( $oid ) : '',
'anchor' => $r->anchor_text,
'score' => (int) $r->score,
);
} else {
$out[] = array(
'source_id' => $oid,
'source_title' => $oid ? get_the_title( $oid ) : '',
'source_url' => $oid ? get_permalink( $oid ) : '',
'anchor' => $r->anchor_text,
'score' => (int) $r->score,
);
}
}
return $out;
}
$column = '';
switch ( $type ) {
case 'out_rules': $column = 'out_rules_data'; break;
case 'in_rules': $column = 'in_rules_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();
}
/**
* Concatenate the HTML of every text-editor and html widget found in a
* post's _elementor_data tree. Used by update_post() so link counters
* include links living inside Elementor widgets (not just post_content).
*
* Public so the stats/detail panels (FLINK_Stats, FLINK_Ajax) can count
* against the SAME source the front-end renders, instead of post_content
* (which may hold stale, non-rendered links on Elementor pages).
*/
/**
* Return the HTML to scan for internal links for a given post.
*
* CRITICAL: for Elementor builder pages we return ONLY the Elementor
* widget HTML, never post_content. Elementor keeps a rendered copy of the
* page in post_content as well, so concatenating both counts every link
* TWICE (this is why a pillar with 15 incoming links was reported as 30).
* Classic pages have their content in post_content as usual.
*/
public static function get_scan_html( $post_id ) {
if ( get_post_meta( $post_id, '_elementor_edit_mode', true ) === 'builder' ) {
$el = self::extract_elementor_html( $post_id );
// Fallback: if for some reason the Elementor data yields nothing,
// fall back to post_content so we don't lose links entirely.
return ( trim( $el ) !== '' ) ? $el : (string) get_post_field( 'post_content', $post_id );
}
return (string) get_post_field( 'post_content', $post_id );
}
public static function extract_elementor_html( $post_id ) {
$raw = get_post_meta( $post_id, '_elementor_data', true );
if ( is_string( $raw ) ) {
$data = json_decode( $raw, true );
} elseif ( is_array( $raw ) ) {
$data = $raw;
} else {
$data = null;
}
if ( ! is_array( $data ) ) return '';
$html = '';
self::collect_elementor_widget_html( $data, $html );
return $html;
}
/** Recursively gather text-editor/html widget content from the tree. */
private static function collect_elementor_widget_html( array $elements, &$html ) {
foreach ( $elements as $el ) {
if ( isset( $el['widgetType'] ) ) {
if ( $el['widgetType'] === 'text-editor' && isset( $el['settings']['editor'] ) ) {
$html .= "\n" . $el['settings']['editor'];
} elseif ( $el['widgetType'] === 'html' && isset( $el['settings']['html'] ) ) {
$html .= "\n" . $el['settings']['html'];
}
}
if ( ! empty( $el['elements'] ) && is_array( $el['elements'] ) ) {
self::collect_elementor_widget_html( $el['elements'], $html );
}
}
}
/**
* Get summary totals for the header stats.
*
* Rule links are read from the cache table (they're scanned from HTML).
* Semantic links are read DIRECTLY from il_semantic_links so the outgoing
* total always equals the incoming total — every A→B row contributes one
* outgoing (for A) and one incoming (for B). Mixing sources here is what
* produced "Sal. semántico 11 vs Ent. semántico 18".
*/
public static function get_summary() {
global $wpdb;
$table = $wpdb->prefix . self::TABLE;
// Rule totals + post/orphan counts from the cache. Filter to posts
// that still exist in a visible state, EXACTLY like get_all(), so the
// summary always matches the sum of the table below it. (A stale row
// for a trashed/deleted page was inflating out_rules in the summary
// while the table — which filters — didn't show it.)
$base = $wpdb->get_row(
"SELECT
COUNT(*) AS total_posts,
SUM(s.out_rules) AS total_out_rules,
SUM(s.in_rules) AS total_in_rules,
SUM(CASE WHEN p.post_type = 'page' THEN 1 ELSE 0 END) AS pages_count,
SUM(CASE WHEN p.post_type = 'post' THEN 1 ELSE 0 END) AS posts_count,
SUM(CASE WHEN s.in_rules + s.in_semantic = 0 THEN 1 ELSE 0 END) AS orphan_posts,
SUM(CASE WHEN s.in_rules + s.in_semantic = 0 AND p.post_type = 'page' THEN 1 ELSE 0 END) AS orphan_pages,
SUM(CASE WHEN s.in_rules + s.in_semantic = 0 AND p.post_type = 'post' THEN 1 ELSE 0 END) AS orphan_post_type
FROM {$table} s
INNER JOIN {$wpdb->posts} p ON p.ID = s.post_id
WHERE (s.out_rules + s.out_semantic + s.in_rules + s.in_semantic) > 0
AND p.post_status IN ('publish','draft')"
);
// Semantic total = number of real links in il_semantic_links. This is
// simultaneously the outgoing total AND the incoming total (they're
// equal by definition), so both columns show the same correct number.
// Free edition has no semantic table: total is simply 0.
$sem_total = self::sem_available() ? (int) $wpdb->get_var(
"SELECT COUNT(*) FROM {$wpdb->prefix}il_semantic_links
WHERE anchor_text != '__analyzed__'
AND anchor_text IS NOT NULL AND anchor_text != ''"
) : 0;
$out_rules = (int) ( $base->total_out_rules ?? 0 );
$in_rules = (int) ( $base->total_in_rules ?? 0 );
// Rule links counted ONCE: outgoing == incoming for rules (every rule
// link that leaves a post arrives at its destination). We use the
// outgoing count as the canonical "number of rule links".
$rule_links = $out_rules;
// Build a plain object mirroring the previous shape.
$summary = new \stdClass();
$summary->total_posts = (int) ( $base->total_posts ?? 0 );
$summary->pages_count = (int) ( $base->pages_count ?? 0 );
$summary->posts_count = (int) ( $base->posts_count ?? 0 );
$summary->total_out_rules = $out_rules;
$summary->total_out_semantic = $sem_total;
$summary->total_in_rules = $in_rules;
$summary->total_in_semantic = $sem_total;
$summary->rule_links = $rule_links;
// Total internal links = each link counted ONCE (rule links + semantic
// links). NOT outgoing+incoming, which would double-count every link.
$summary->total_links = $rule_links + $sem_total;
$summary->orphan_posts = (int) ( $base->orphan_posts ?? 0 );
$summary->orphan_pages = (int) ( $base->orphan_pages ?? 0 );
$summary->orphan_post_type = (int) ( $base->orphan_post_type ?? 0 );
self::dbg_log( 'get_summary', array(
'rule_links' => $rule_links,
'sem_total' => $sem_total,
'total_links' => $summary->total_links,
'pages' => $summary->pages_count,
'posts' => $summary->posts_count,
'orphan_posts' => $summary->orphan_posts,
) );
return $summary;
}
}