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_Linker {
private $pilares;
private $reglas;
public function init() {
$this->pilares = new FLINK_Pilares();
$this->reglas = new FLINK_Reglas();
add_action( 'save_post', array( $this, 'process_post' ), 20, 2 );
// Force rule-based linking after content generation (may be draft status)
add_action( 'flink_after_post_created', array( $this, 'force_apply_links' ), 10, 1 );
}
/** Initialise without registering WP hooks — for AJAX batch processing */
public function init_for_batch() {
$this->pilares = new FLINK_Pilares();
$this->reglas = new FLINK_Reglas();
}
/**
* Force apply links to a post regardless of status/caps.
* Called after content generation to ensure all rules are applied.
*/
public function force_apply_links( $post_id ) {
$post = get_post( $post_id );
if ( ! $post ) return;
$content = $this->apply_links( $post->post_content, $post_id );
if ( $content !== $post->post_content ) {
global $wpdb;
$wpdb->update( $wpdb->posts, array( 'post_content' => $content ), array( 'ID' => $post_id ) );
clean_post_cache( $post_id );
if ( class_exists( 'FLINK_Link_Stats' ) ) FLINK_Link_Stats::update_post( $post_id );
}
}
public function process_post( $post_id, $post ) {
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) return;
if ( wp_is_post_revision( $post_id ) ) return;
// Accept publish, future AND draft — content generator creates drafts
if ( ! in_array( $post->post_status, array( 'publish', 'future', 'draft', 'pending' ), true ) ) return;
// During AJAX/cron, skip capability check (the action was already authorized)
if ( ! defined( 'DOING_AJAX' ) && ! defined( 'DOING_CRON' ) ) {
if ( ! current_user_can( 'edit_post', $post_id ) ) return;
}
$content = $post->post_content;
$content = $this->apply_links( $content, $post_id );
if ( $content !== $post->post_content ) {
remove_action( 'save_post', array( $this, 'process_post' ), 20 );
wp_update_post( array( 'ID' => $post_id, 'post_content' => $content ) );
add_action( 'save_post', array( $this, 'process_post' ), 20, 2 );
if ( class_exists( 'FLINK_Link_Stats' ) ) FLINK_Link_Stats::update_post( $post_id );
}
}
public function apply_links( $content, $post_id ) {
// ── CLEAN SLATE ───────────────────────────────────────────────────────────
$content = $this->strip_all_plugin_links( $content );
// ── EARLY EXITS ──────────────────────────────────────────────────────────
// Origen filter: only insert links into post_types the user
// has selected as link sources. Default 'post' protects pillar
// pages from being modified by the rules engine.
$origen_tipo = get_option( 'flink_origen_tipo', 'post' );
if ( $origen_tipo !== 'any' ) {
$tipos = array_map( 'trim', explode( ',', $origen_tipo ) );
if ( ! in_array( get_post_type( $post_id ), $tipos, true ) ) {
return $content;
}
}
if ( FLINK_Exclusiones::is_post_excluded( $post_id ) ) {
return $content;
}
$reglas = $this->reglas->get_active_for_post( $post_id );
$pilares = $this->pilares->get_all( array( 'activo' => 1 ) );
if ( empty( $reglas ) || empty( $pilares ) ) {
return $content;
}
$pilares_map = array();
foreach ( $pilares as $p ) $pilares_map[ (int) $p->id ] = $p;
$max_total = (int) get_option( 'flink_max_enlaces_total', 5 );
$insertados = 0;
$post_lang = get_post_meta( $post_id, '_flink_idioma', true );
if ( empty( $post_lang ) && function_exists( 'pll_get_post_language' ) ) {
$post_lang = pll_get_post_language( $post_id );
}
$current_url = get_permalink( $post_id );
// ── PHASE 1: Resolve all rules into candidates ──────────────────────────
// Each candidate has: keywords, dest_url, dest_nombre, pilar_id_log,
// max_enlaces, prioridad, regla_id
$candidates = array();
foreach ( $reglas as $regla ) {
$variante = null;
$variantes = $this->reglas->get_variantes( $regla );
if ( ! empty( $post_lang ) && ! empty( $variantes ) && isset( $variantes[ $post_lang ] ) ) {
$variante = $variantes[ $post_lang ];
}
// Resolve destination
$pilar = null;
$dest_url = $dest_nombre = '';
$pilar_id_log = null;
if ( $variante ) {
if ( ! empty( $variante['url'] ) ) {
$dest_url = $variante['url'];
$dest_nombre = $dest_url;
} elseif ( ! empty( $variante['pilar_id'] ) && isset( $pilares_map[ (int) $variante['pilar_id'] ] ) ) {
$pilar = $pilares_map[ (int) $variante['pilar_id'] ];
$dest_url = $pilar->url;
$dest_nombre = $pilar->nombre;
$pilar_id_log = $pilar->id;
} else {
continue;
}
} else {
if ( ! empty( $regla->pilar_id ) && isset( $pilares_map[ (int) $regla->pilar_id ] ) ) {
$pilar = $pilares_map[ (int) $regla->pilar_id ];
} elseif ( $regla->destino_tipo === 'pilar' ) {
$pilar = reset( $pilares );
}
if ( ! $pilar ) continue;
$dest_url = ! empty( $regla->destino_valor ) ? $regla->destino_valor : $pilar->url;
$dest_nombre = ! empty( $regla->destino_valor ) ? $regla->destino_valor : $pilar->nombre;
$pilar_id_log = ! empty( $regla->destino_valor ) ? null : $pilar->id;
}
// Skip self-links
if ( trailingslashit( $current_url ) === trailingslashit( $dest_url ) ) continue;
// Resolve keywords
if ( $variante ) {
$kw_regla = clone $regla;
if ( ! empty( $variante['keyword'] ) ) $kw_regla->nombre = $variante['keyword'];
if ( isset( $variante['sinonimos'] ) ) $kw_regla->descripcion = $variante['sinonimos'];
$keywords = $this->get_keywords_for_regla( $kw_regla );
} else {
$keywords = $this->get_keywords_for_regla( $regla );
}
if ( empty( $keywords ) ) continue;
$candidates[] = array(
'regla_id' => $regla->id,
'prioridad' => (int) $regla->prioridad,
'max_enlaces' => (int) $regla->max_enlaces,
'keywords' => $keywords,
'dest_url' => $dest_url,
'dest_nombre' => $dest_nombre,
'pilar_id_log' => $pilar_id_log,
'insertados' => 0,
'kw_index' => 0, // tracks which keyword to try next
);
}
if ( empty( $candidates ) ) return $content;
// ── PHASE 2: Round-robin insertion ───────────────────────────────────────
// Group candidates by priority. Process highest priority group first.
// Within each group, cycle through candidates one link at a time.
// This ensures fair distribution: Galicia(50) + Agua(50) with max_total=2
// → round 1: Galicia gets 1, Agua gets 1 → done.
// Sort by priority DESC (already sorted, but ensure)
usort( $candidates, function( $a, $b ) { return $b['prioridad'] - $a['prioridad']; } );
// Group by priority
$priority_groups = array();
foreach ( $candidates as $idx => $c ) {
$priority_groups[ $c['prioridad'] ][] = $idx;
}
krsort( $priority_groups ); // highest first
foreach ( $priority_groups as $prio => $group_indices ) {
if ( $insertados >= $max_total ) break;
// Round-robin within this priority group
$any_inserted = true;
while ( $any_inserted && $insertados < $max_total ) {
$any_inserted = false;
foreach ( $group_indices as $idx ) {
if ( $insertados >= $max_total ) break;
$c = &$candidates[ $idx ];
if ( $c['insertados'] >= $c['max_enlaces'] ) continue;
if ( $c['kw_index'] >= count( $c['keywords'] ) ) continue;
// Try to insert ONE link for this candidate.
// Try the current keyword first. If it can't insert (no more
// occurrences in text), advance to the next keyword.
$inserted_this_round = false;
while ( $c['kw_index'] < count( $c['keywords'] ) && ! $inserted_this_round ) {
$keyword = $c['keywords'][ $c['kw_index'] ];
$result = $this->insert_link( $content, $keyword, $c['dest_url'], 1 );
if ( $result['inserted'] > 0 ) {
$content = $result['content'];
$insertados++;
$c['insertados']++;
$inserted_this_round = true;
$any_inserted = true;
// Do NOT advance kw_index — same keyword may have
// more occurrences in the text for the next round.
} else {
// This keyword has no more linkable occurrences.
// Move to the next keyword for future rounds.
$c['kw_index']++;
}
}
unset( $c ); // release reference
}
}
}
return $content;
}
// ─────────────────────────────────────────────────────────────
// OPERACIONES DE PROCESADO EN MASA
// ─────────────────────────────────────────────────────────────
/**
* Apply a SINGLE rule to all eligible posts WITHOUT stripping existing links.
*
* Used when a rule is re-activated. Instead of the destructive clean-slate
* approach of apply_links(), this method:
* 1. Loads only the one rule.
* 2. For each post, checks whether the rule's keywords are already linked.
* 3. If not, inserts the link without touching any other tags.
*
* This guarantees that activating rule X never removes links from rule Y.
*/
public function apply_single_regla_to_all_posts( $regla_id ) {
global $wpdb;
// phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Long-running batch operations need to extend the execution window; we set a conservative cap, not unlimited.
@set_time_limit( 300 );
$regla = $this->reglas->get( (int) $regla_id );
if ( ! $regla || ! $regla->activo ) return 0;
$pilares = $this->pilares->get_all( array( 'activo' => 1 ) );
$pilares_map = array();
foreach ( $pilares as $p ) $pilares_map[ (int) $p->id ] = $p;
// Resolve destination for this rule
if ( ! empty( $regla->pilar_id ) && isset( $pilares_map[ (int) $regla->pilar_id ] ) ) {
$pilar = $pilares_map[ (int) $regla->pilar_id ];
} elseif ( $regla->destino_tipo === 'pilar' ) {
$pilar = reset( $pilares );
} else {
$pilar = null;
}
if ( ! $pilar && empty( $regla->destino_valor ) ) return 0;
$dest_url = ! empty( $regla->destino_valor ) ? $regla->destino_valor : $pilar->url;
$dest_nombre = ! empty( $regla->destino_valor ) ? $regla->destino_valor : $pilar->nombre;
$pilar_id_log = $pilar ? $pilar->id : null;
$keywords = $this->get_keywords_for_regla( $regla );
if ( empty( $keywords ) ) return 0;
// Iterate over posts that can be SOURCES of links (origen).
$origen_tipo = get_option( 'flink_origen_tipo', 'post' );
$type_where = $this->get_type_where( $wpdb, $origen_tipo );
$max_total = (int) get_option( 'flink_max_enlaces_total', 5 );
$posts = $wpdb->get_results(
"SELECT ID, post_content FROM {$wpdb->posts}
WHERE post_status = 'publish'
AND post_type NOT IN ('revision','attachment','nav_menu_item')
{$type_where}
ORDER BY ID ASC"
);
$updated = 0;
foreach ( $posts as $post ) {
if ( FLINK_Exclusiones::is_post_excluded( $post->ID ) ) continue;
// Skip if this post IS the destination
$permalink = get_permalink( $post->ID );
if ( trailingslashit( $permalink ) === trailingslashit( $dest_url ) ) continue;
// Language filter
$post_lang = get_post_meta( $post->ID, '_flink_idioma', true );
if ( empty( $post_lang ) && function_exists( 'pll_get_post_language' ) ) {
$post_lang = pll_get_post_language( $post->ID );
}
if ( ! empty( $post_lang ) && ! empty( $regla->idioma ) && $regla->idioma !== $post_lang ) {
continue;
}
// Count how many plugin links this post already has (respect global max)
$existing_count = preg_match_all( '/data-il=["\']1["\']/', $post->post_content );
if ( $existing_count >= $max_total ) continue;
$content = $post->post_content;
$regla_max = (int) $regla->max_enlaces;
$regla_insertados = 0;
$changed = false;
foreach ( $keywords as $keyword ) {
if ( $existing_count + $regla_insertados >= $max_total ) break;
if ( $regla_insertados >= $regla_max ) break;
$remaining = min( $regla_max - $regla_insertados, $max_total - $existing_count - $regla_insertados );
$result = $this->insert_link( $content, $keyword, $dest_url, $remaining );
if ( $result['inserted'] > 0 ) {
$content = $result['content'];
$regla_insertados += $result['inserted'];
$changed = true;
}
}
if ( $changed ) {
$wpdb->update( $wpdb->posts, array( 'post_content' => $content ), array( 'ID' => $post->ID ) );
clean_post_cache( $post->ID );
if ( class_exists( 'FLINK_Link_Stats' ) ) FLINK_Link_Stats::update_post( $post->ID );
$updated++;
}
}
return $updated;
}
/**
* Full reprocess of all eligible posts.
*
* For every post: strip ALL plugin links → reapply ALL active rules
* in priority order (highest first). This is the only correct way to
* handle any rule change (create / update / delete / toggle) because:
*
* - A new rule may apply to posts that already have other rules applied.
* - A modified rule may have changed keyword, URL or priority.
* - A deleted/disabled rule must have its links removed cleanly.
*
* The apply_links() method already does the clean-slate approach,
* so this is just a loop that calls it on every post.
*
* @param int $regla_id Unused — kept for backward compatibility.
*/
public function apply_regla_to_all_posts( $regla_id = 0 ) {
global $wpdb;
// phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Long-running batch operations need to extend the execution window; we set a conservative cap, not unlimited.
@set_time_limit( 300 );
// Iterate over posts that can be SOURCES (origen).
$origen_tipo = get_option( 'flink_origen_tipo', 'post' );
$type_where = $this->get_type_where( $wpdb, $origen_tipo );
$posts = $wpdb->get_results(
"SELECT ID, post_content FROM {$wpdb->posts}
WHERE post_status = 'publish'
AND post_type NOT IN ('revision','attachment','nav_menu_item')
{$type_where}
ORDER BY ID ASC"
);
$updated = 0;
foreach ( $posts as $post ) {
if ( FLINK_Exclusiones::is_post_excluded( $post->ID ) ) continue;
$new_content = $this->apply_links( $post->post_content, $post->ID );
if ( $new_content !== $post->post_content ) {
$wpdb->update( $wpdb->posts,
array( 'post_content' => $new_content ),
array( 'ID' => $post->ID )
);
clean_post_cache( $post->ID );
if ( class_exists( 'FLINK_Link_Stats' ) ) FLINK_Link_Stats::update_post( $post->ID );
$updated++;
}
}
return $updated;
}
/**
* REGLA MODIFICADA: en los posts que tienen enlaces del plugin
* a esta URL, elimina esos enlaces y reaplica TODAS las reglas activas.
* Esto añade keywords nuevas y elimina las suprimidas.
*/
public function reprocess_posts_for_url( $url ) {
global $wpdb;
$url = esc_url_raw( $url );
$url_trail = trailingslashit( $url );
$like_il = '%' . $wpdb->esc_like( 'data-il="1"' ) . '%';
$like1 = '%' . $wpdb->esc_like( 'href="' . $url . '"' ) . '%';
$like2 = '%' . $wpdb->esc_like( 'href="' . $url_trail . '"' ) . '%';
$posts = $wpdb->get_results( $wpdb->prepare(
"SELECT ID, post_content FROM {$wpdb->posts}
WHERE post_status = 'publish'
AND post_type NOT IN ('revision','attachment','nav_menu_item')
AND post_content LIKE %s
AND ( post_content LIKE %s OR post_content LIKE %s )",
$like_il, $like1, $like2
) );
$updated = 0;
foreach ( $posts as $post ) {
$stripped_anchors = $this->extract_plugin_link_anchors( $post->post_content, $url );
// 1. Remove plugin-inserted links to this URL
$clean = $this->strip_links_for_url( $post->post_content, $url );
// 2. Reapply all active rules
$new_content = $this->apply_links( $clean, $post->ID );
if ( $new_content !== $post->post_content ) {
$wpdb->update( $wpdb->posts, array( 'post_content' => $new_content ), array( 'ID' => $post->ID ) );
clean_post_cache( $post->ID );
if ( class_exists( 'FLINK_Link_Stats' ) ) FLINK_Link_Stats::update_post( $post->ID );
$updated++;
}
// Log keywords that were stripped and not reinserted
if ( ! empty( $stripped_anchors ) ) {
$reinserted = $this->extract_plugin_link_anchors( $new_content, $url );
$truly_stripped = array_diff( $stripped_anchors, $reinserted );
// (Log of stripped anchors removed — il_log was deprecated)
}
}
return $updated;
}
/**
* Strip links created by a SPECIFIC rule from all posts.
*
* Identifies links by BOTH the destination URL AND the anchor text
* (keyword/synonyms of the rule). This is critical when multiple rules
* point to the same pilar URL — we must only remove the links whose
* anchor text belongs to THIS rule, leaving other rules' links intact.
*
* e.g. Rules "playa" and "excursiones" both point to the same pilar URL.
* Deactivating "excursiones" must only remove excursiones,
* NOT playa or playas.
*/
public function strip_regla_from_all_posts( $regla_id ) {
global $wpdb;
$regla = $this->reglas->get( $regla_id );
if ( ! $regla ) return 0;
// Build keyword list for this rule
$keywords = $this->get_keywords_for_regla( $regla );
if ( empty( $keywords ) ) return 0;
// Collect destination URLs (main + language variants)
$url_map = array(); // keyword => url (for variant support)
$default_url = '';
if ( ! empty( $regla->destino_valor ) ) {
$default_url = esc_url_raw( $regla->destino_valor );
} elseif ( ! empty( $regla->pilar_id ) ) {
$pilar = $this->pilares->get( (int) $regla->pilar_id );
if ( $pilar ) $default_url = esc_url_raw( $pilar->url );
}
// Language variant URLs (each variant may override URL + keywords)
$variantes = $this->reglas->get_variantes( $regla );
$variant_keywords = array();
foreach ( $variantes as $lang => $v ) {
$v_url = '';
if ( ! empty( $v['url'] ) ) {
$v_url = esc_url_raw( $v['url'] );
} elseif ( ! empty( $v['pilar_id'] ) ) {
$pilar = $this->pilares->get( (int) $v['pilar_id'] );
if ( $pilar ) $v_url = esc_url_raw( $pilar->url );
}
// Collect variant keywords
if ( ! empty( $v['keyword'] ) ) $variant_keywords[] = $v['keyword'];
if ( ! empty( $v['sinonimos'] ) ) {
foreach ( array_filter( array_map( 'trim', explode( "\n", $v['sinonimos'] ) ) ) as $s ) {
$variant_keywords[] = $s;
}
}
}
// All keywords to strip (main rule + all variants)
$all_keywords = array_unique( array_merge( $keywords, $variant_keywords ) );
if ( empty( $all_keywords ) || empty( $default_url ) ) return 0;
// Find posts that have plugin links (data-il="1")
$like_il = '%' . $wpdb->esc_like( 'data-il="1"' ) . '%';
$posts = $wpdb->get_results( $wpdb->prepare(
"SELECT ID, post_content FROM {$wpdb->posts}
WHERE post_status = 'publish'
AND post_type NOT IN ('revision','attachment','nav_menu_item')
AND post_content LIKE %s",
$like_il
) );
$updated = 0;
foreach ( $posts as $post ) {
$content = $post->post_content;
$changed = false;
foreach ( $all_keywords as $keyword ) {
if ( empty( $keyword ) ) continue;
$kw_esc = preg_quote( $keyword, '/' );
// Pattern: KEYWORD
// We match by BOTH the anchor text AND the href to be surgical.
// The href is checked loosely (any il link containing this keyword).
// We use a pattern that captures the full tag and checks the text inside.
$pattern = '/]*data-il=["\']1["\'][^>]*>\s*' . $kw_esc . '\s*<\/a>/iu';
$new_content = preg_replace( $pattern, $keyword, $content );
if ( $new_content !== $content ) {
$content = $new_content;
$changed = true;
}
}
if ( $changed ) {
$wpdb->update( $wpdb->posts, array( 'post_content' => $content ), array( 'ID' => $post->ID ) );
clean_post_cache( $post->ID );
if ( class_exists( 'FLINK_Link_Stats' ) ) FLINK_Link_Stats::update_post( $post->ID );
$updated++;
}
}
return $updated;
}
/**
* Elimina todos los enlaces del plugin () que apuntan a $url.
* Registra en el log los anchors eliminados.
*/
public function strip_links_only( $url ) {
global $wpdb;
$url = esc_url_raw( $url );
$url_trail = trailingslashit( $url );
$like_il = '%' . $wpdb->esc_like( 'data-il="1"' ) . '%';
$like1 = '%' . $wpdb->esc_like( 'href="' . $url . '"' ) . '%';
$like2 = '%' . $wpdb->esc_like( 'href="' . $url_trail . '"' ) . '%';
$posts = $wpdb->get_results( $wpdb->prepare(
"SELECT ID, post_content FROM {$wpdb->posts}
WHERE post_status = 'publish'
AND post_type NOT IN ('revision','attachment','nav_menu_item')
AND post_content LIKE %s
AND ( post_content LIKE %s OR post_content LIKE %s )",
$like_il, $like1, $like2
) );
$updated = 0;
foreach ( $posts as $post ) {
$stripped_anchors = $this->extract_plugin_link_anchors( $post->post_content, $url );
$new_content = $this->strip_links_for_url( $post->post_content, $url );
if ( $new_content !== $post->post_content ) {
$wpdb->update( $wpdb->posts, array( 'post_content' => $new_content ), array( 'ID' => $post->ID ) );
clean_post_cache( $post->ID );
if ( class_exists( 'FLINK_Link_Stats' ) ) FLINK_Link_Stats::update_post( $post->ID );
$updated++;
// (Log of stripped anchors removed — il_log was deprecated)
}
}
return $updated;
}
/**
* Strip ALL plugin-inserted links from content, regardless of destination.
* Builds the pattern from concatenated strings to avoid quote conflicts.
* Returns the cleaned content.
*/
public function strip_all_plugin_links( $content ) {
if ( strpos( $content, 'data-il=' ) === false ) {
return $content;
}
// Match text in any attribute order
$pat = '/]*data-il=["\']1["\'][^>]*>(.*?)<\/a>/is';
return preg_replace( $pat, '$1', $content );
}
/**
* Called when a URL is added to exclusions.
* Finds all posts that either:
* (a) contain a plugin link TO this URL (excluded as destination), or
* (b) ARE this URL (excluded as origin — they have outgoing plugin links)
* Then runs apply_links() on each, which now starts with a clean slate,
* stripping stale links and reinserting only what is currently valid.
*
* Returns the number of posts actually updated.
*/
public function strip_links_for_exclusion( $url ) {
global $wpdb;
$url = esc_url_raw( trim( $url ) );
$url_trail = trailingslashit( $url );
$url_path = wp_parse_url( $url, PHP_URL_PATH );
// Exclusion means: this page will NO LONGER GENERATE outgoing links.
// It can still RECEIVE incoming links from other pages.
// So we only need to find the post whose permalink matches $url
// and strip the outgoing plugin links it contains.
$posts = $wpdb->get_results(
"SELECT ID, post_content FROM {$wpdb->posts}
WHERE post_status = 'publish'
AND post_type NOT IN ('revision','attachment','nav_menu_item')"
);
$updated = 0;
foreach ( $posts as $post ) {
$permalink = esc_url_raw( get_permalink( $post->ID ) );
$permalink_trail = trailingslashit( $permalink );
$permalink_path = wp_parse_url( $permalink, PHP_URL_PATH );
// Only process the post whose URL is being excluded
$is_the_url = ( $permalink === $url || $permalink_trail === $url_trail );
if ( ! $is_the_url && $url_path && $permalink_path ) {
$is_the_url = ( rtrim( $url_path, '/' ) === rtrim( $permalink_path, '/' ) );
}
if ( ! $is_the_url ) continue;
// Strip all outgoing plugin links from this post
$new_content = $this->strip_all_plugin_links( $post->post_content );
if ( $new_content !== $post->post_content ) {
$wpdb->update( $wpdb->posts, array( 'post_content' => $new_content ), array( 'ID' => $post->ID ) );
clean_post_cache( $post->ID );
if ( class_exists( 'FLINK_Link_Stats' ) ) FLINK_Link_Stats::update_post( $post->ID );
$updated++;
}
break; // Only one post can match
}
return $updated;
}
/**
* Elimina del HTML solo los que apuntan a $url.
* Los enlaces manuales del redactor (sin data-il) NO se tocan.
*/
public function strip_links_for_url( $content, $url ) {
$url = esc_url_raw( $url );
$url_trail = trailingslashit( $url );
foreach ( array( $url, $url_trail ) as $u ) {
$u_esc = preg_quote( $u, '/' );
// data-il before href
$content = preg_replace(
'/]*data-il=["\']1["\'][^>]*href=["\']' . $u_esc . '["\'][^>]*>(.*?)<\/a>/is',
'$1',
$content
);
// href before data-il
$content = preg_replace(
'/]*href=["\']' . $u_esc . '["\'][^>]*data-il=["\']1["\'][^>]*>(.*?)<\/a>/is',
'$1',
$content
);
}
return $content;
}
// ─────────────────────────────────────────────────────────────
// HELPERS PRIVADOS
// ─────────────────────────────────────────────────────────────
/** Returns all keywords for a regla: main keyword + synonyms */
private function get_keywords_for_regla( $regla ) {
$keywords = array();
$main_kw = ! empty( $regla->anchor_text ) ? $regla->anchor_text : $regla->nombre;
if ( $main_kw ) $keywords[] = $main_kw;
if ( ! empty( $regla->descripcion ) ) {
$lines = array_filter( array_map( 'trim', explode( "\n", $regla->descripcion ) ) );
foreach ( $lines as $line ) {
if ( $line && ! in_array( $line, $keywords, true ) ) $keywords[] = $line;
}
}
return $keywords;
}
/** Extracts anchor text from plugin-inserted links pointing to $url */
private function extract_plugin_link_anchors( $content, $url ) {
$url = esc_url_raw( $url );
$url_trail = trailingslashit( $url );
$anchors = array();
foreach ( array( $url, $url_trail ) as $u ) {
$u_esc = preg_quote( $u, '/' );
foreach ( array(
'/]*data-il=["\']1["\'][^>]*href=["\']' . $u_esc . '["\'][^>]*>(.*?)<\/a>/is',
'/]*href=["\']' . $u_esc . '["\'][^>]*data-il=["\']1["\'][^>]*>(.*?)<\/a>/is',
) as $pattern ) {
if ( preg_match_all( $pattern, $content, $m ) ) {
foreach ( $m[1] as $html ) {
$clean = trim( wp_strip_all_tags( $html ) );
if ( $clean ) $anchors[] = $clean;
}
}
}
}
return array_unique( $anchors );
}
/** Build SQL type WHERE clause from setting */
private function get_type_where( $wpdb, $origen_tipo ) {
if ( $origen_tipo !== 'any' ) {
$types = array_map( 'trim', explode( ',', $origen_tipo ) );
$placeholders = implode( ',', array_fill( 0, count( $types ), '%s' ) );
return $wpdb->prepare( "AND post_type IN ($placeholders)", $types );
}
return "AND post_type IN ('post','page')";
}
/**
* Inserts a link around $anchor in $content pointing to $url.
* Rules:
* - Only matches whole words (\b boundary): "salud" won't match inside "saludable"
* - Never inserts inside existing tags
* - Never inserts inside