prefix plus a fixed suffix from a private helper (never user input); all runtime values go through $wpdb->prepare() placeholders. class Linkbraid_Ajax_Core { public function __construct() { add_action('wp_ajax_lbraid_details', [$this, 'details']); add_action('wp_ajax_lbraid_undo', [$this, 'undo']); // Index actions if (lbraid_has_pro_code()) add_action('wp_ajax_lbraid_index_post', [$this, 'index_post']); if (lbraid_has_pro_code()) add_action('wp_ajax_lbraid_bulk_index', [$this, 'bulk_index']); // Analysis if (lbraid_has_pro_code()) add_action('wp_ajax_lbraid_analyze_post', [$this, 'analyze_post']); if (lbraid_has_pro_code()) add_action('wp_ajax_lbraid_bulk_analyze', [$this, 'bulk_analyze']); add_action('wp_ajax_lbraid_refresh_links_batch', [$this, 'refresh_links_batch']); // Suggestions add_action('wp_ajax_lbraid_get_suggestions', [$this, 'get_suggestions']); add_action('wp_ajax_lbraid_get_existing_links', [$this, 'get_existing_links']); add_action('wp_ajax_lbraid_accept_suggestion', [$this, 'accept_suggestion']); add_action('wp_ajax_lbraid_reject_suggestion', [$this, 'reject_suggestion']); add_action('wp_ajax_lbraid_bulk_accept', [$this, 'bulk_accept']); add_action('wp_ajax_lbraid_update_suggestion_url', [$this, 'update_suggestion_url']); add_action('wp_ajax_lbraid_update_suggestion_anchor', [$this, 'update_suggestion_anchor']); if (lbraid_has_pro_code()) add_action('wp_ajax_lbraid_suggest_priority_link', [$this, 'suggest_priority_link']); if (lbraid_has_pro_code()) add_action('wp_ajax_lbraid_inbound_analyze', [$this, 'inbound_analyze']); if (lbraid_has_pro_code()) add_action('wp_ajax_lbraid_suggestions_by_ids', [$this, 'suggestions_by_ids']); // Priority add_action('wp_ajax_lbraid_toggle_priority', [$this, 'toggle_priority']); add_action('wp_ajax_lbraid_update_priority_weight', [$this, 'update_priority_weight']); add_action('wp_ajax_lbraid_get_priority_pages', [$this, 'get_priority_pages']); add_action('wp_ajax_lbraid_save_priority_anchors', [$this, 'save_priority_anchors']); if (lbraid_has_pro_code()) add_action('wp_ajax_lbraid_propose_priority_anchors', [$this, 'propose_priority_anchors']); add_action('wp_ajax_lbraid_get_all_posts_for_select', [$this, 'get_all_posts_for_select']); // Map data add_action('wp_ajax_lbraid_get_map_data', [$this, 'get_map_data']); if (lbraid_has_pro_code()) add_action('wp_ajax_lbraid_map_pruning', [$this, 'map_pruning']); if (lbraid_has_pro_code()) add_action('wp_ajax_lbraid_prune_trash', [$this, 'prune_trash']); // Stats add_action('wp_ajax_lbraid_get_stats', [$this, 'get_stats']); add_action('wp_ajax_lbraid_get_health', [$this, 'get_health']); // Reports add_action('wp_ajax_lbraid_report', [$this, 'report']); add_action('wp_ajax_lbraid_report_export', [$this, 'report_export']); add_action('wp_ajax_lbraid_broken_action', [$this, 'broken_action']); add_action('wp_ajax_lbraid_domain_sources', [$this, 'domain_sources']); add_action('wp_ajax_lbraid_clicks_enable', [$this, 'clicks_enable']); add_action('wp_ajax_lbraid_ignore_post', [$this, 'ignore_post']); add_action('wp_ajax_lbraid_change_url', [$this, 'change_url']); // Strands add_action('wp_ajax_lbraid_strands_list', [$this, 'strands_list']); add_action('wp_ajax_lbraid_strands_create', [$this, 'strands_create']); add_action('wp_ajax_lbraid_strands_delete', [$this, 'strands_delete']); add_action('wp_ajax_lbraid_strands_posts', [$this, 'strands_posts']); add_action('wp_ajax_lbraid_strands_add_post', [$this, 'strands_add_post']); add_action('wp_ajax_lbraid_strands_remove_post', [$this, 'strands_remove_post']); add_action('wp_ajax_lbraid_strands_bootstrap', [$this, 'strands_bootstrap']); if (lbraid_has_pro_code()) add_action('wp_ajax_lbraid_strands_bootstrap_pillars', [$this, 'strands_bootstrap_pillars']); // Global history and anchor sources add_action('wp_ajax_lbraid_history_list', [$this, 'history_list']); add_action('wp_ajax_lbraid_anchor_sources', [$this, 'anchor_sources']); // Autobraid add_action('wp_ajax_lbraid_autobraid_rules', [$this, 'autobraid_rules']); add_action('wp_ajax_lbraid_autobraid_save_rule', [$this, 'autobraid_save_rule']); add_action('wp_ajax_lbraid_autobraid_toggle_rule', [$this, 'autobraid_toggle_rule']); add_action('wp_ajax_lbraid_autobraid_delete_rule', [$this, 'autobraid_delete_rule']); add_action('wp_ajax_lbraid_autobraid_apply_all', [$this, 'autobraid_apply_all']); // Clicks (public beacon - no nonce, it only stores a validated event) add_action('wp_ajax_lbraid_track_click', [$this, 'track_click']); add_action('wp_ajax_nopriv_lbraid_track_click', [$this, 'track_click']); // Setup wizard if (lbraid_has_pro_code()) add_action('wp_ajax_lbraid_wizard_save', [$this, 'wizard_save']); if (lbraid_has_pro_code()) add_action('wp_ajax_lbraid_wizard_test_api', [$this, 'wizard_test_api']); if (lbraid_has_pro_code()) add_action('wp_ajax_lbraid_wizard_finish', [$this, 'wizard_finish']); // GSC if (lbraid_has_pro_code()) add_action('wp_ajax_lbraid_gsc_sync', [$this, 'gsc_sync']); if (lbraid_has_pro_code()) add_action('wp_ajax_lbraid_gsc_cannibalization', [$this, 'gsc_cannibalization']); if (lbraid_has_pro_code()) add_action('wp_ajax_lbraid_gsc_cannibalization_export', [$this, 'gsc_cannibalization_export']); if (lbraid_has_pro_code()) add_action('wp_ajax_lbraid_gsc_weak_pages', [$this, 'gsc_weak_pages']); if (lbraid_has_pro_code()) add_action('wp_ajax_lbraid_gsc_zero_pages', [$this, 'gsc_zero_pages']); if (lbraid_has_pro_code()) add_action('wp_ajax_lbraid_gsc_page_queries', [$this, 'gsc_page_queries']); // Debug add_action('wp_ajax_lbraid_clear_debug', [$this, 'clear_debug']); } // Protected: the Pro subclass (class-ajax-pro.php) runs its endpoints through the same gate. protected function verify_nonce() { if (!check_ajax_referer('lbraid_nonce', 'nonce', false)) { wp_send_json_error(__( 'Invalid nonce', 'linkbraid' )); exit; } if (!current_user_can('manage_options')) { wp_send_json_error(__( 'Insufficient permissions', 'linkbraid' )); exit; } } public function refresh_links_batch() { $this->verify_nonce(); $ids = array_slice(array_values(array_filter(array_map('intval', (array) ($_POST['post_ids'] ?? [])))), 0, 30); $done = 0; foreach ($ids as $id) { if (get_post_status($id) === 'publish' && current_user_can('edit_post', $id) && Linkbraid_Database::refresh_existing_links($id)) $done++; } wp_send_json_success(['refreshed' => $done]); } /** "Find sources": the inbound pass for a target post (orphans, priorities). */ public function get_suggestions() { $this->verify_nonce(); $post_id = intval($_POST['post_id'] ?? 0); $suggestions = Linkbraid_Database::get_suggestions_for_post($post_id); wp_send_json_success($suggestions); } public function get_existing_links() { $this->verify_nonce(); global $wpdb; $post_id = intval($_POST['post_id'] ?? 0); $table = $wpdb->prefix . 'lbraid_existing_links'; $links = $wpdb->get_results($wpdb->prepare( "SELECT l.*, p.post_title as target_title FROM $table l LEFT JOIN {$wpdb->posts} p ON l.target_post_id = p.ID WHERE l.source_post_id = %d ORDER BY l.id ASC", $post_id )); // Add URLs foreach ($links as &$l) { $l->target_url = get_permalink($l->target_post_id); } wp_send_json_success($links); } public function accept_suggestion() { $this->verify_nonce(); $suggestion_id = intval($_POST['suggestion_id'] ?? 0); $result = Linkbraid_Link_Manager::apply_suggestion($suggestion_id); if (isset($result['error'])) { wp_send_json_error($result['error']); } else { wp_send_json_success($result); } } public function reject_suggestion() { $this->verify_nonce(); $suggestion_id = intval($_POST['suggestion_id'] ?? 0); if (false === Linkbraid_Link_Manager::reject_suggestion($suggestion_id)) wp_send_json_error(__( 'The suggestion could not be rejected.', 'linkbraid' )); wp_send_json_success(['message' => __( 'Suggestion rejected', 'linkbraid' )]); } public function bulk_accept() { $this->verify_nonce(); $ids = array_slice(array_map('intval', (array) ($_POST['suggestion_ids'] ?? [])), 0, 20); $result = Linkbraid_Link_Manager::apply_bulk($ids); wp_send_json_success($result); } public function update_suggestion_url() { $this->edit_suggestion(true); } public function update_suggestion_anchor() { $this->edit_suggestion(false); } private function edit_suggestion($url) { $this->verify_nonce(); global $wpdb; $id = absint($_POST['suggestion_id'] ?? 0); $table = $wpdb->prefix . 'lbraid_link_suggestions'; $s = $wpdb->get_row($wpdb->prepare("SELECT * FROM $table WHERE id=%d", $id)); if (!$s || $s->status !== 'pending') wp_send_json_error(__( 'The suggestion is not pending.', 'linkbraid' )); $lock = Linkbraid_Jobs::lock('post_' . $s->source_post_id); if (!$lock) wp_send_json_error(__( 'The post is being processed.', 'linkbraid' )); $error = null; try { if ($url) { $new_url = Linkbraid_Content::resolve_url(esc_url_raw(wp_unslash($_POST['custom_url'] ?? ''))); $target = $new_url ? url_to_postid($new_url) : 0; if (!$target) $error = __( 'Enter the URL of a published page on this site.', 'linkbraid' ); $changes = ['custom_url' => $new_url ?: '', 'target_post_id' => $target]; } else { $changes = ['anchor_text' => sanitize_text_field(wp_unslash($_POST['new_anchor'] ?? ''))]; if (isset($_POST['new_sentence'])) $changes['suggested_sentence'] = sanitize_textarea_field(wp_unslash($_POST['new_sentence'])); if (empty($changes['anchor_text']) || mb_strlen($changes['anchor_text']) > 200) $error = __( 'The anchor must be between 1 and 200 characters.', 'linkbraid' ); } foreach ($changes as $k => $v) $s->$k = $v; $plan = $error ? ['error' => $error] : Linkbraid_Link_Manager::plan($s); if (isset($plan['error'])) $error = $plan['error']; elseif (false === $wpdb->update($table, $changes, ['id' => $id, 'status' => 'pending'])) $error = __( 'Save error.', 'linkbraid' ); } finally { Linkbraid_Jobs::unlock('post_' . $s->source_post_id, $lock); } if ($error) wp_send_json_error($error); wp_send_json_success(['message' => __( 'Saved.', 'linkbraid' )]); } public function details() { $this->verify_nonce(); global $wpdb; $id = absint($_POST['post_id'] ?? 0); if (!current_user_can('edit_post', $id)) wp_send_json_error(__( 'Insufficient permissions.', 'linkbraid' )); Linkbraid_Database::refresh_existing_links($id); $suggestions = Linkbraid_Database::get_suggestions_for_post($id); foreach ($suggestions as $s) { $s->anchor_match = Linkbraid_Anchors::matches($s->anchor_text, Linkbraid_Anchors::get($s->target_post_id)['anchors']) ? 'exact' : 'other'; $plan = Linkbraid_Link_Manager::plan($s); $allowed = ['p' => [], 'br' => [], 'strong' => [], 'em' => [], 'b' => [], 'i' => [], 'ul' => [], 'ol' => [], 'li' => [], 'span' => [], 'a' => ['href' => true]]; $s->preview = isset($plan['error']) ? ['error' => $plan['error']] : ['before' => wp_kses($plan['before'], $allowed), 'after' => wp_kses($plan['after'], $allowed), 'location' => $plan['location']]; } $history = []; $rows = $wpdb->get_results($wpdb->prepare("SELECT meta_id, meta_value FROM {$wpdb->postmeta} WHERE post_id=%d AND meta_key='_lbraid_link_history' ORDER BY meta_id DESC LIMIT 30", $id)); foreach ($rows as $row) { $h = maybe_unserialize($row->meta_value); $history[] = ['id' => (int) $row->meta_id, 'date' => $h['date'], 'state' => $h['state'], 'suggestion_id' => $h['suggestion_id'], 'can_undo' => $h['state'] === 'applied' && hash_equals($h['after_hash'], Linkbraid_Content::hash($id))]; } $links = $wpdb->get_results($wpdb->prepare("SELECT * FROM {$wpdb->prefix}lbraid_existing_links WHERE source_post_id=%d", $id)); foreach ($links as $l) { $l->url = get_permalink($l->target_post_id); $l->title = get_the_title($l->target_post_id); } wp_send_json_success([ 'suggestions' => $suggestions, 'links' => $links, 'history' => $history, 'candidates' => get_post_meta($id, '_lbraid_priority_candidates', true) ?: [], 'counts' => [ 'incoming' => Linkbraid_Database::get_link_count($id, 'incoming'), 'outgoing' => Linkbraid_Database::get_link_count($id, 'outgoing'), ], 'scanned' => (bool) get_post_meta($id, '_lbraid_links_scanned', true), ]); } public function undo() { $this->verify_nonce(); $result = Linkbraid_Link_Manager::undo(absint($_POST['post_id'] ?? 0), absint($_POST['history_id'] ?? 0)); if (isset($result['error'])) wp_send_json_error($result['error']); wp_send_json_success($result); } public function toggle_priority() { $this->verify_nonce(); global $wpdb; $post_id = intval($_POST['post_id'] ?? 0); $is_priority = intval($_POST['is_priority'] ?? 0); $table = $wpdb->prefix . 'lbraid_post_index'; $post = get_post($post_id); if (!$post || $post->post_status !== 'publish' || !current_user_can('edit_post', $post_id) || !in_array($post->post_type, lbraid_post_types(), true)) wp_send_json_error(__( 'Invalid post.', 'linkbraid' )); $data = ['post_id' => $post_id, 'is_priority' => $is_priority ? 1 : 0, 'title' => $post->post_title, 'url' => get_permalink($post_id)]; if (false === Linkbraid_Database::upsert_post_index($data)) wp_send_json_error(__( 'The priority could not be saved.', 'linkbraid' )); wp_send_json_success(['message' => __( 'Priority updated', 'linkbraid' )]); } public function update_priority_weight() { $this->verify_nonce(); global $wpdb; $post_id = intval($_POST['post_id'] ?? 0); $weight = intval($_POST['weight'] ?? 5); $weight = max(1, min(10, $weight)); $table = $wpdb->prefix . 'lbraid_post_index'; if (!Linkbraid_Database::get_post_index($post_id)) wp_send_json_error(__( 'Add the page to the priority pages first.', 'linkbraid' )); if (false === $wpdb->update($table, ['priority_weight' => $weight], ['post_id' => $post_id])) wp_send_json_error(__( 'Error saving the weight.', 'linkbraid' )); wp_send_json_success(['message' => __( 'Priority weight updated', 'linkbraid' )]); } public function get_priority_pages() { $this->verify_nonce(); $priority = Linkbraid_Database::get_priority_posts(); $result = []; $owners = []; foreach ($priority as $p) foreach (Linkbraid_Anchors::get($p->post_id)['anchors'] as $anchor) $owners[Linkbraid_Anchors::normalize($anchor)][$p->post_id] = html_entity_decode(get_the_title($p->post_id), ENT_QUOTES | ENT_HTML5, 'UTF-8'); foreach ($priority as $p) { $post = get_post($p->post_id); $config = Linkbraid_Anchors::get($p->post_id); $conflicts = []; foreach ($config['anchors'] as $anchor) { $other = $owners[Linkbraid_Anchors::normalize($anchor)]; unset($other[$p->post_id]); if ($other) $conflicts[] = ['anchor' => $anchor, 'targets' => array_values($other)]; } $result[] = [ 'post_id' => $p->post_id, 'title' => html_entity_decode(get_the_title($p->post_id), ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'url' => get_permalink($p->post_id), 'type' => $post ? $post->post_type : 'unknown', 'priority_weight' => $p->priority_weight, 'incoming_links' => Linkbraid_Database::get_link_count($p->post_id, 'incoming'), 'anchor_config' => $config, 'anchor_report' => Linkbraid_Anchors::incoming($p->post_id), 'anchor_conflicts'=> $conflicts, ]; } wp_send_json_success($result); } public function save_priority_anchors() { $this->verify_nonce(); $result = Linkbraid_Anchors::save(absint($_POST['post_id'] ?? 0), sanitize_textarea_field(wp_unslash($_POST['anchors'] ?? '')), sanitize_key($_POST['mode'] ?? ''), sanitize_text_field(wp_unslash($_POST['expected_hash'] ?? ''))); if (is_wp_error($result)) wp_send_json_error($result->get_error_message()); wp_send_json_success($result); } public function get_all_posts_for_select() { $this->verify_nonce(); // The priority picker hides pages that are already priorities. Every // other consumer (Strands, Autobraid) needs them MOST of all, so the // exclusion only applies without include_priority. $priority_ids = []; if (empty($_POST['include_priority'])) { foreach (Linkbraid_Database::get_priority_posts() as $p) $priority_ids[] = $p->post_id; } $args = [ 'post_type' => lbraid_post_types(), 'post_status' => 'publish', 'posts_per_page' => -1, 'orderby' => 'title', 'order' => 'ASC', ]; if (!empty($priority_ids)) { $args['post__not_in'] = $priority_ids; } $posts = get_posts($args); $result = []; foreach ($posts as $p) { $result[] = [ 'id' => $p->ID, 'title' => $p->post_title, 'type' => $p->post_type, 'url' => get_permalink($p->ID), ]; } wp_send_json_success($result); } public function get_map_data() { $this->verify_nonce(); global $wpdb; $links_table = $wpdb->prefix . 'lbraid_existing_links'; // Two grouped queries instead of two COUNT(*) queries per node — the old // loop was the main reason the map crawled on sites with many posts. $out_counts = []; $in_counts = []; foreach ($wpdb->get_results("SELECT source_post_id id, COUNT(*) c FROM $links_table GROUP BY source_post_id") as $r) $out_counts[(int) $r->id] = (int) $r->c; foreach ($wpdb->get_results("SELECT target_post_id id, COUNT(*) c FROM $links_table WHERE target_post_id > 0 GROUP BY target_post_id") as $r) $in_counts[(int) $r->id] = (int) $r->c; $nodes = []; $node_ids = []; $strand_map = Linkbraid_Strands::node_map(); // Semantic layer: Site Focus / Radius and each node's alignment with the core. $focus = lbraid_has_pro_code() ? Linkbraid_Embeddings::site_focus() : ['count' => 0, 'focus' => 0, 'radius' => 0, 'per_post' => [], 'strands' => []]; foreach (Linkbraid_Database::get_all_indexed_posts() as $p) { $incoming = $in_counts[(int) $p->post_id] ?? 0; $nodes[] = [ 'id' => (int) $p->post_id, 'label' => mb_substr($p->title, 0, 40), 'title' => $p->title, 'url' => $p->url, 'is_priority'=> (bool) $p->is_priority, 'outgoing' => $out_counts[(int) $p->post_id] ?? 0, 'incoming' => $incoming, 'is_orphan' => ($incoming === 0), 'strand' => $strand_map[(int) $p->post_id] ?? 0, 'align' => $focus['per_post'][(int) $p->post_id] ?? null, ]; $node_ids[(int) $p->post_id] = true; } // Term-archive targets (categories) join the graph in their own id space. $term_ids = []; foreach ($wpdb->get_results("SELECT target_term_id id, COUNT(*) c FROM $links_table WHERE target_term_id > 0 GROUP BY target_term_id") as $r) { $term = Linkbraid_Terms::get((int) $r->id, false); // linked archives stay on the map even when they are not AI targets if (!$term) continue; $nodes[] = [ 'id' => 't' . (int) $r->id, 'label' => mb_substr($term['name'], 0, 40), 'title' => $term['name'], 'url' => $term['url'], 'is_priority'=> false, 'outgoing' => 0, 'incoming' => (int) $r->c, 'is_orphan' => false, 'is_term' => true, 'strand' => 0, ]; $term_ids[(int) $r->id] = true; } // Aggregated edges: several links between the same pair become one weighted line. $edges = []; foreach ($wpdb->get_results("SELECT source_post_id s, target_post_id t, target_term_id tt, COUNT(*) c FROM $links_table GROUP BY source_post_id, target_post_id, target_term_id") as $l) { if (!isset($node_ids[(int) $l->s])) continue; if ((int) $l->tt > 0) { if (!isset($term_ids[(int) $l->tt])) continue; $target = 't' . (int) $l->tt; } elseif (isset($node_ids[(int) $l->t])) { $target = (int) $l->t; } else { continue; } $edges[] = ['source' => (int) $l->s, 'target' => $target, 'weight' => (int) $l->c]; } $strands = $wpdb->get_results("SELECT id, name FROM {$wpdb->prefix}lbraid_strands ORDER BY name ASC", ARRAY_A); wp_send_json_success([ 'nodes' => $nodes, 'edges' => $edges, 'strands' => array_map(function ($s) { return ['id' => (int) $s['id'], 'name' => $s['name']]; }, $strands ?: []), 'focus' => [ 'count' => (int) $focus['count'], 'focus' => (int) $focus['focus'], 'radius' => (int) $focus['radius'], 'strands' => $focus['strands'], ], ]); } /** Content drifting furthest from the topical core, with the signals a pruning decision needs. */ public function get_stats() { $this->verify_nonce(); global $wpdb; $index_table = $wpdb->prefix . 'lbraid_post_index'; $links_table = $wpdb->prefix . 'lbraid_existing_links'; $suggestions_table = $wpdb->prefix . 'lbraid_link_suggestions'; $total_posts = 0; foreach (lbraid_post_types() as $type) $total_posts += (int) wp_count_posts($type)->publish; $indexed = $wpdb->get_var("SELECT COUNT(*) FROM $index_table WHERE embedding IS NOT NULL"); $total_links = $wpdb->get_var("SELECT COUNT(*) FROM $links_table"); $pending = $wpdb->get_var("SELECT COUNT(*) FROM $suggestions_table WHERE status = 'pending'"); $accepted = $wpdb->get_var("SELECT COUNT(*) FROM $suggestions_table WHERE status = 'accepted'"); $priority = $wpdb->get_var("SELECT COUNT(*) FROM $index_table WHERE is_priority = 1"); $orphans = count(Linkbraid_Database::get_orphan_posts()); wp_send_json_success([ 'total_posts' => $total_posts, 'indexed_posts' => $indexed, 'total_links' => $total_links, 'pending' => $pending, 'accepted' => $accepted, 'priority' => $priority, 'orphan_posts' => $orphans, 'gsc_connected' => lbraid_has_pro_code() && (new Linkbraid_GSC())->is_connected(), 'gsc_last_sync' => get_option('lbraid_gsc_last_sync', ''), ]); } public function get_health() { $this->verify_nonce(); Linkbraid_Health::maybe_snapshot(); wp_send_json_success(Linkbraid_Health::report()); } // ================== // Reports (server-side pagination) // ================== /** WHERE clause for published content of the enabled types. Returns [sql, params]. */ private function report_types_where() { $types = lbraid_post_types(); return ['p.post_status=\'publish\' AND p.post_type IN (' . implode(',', array_fill(0, count($types), '%s')) . ')', $types]; } /** Builds the queries for a report: [select, from_where, params, order]. */ /** Shared stoplist; see Linkbraid_Anchors::generic(). */ private function generic_anchors() { return Linkbraid_Anchors::generic(); } /** The post the domain-report drill-down opens: where exactly a domain is linked from. */ public function domain_sources() { $this->verify_nonce(); global $wpdb; $host = sanitize_text_field(wp_unslash($_POST['host'] ?? '')); if ($host === '') wp_send_json_error(__( 'Unknown report.', 'linkbraid' )); $rows = $wpdb->get_results($wpdb->prepare( "SELECT e.source_post_id, e.url, e.anchor_text, p.post_title FROM {$wpdb->prefix}lbraid_external_links e INNER JOIN {$wpdb->posts} p ON p.ID = e.source_post_id WHERE e.host = %s AND p.post_status = 'publish' ORDER BY p.post_title ASC, e.id ASC LIMIT 100", $host), ARRAY_A); foreach ($rows as &$row) $row['edit'] = get_edit_post_link($row['source_post_id'], 'raw'); unset($row); wp_send_json_success(['rows' => $rows]); } /** URL changer: swaps every content link from one address to another (history + undo per post). */ public function change_url() { $this->verify_nonce(); $result = Linkbraid_Link_Manager::change_url( sanitize_text_field(wp_unslash($_POST['old_url'] ?? '')), sanitize_text_field(wp_unslash($_POST['new_url'] ?? '')) ); isset($result['error']) ? wp_send_json_error($result['error']) : wp_send_json_success($result); } /** Quick add to the ignore list from the Loose Ends report (removal lives in the settings). */ public function ignore_post() { $this->verify_nonce(); $post_id = absint($_POST['post_id'] ?? 0); if (!$post_id || !get_post($post_id)) wp_send_json_error(__( 'This post is not available for analysis.', 'linkbraid' )); $ignored = lbraid_ignored_posts(); if (!in_array($post_id, $ignored, true)) $ignored[] = $post_id; update_option('lbraid_ignored_posts', implode(',', $ignored)); wp_send_json_success(); } /** One-click opt-in from the Clicks report (turning it off lives in the settings). */ public function clicks_enable() { $this->verify_nonce(); update_option('lbraid_click_tracking', 1); wp_send_json_success(); } private function report_query($report) { global $wpdb; list($where, $params) = $this->report_types_where(); $links = $wpdb->prefix . 'lbraid_existing_links'; $ext = $wpdb->prefix . 'lbraid_external_links'; $status = $wpdb->prefix . 'lbraid_url_status'; $wc = "(LENGTH(TRIM(l.anchor_text)) - LENGTH(REPLACE(TRIM(l.anchor_text), ' ', '')) + 1)"; switch ($report) { case 'orphans': $ignored = lbraid_ignored_posts(); $ignored_sql = $ignored ? ' AND p.ID NOT IN (' . implode(',', array_fill(0, count($ignored), '%d')) . ')' : ''; return [ "SELECT p.ID, p.post_title, p.post_type, p.post_date, COALESCE(o.c,0) AS outgoing", "FROM {$wpdb->posts} p JOIN {$wpdb->postmeta} m ON m.post_id=p.ID AND m.meta_key='_lbraid_links_scanned' LEFT JOIN (SELECT DISTINCT target_post_id FROM $links) inc ON inc.target_post_id=p.ID LEFT JOIN (SELECT source_post_id, COUNT(*) c FROM $links GROUP BY source_post_id) o ON o.source_post_id=p.ID WHERE $where AND inc.target_post_id IS NULL$ignored_sql", array_merge($params, $ignored), 'ORDER BY p.post_date DESC, p.ID DESC', null, ]; case 'term_orphans': $taxonomies = Linkbraid_Terms::taxonomies(); $tax_ph = implode(',', array_fill(0, max(1, count($taxonomies)), '%s')); return [ "SELECT t.term_id, t.name, tt.taxonomy, tt.count AS posts_in_term", "FROM {$wpdb->terms} t JOIN {$wpdb->term_taxonomy} tt ON tt.term_id=t.term_id LEFT JOIN (SELECT target_term_id, COUNT(*) c FROM $links WHERE target_term_id>0 GROUP BY target_term_id) n ON n.target_term_id=t.term_id WHERE tt.taxonomy IN ($tax_ph) AND COALESCE(n.c,0)=0", $taxonomies ?: [''], 'ORDER BY tt.count DESC, t.name ASC', null, ]; case 'coverage': return [ "SELECT p.ID, p.post_title, p.post_type, COALESCE(o.c,0) AS outgoing, COALESCE(n.c,0) AS incoming", "FROM {$wpdb->posts} p JOIN {$wpdb->postmeta} m ON m.post_id=p.ID AND m.meta_key='_lbraid_links_scanned' LEFT JOIN (SELECT source_post_id, COUNT(*) c FROM $links GROUP BY source_post_id) o ON o.source_post_id=p.ID LEFT JOIN (SELECT target_post_id, COUNT(*) c FROM $links GROUP BY target_post_id) n ON n.target_post_id=p.ID WHERE $where AND COALESCE(o.c,0) < 3", $params, 'ORDER BY outgoing ASC, p.post_date DESC', null, ]; case 'anchors': // Anchor LENGTH is not the problem — generic phrases, raw URLs // and unnaturally long anchors are. $generic = $this->generic_anchors(); $generic_ph = implode(',', array_fill(0, count($generic), '%s')); return [ "SELECT l.id, l.anchor_text, l.source_post_id, l.target_post_id, $wc AS wc, sp.post_title AS source_title, tp.post_title AS target_title", "FROM $links l LEFT JOIN {$wpdb->posts} sp ON sp.ID=l.source_post_id LEFT JOIN {$wpdb->posts} tp ON tp.ID=l.target_post_id WHERE TRIM(l.anchor_text) <> '' AND ( LOWER(TRIM(l.anchor_text)) IN ($generic_ph) OR $wc > 8 OR LOWER(TRIM(l.anchor_text)) LIKE 'http%' OR LOWER(TRIM(l.anchor_text)) LIKE 'www.%' )", $generic, "ORDER BY wc DESC, l.id DESC", null, ]; case 'domains': return [ "SELECT e.host, COUNT(*) AS links, COUNT(DISTINCT e.source_post_id) AS sources, COALESCE(MAX(b.broken),0) AS broken", "FROM $ext e LEFT JOIN (SELECT host, COUNT(*) broken FROM $status WHERE check_count>0 AND ok=0 AND ignored=0 GROUP BY host) b ON b.host=e.host WHERE e.host <> '' GROUP BY e.host", [], 'ORDER BY links DESC, e.host ASC', ["SELECT COUNT(DISTINCT e.host) FROM $ext e WHERE e.host <> ''", []], ]; case 'clicks': $clicks = $wpdb->prefix . 'lbraid_click_data'; return [ "SELECT c.target_post_id AS ID, p.post_title, p.post_type, COUNT(*) AS clicks, COUNT(DISTINCT c.source_post_id) AS sources, MAX(c.clicked_at) AS last_click", "FROM $clicks c LEFT JOIN {$wpdb->posts} p ON p.ID = c.target_post_id WHERE 1=1 GROUP BY c.target_post_id, p.post_title, p.post_type", [], 'ORDER BY clicks DESC, ID ASC', ["SELECT COUNT(DISTINCT target_post_id) FROM $clicks", []], ]; case 'redirects': return [ "SELECT s.id, s.url, s.host, s.redirect_code, s.redirect_url, s.last_checked, (SELECT COUNT(*) FROM $ext e WHERE e.url=s.url) AS uses, (SELECT e2.source_post_id FROM $ext e2 WHERE e2.url=s.url ORDER BY e2.id ASC LIMIT 1) AS sample_post", "FROM $status s WHERE s.redirect_code > 0 AND s.ignored = 0 AND (s.snoozed_until IS NULL OR s.snoozed_until < %s) AND EXISTS (SELECT 1 FROM $ext e3 WHERE e3.url = s.url)", [current_time('Y-m-d')], 'ORDER BY uses DESC, s.host ASC, s.id ASC', null, ]; case 'broken': return [ "SELECT s.id, s.url, s.host, s.status_code, s.error_message, s.last_checked, s.check_count, (SELECT COUNT(*) FROM $ext e WHERE e.url=s.url) AS uses, (SELECT e2.source_post_id FROM $ext e2 WHERE e2.url=s.url ORDER BY e2.id ASC LIMIT 1) AS sample_post", "FROM $status s WHERE s.check_count>0 AND s.ok=0 AND s.ignored=0 AND (s.snoozed_until IS NULL OR s.snoozed_until < %s)", [current_time('Y-m-d')], 'ORDER BY s.status_code DESC, s.host ASC, s.id ASC', null, ]; } return null; } /** Every report table sorts; the per-report whitelist is the contract. */ private function report_sort($report, $order) { $sortable = [ 'orphans' => ['title' => 'p.post_title', 'outgoing' => 'outgoing', 'date' => 'p.post_date'], 'term_orphans' => ['name' => 't.name', 'taxonomy' => 'tt.taxonomy', 'count' => 'posts_in_term'], 'coverage' => ['title' => 'p.post_title', 'outgoing' => 'outgoing', 'incoming' => 'incoming'], 'anchors' => ['anchor' => 'l.anchor_text', 'wc' => 'wc', 'source' => 'sp.post_title', 'target' => 'tp.post_title'], 'domains' => ['host' => 'e.host', 'links' => 'links', 'sources' => 'sources', 'broken' => 'broken'], 'broken' => ['url' => 's.url', 'code' => 's.status_code', 'uses' => 'uses', 'checked' => 's.last_checked'], 'redirects' => ['url' => 's.url', 'code' => 's.redirect_code', 'uses' => 'uses', 'checked' => 's.last_checked'], 'clicks' => ['title' => 'p.post_title', 'clicks' => 'clicks', 'sources' => 'sources', 'last' => 'last_click'], ]; $sort = sanitize_key($_REQUEST['sort'] ?? ''); if ($sort && isset($sortable[$report][$sort])) { $dir = sanitize_key($_REQUEST['dir'] ?? '') === 'desc' ? 'DESC' : 'ASC'; return 'ORDER BY ' . $sortable[$report][$sort] . " $dir"; } return $order; } public function report() { $this->verify_nonce(); global $wpdb; $report = sanitize_key($_POST['report'] ?? ''); $query = $this->report_query($report); if (!$query) wp_send_json_error(__( 'Unknown report.', 'linkbraid' )); list($select, $from_where, $params, $order, $count) = $query; $order = $this->report_sort($report, $order); $page = max(1, absint($_POST['page'] ?? 1)); $size = max(1, min(100, absint($_POST['per_page'] ?? 25))); // COUNT(*) over the same FROM/WHERE by default; GROUP BY reports supply their own counting query. list($total_sql, $total_params) = $count ?: ["SELECT COUNT(*) $from_where", $params]; $total = (int) ($total_params ? $wpdb->get_var($wpdb->prepare($total_sql, ...$total_params)) : $wpdb->get_var($total_sql)); $sql = "$select $from_where $order LIMIT %d OFFSET %d"; $args = array_merge($params, [$size, ($page - 1) * $size]); $rows = $wpdb->get_results($wpdb->prepare($sql, ...$args), ARRAY_A); foreach ($rows as &$row) { if (isset($row['ID'])) { $row['url'] = get_permalink($row['ID']); $row['edit'] = get_edit_post_link($row['ID'], 'raw'); } if (isset($row['source_post_id'])) $row['source_edit'] = get_edit_post_link($row['source_post_id'], 'raw'); if (!empty($row['sample_post'])) { $row['sample_title'] = get_the_title($row['sample_post']); $row['sample_edit'] = get_edit_post_link($row['sample_post'], 'raw'); } if ($report === 'term_orphans' && isset($row['term_id'])) { $term_link = get_term_link((int) $row['term_id']); $row['url'] = is_wp_error($term_link) ? '' : $term_link; $taxonomy = get_taxonomy($row['taxonomy']); $row['taxonomy_label'] = $taxonomy ? $taxonomy->labels->singular_name : $row['taxonomy']; } if ($report === 'anchors' && isset($row['anchor_text'])) { $anchor = mb_strtolower(trim($row['anchor_text'])); $row['issue'] = (strpos($anchor, 'http') === 0 || strpos($anchor, 'www.') === 0) ? 'url' : (((int) $row['wc']) > 8 ? 'long' : 'generic'); } } unset($row); $summary = null; if ($report === 'anchors') { $links = $wpdb->prefix . 'lbraid_existing_links'; $wc = "(LENGTH(TRIM(anchor_text)) - LENGTH(REPLACE(TRIM(anchor_text), ' ', '')) + 1)"; $generic = $this->generic_anchors(); $generic_ph = implode(',', array_fill(0, count($generic), '%s')); $summary = $wpdb->get_row($wpdb->prepare("SELECT COUNT(*) total, SUM(CASE WHEN LOWER(TRIM(anchor_text)) IN ($generic_ph) THEN 1 ELSE 0 END) generic_cnt, SUM(CASE WHEN LOWER(TRIM(anchor_text)) LIKE %s OR LOWER(TRIM(anchor_text)) LIKE %s THEN 1 ELSE 0 END) url_cnt, SUM(CASE WHEN $wc > 8 THEN 1 ELSE 0 END) long_cnt FROM $links WHERE TRIM(anchor_text) <> ''", ...array_merge($generic, ['http%', 'www.%'])), ARRAY_A); } elseif ($report === 'broken') { $summary = ['last_scan' => $wpdb->get_var('SELECT MAX(last_checked) FROM ' . $wpdb->prefix . 'lbraid_url_status')]; } elseif ($report === 'clicks') { $summary = ['enabled' => Linkbraid_Clicks::enabled(), 'series' => Linkbraid_Clicks::series(30), 'total30' => Linkbraid_Clicks::total(30)]; } wp_send_json_success(['rows' => $rows, 'total' => $total, 'total_pages' => (int) ceil($total / $size), 'summary' => $summary]); } /** * One RFC-4180 CSV line: fields with delimiters, quotes or line breaks are * quoted and inner quotes doubled. Output is a CSV byte stream, not HTML, * so the esc_*() family does not apply — this IS the escaping step. */ private static function csv_line($fields) { $cells = []; foreach ($fields as $field) { $field = (string) $field; $cells[] = preg_match('/[",\r\n]/', $field) ? '"' . str_replace('"', '""', $field) . '"' : $field; } return implode(',', $cells) . "\n"; } /** Streaming CSV export of a report (batches of 500 rows, no libraries). */ public function report_export() { $this->verify_nonce(); global $wpdb; $report = sanitize_key($_REQUEST['report'] ?? ''); $query = $this->report_query($report); if (!$query) wp_die(esc_html__( 'Unknown report.', 'linkbraid' )); list($select, $from_where, $params, $order) = $query; $order = $this->report_sort($report, $order); nocache_headers(); header('Content-Type: text/csv; charset=UTF-8'); header('Content-Disposition: attachment; filename=linkbraid-' . $report . '-' . current_time('Y-m-d') . '.csv'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- UTF-8 BOM bytes for Excel; the response is a CSV stream, not HTML. echo "\xEF\xBB\xBF"; $headers = [ 'orphans' => [__( 'ID', 'linkbraid' ), __( 'Title', 'linkbraid' ), __( 'Type', 'linkbraid' ), __( 'Date', 'linkbraid' ), __( 'Outgoing links', 'linkbraid' ), __( 'Address', 'linkbraid' )], 'coverage' => [__( 'ID', 'linkbraid' ), __( 'Title', 'linkbraid' ), __( 'Type', 'linkbraid' ), __( 'Outgoing', 'linkbraid' ), __( 'Incoming', 'linkbraid' ), __( 'Address', 'linkbraid' )], 'anchors' => [__( 'Anchor', 'linkbraid' ), __( 'Problem', 'linkbraid' ), __( 'Source post', 'linkbraid' ), __( 'Target post', 'linkbraid' )], 'domains' => [__( 'Domain', 'linkbraid' ), __( 'Links', 'linkbraid' ), __( 'Source posts', 'linkbraid' ), __( 'Broken links', 'linkbraid' )], 'broken' => [__( 'URL', 'linkbraid' ), __( 'Domain', 'linkbraid' ), __( 'HTTP code', 'linkbraid' ), __( 'Error', 'linkbraid' ), __( 'Occurrences', 'linkbraid' ), __( 'Last check', 'linkbraid' )], 'redirects' => [__( 'URL', 'linkbraid' ), __( 'HTTP code', 'linkbraid' ), __( 'Redirect target', 'linkbraid' ), __( 'Occurrences', 'linkbraid' ), __( 'Last check', 'linkbraid' )], 'clicks' => [__( 'ID', 'linkbraid' ), __( 'Title', 'linkbraid' ), __( 'Clicks', 'linkbraid' ), __( 'Source posts', 'linkbraid' ), __( 'Last click', 'linkbraid' )], 'term_orphans' => [__( 'ID', 'linkbraid' ), __( 'Name', 'linkbraid' ), __( 'Taxonomy', 'linkbraid' ), __( 'Posts in the term', 'linkbraid' ), __( 'Address', 'linkbraid' )], ]; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CSV stream, not HTML; csv_line() quotes every field (RFC 4180). echo self::csv_line($headers[$report]); $offset = 0; $batch = 500; do { $sql = "$select $from_where $order LIMIT %d OFFSET %d"; $args = array_merge($params, [$batch, $offset]); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $select/$from_where/$order come from the fixed per-report whitelists above; values travel through $args placeholders. $rows = $wpdb->get_results($wpdb->prepare($sql, ...$args), ARRAY_A); foreach ($rows as $row) { $cells = null; switch ($report) { case 'orphans': $cells = [$row['ID'], $row['post_title'], $row['post_type'], $row['post_date'], $row['outgoing'], get_permalink($row['ID'])]; break; case 'coverage': $cells = [$row['ID'], $row['post_title'], $row['post_type'], $row['outgoing'], $row['incoming'], get_permalink($row['ID'])]; break; case 'anchors': $anchor = mb_strtolower(trim($row['anchor_text'])); $issue = (strpos($anchor, 'http') === 0 || strpos($anchor, 'www.') === 0) ? __( 'URL as anchor', 'linkbraid' ) : (((int) $row['wc']) > 8 ? __( 'Over 8 words', 'linkbraid' ) : __( 'Generic phrase', 'linkbraid' )); $cells = [$row['anchor_text'], $issue, $row['source_title'], $row['target_title']]; break; case 'domains': $cells = [$row['host'], $row['links'], $row['sources'], $row['broken']]; break; case 'broken': $cells = [$row['url'], $row['host'], $row['status_code'], $row['error_message'], $row['uses'], $row['last_checked']]; break; case 'redirects': $cells = [$row['url'], $row['redirect_code'], $row['redirect_url'], $row['uses'], $row['last_checked']]; break; case 'clicks': $cells = [$row['ID'], $row['post_title'], $row['clicks'], $row['sources'], $row['last_click']]; break; case 'term_orphans': $term_link = get_term_link((int) $row['term_id']); $cells = [$row['term_id'], $row['name'], $row['taxonomy'], $row['posts_in_term'], is_wp_error($term_link) ? '' : $term_link]; break; } // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CSV stream, not HTML; csv_line() quotes every field (RFC 4180). if ($cells !== null) echo self::csv_line($cells); } $offset += $batch; flush(); } while (count($rows) === $batch); exit; } public function broken_action() { $this->verify_nonce(); $id = absint($_POST['id'] ?? 0); $op = sanitize_key($_POST['op'] ?? ''); if (!$id) wp_send_json_error(__( 'No URL given.', 'linkbraid' )); if ($op === 'ignore') { Linkbraid_Broken::set_ignored($id, true); wp_send_json_success(); } if ($op === 'snooze') { Linkbraid_Broken::snooze($id, absint($_POST['days'] ?? 30)); wp_send_json_success(); } if ($op === 'recheck') { $r = Linkbraid_Broken::check($id); isset($r['error']) ? wp_send_json_error($r['error']) : wp_send_json_success(); } if ($op === 'unlink') { $url = Linkbraid_Broken::url($id); if (!$url) wp_send_json_error(__( 'No URL given.', 'linkbraid' )); $r = Linkbraid_Link_Manager::unlink_url($url); isset($r['error']) ? wp_send_json_error($r['error']) : wp_send_json_success($r); } wp_send_json_error(__( 'Unknown operation.', 'linkbraid' )); } // ================== // Strands // ================== public function strands_list() { $this->verify_nonce(); wp_send_json_success(['strands' => Linkbraid_Strands::all()]); } /** * Pillar bootstrap: every priority page (services, offers) becomes its own * strand — the page plus its semantically closest content, straight from * the stored AI embeddings. No API calls; other pillars stay out of each * other's strands so the silos do not blur. */ public function strands_bootstrap() { $this->verify_nonce(); global $wpdb; // Every public taxonomy of the enabled post types — including custom // ones — not just the taxonomies allowed as link targets. $taxonomies = array_keys(Linkbraid_Terms::choices()); if (!$taxonomies) wp_send_json_error(__( 'No taxonomies with content were found.', 'linkbraid' )); $terms = get_terms(['taxonomy' => $taxonomies, 'orderby' => 'count', 'order' => 'DESC', 'number' => 12, 'hide_empty' => true]); if (is_wp_error($terms)) wp_send_json_error($terms->get_error_message()); $created = 0; $assigned = 0; foreach ($terms as $term) { if ((int) $term->count < 3) continue; $strand_id = (int) $wpdb->get_var($wpdb->prepare("SELECT id FROM {$wpdb->prefix}lbraid_strands WHERE name = %s", $term->name)); if (!$strand_id) { $result = Linkbraid_Strands::create($term->name); if (isset($result['error'])) continue; $strand_id = (int) $result['id']; $created++; } $posts = get_posts([ 'post_type' => lbraid_post_types(), 'post_status' => 'publish', 'posts_per_page' => 200, 'fields' => 'ids', 'tax_query' => [['taxonomy' => $term->taxonomy, 'field' => 'term_id', 'terms' => $term->term_id]], ]); foreach ($posts as $post_id) { $result = Linkbraid_Strands::add_post($strand_id, $post_id); if (!isset($result['error'])) $assigned++; } } if (!$created && !$assigned) wp_send_json_error(__( 'No categories with at least 3 content items were found.', 'linkbraid' )); /* translators: %1$d, %2$d: dynamic values in display order. */ wp_send_json_success(['created' => $created, 'assigned' => $assigned, 'message' => sprintf( __( 'Created %1$d strands from categories and assigned %2$d content items. Adjust the assignments below.', 'linkbraid' ), $created, $assigned )]); } public function strands_create() { $this->verify_nonce(); $result = Linkbraid_Strands::create(sanitize_text_field(wp_unslash($_POST['name'] ?? ''))); isset($result['error']) ? wp_send_json_error($result['error']) : wp_send_json_success($result); } public function strands_delete() { $this->verify_nonce(); Linkbraid_Strands::delete($_POST['id'] ?? 0); wp_send_json_success(); } public function strands_posts() { $this->verify_nonce(); wp_send_json_success(['posts' => Linkbraid_Strands::posts($_POST['id'] ?? 0)]); } public function strands_add_post() { $this->verify_nonce(); $result = Linkbraid_Strands::add_post($_POST['id'] ?? 0, $_POST['post_id'] ?? 0); isset($result['error']) ? wp_send_json_error($result['error']) : wp_send_json_success($result); } public function strands_remove_post() { $this->verify_nonce(); Linkbraid_Strands::remove_post($_POST['id'] ?? 0, $_POST['post_id'] ?? 0); wp_send_json_success(); } // ================== // Global history // ================== public function history_list() { $this->verify_nonce(); global $wpdb; $page = max(1, absint($_POST['page'] ?? 1)); $size = 20; $total = (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = %s", '_lbraid_link_history')); $rows = $wpdb->get_results($wpdb->prepare( "SELECT meta_id, post_id, meta_value FROM {$wpdb->postmeta} WHERE meta_key = %s ORDER BY meta_id DESC LIMIT %d OFFSET %d", '_lbraid_link_history', $size, ($page - 1) * $size)); $hashes = []; $out = []; foreach ($rows as $row) { $h = maybe_unserialize($row->meta_value); if (!is_array($h)) continue; $post_id = (int) $row->post_id; if (!isset($hashes[$post_id])) $hashes[$post_id] = Linkbraid_Content::hash($post_id); $suggestion = $wpdb->get_row($wpdb->prepare( "SELECT anchor_text, target_post_id FROM {$wpdb->prefix}lbraid_link_suggestions WHERE id = %d", (int) ($h['suggestion_id'] ?? 0))); $user = !empty($h['user']) ? get_userdata((int) $h['user']) : null; $out[] = [ 'history_id' => (int) $row->meta_id, 'post_id' => $post_id, 'post_title' => get_the_title($post_id) ?: ('#' . $post_id), 'post_edit' => get_edit_post_link($post_id, 'raw'), 'date' => $h['date'] ?? '', 'state' => $h['state'] ?? '', 'user_name' => $user ? $user->display_name : '—', 'anchor' => $suggestion ? $suggestion->anchor_text : '', 'target_title' => $suggestion ? (get_the_title($suggestion->target_post_id) ?: '#' . $suggestion->target_post_id) : '', 'can_undo' => ($h['state'] ?? '') === 'applied' && !empty($h['after_hash']) && hash_equals($h['after_hash'], $hashes[$post_id]), ]; } wp_send_json_success(['rows' => $out, 'total' => $total, 'total_pages' => (int) ceil($total / $size)]); } // ================== // Anchor sources: GSC + SEO plugins // ================== /** Hard phrases for a target page: synced GSC queries and Yoast/RankMath focus keywords. */ public function anchor_sources() { $this->verify_nonce(); $id = absint($_POST['post_id'] ?? 0); if (!$id || !current_user_can('edit_post', $id)) wp_send_json_error(__( 'Insufficient permissions.', 'linkbraid' )); $gsc = []; foreach (Linkbraid_Database::get_gsc_queries_for_post($id, 12) as $row) { $query = trim((string) ($row['query'] ?? '')); if (mb_strlen($query) >= 2) $gsc[] = $query; } $seo = []; $yoast = trim((string) get_post_meta($id, '_yoast_wpseo_focuskw', true)); if ($yoast !== '') $seo[] = $yoast; foreach (explode(',', (string) get_post_meta($id, 'rank_math_focus_keyword', true)) as $keyword) { $keyword = trim($keyword); if (mb_strlen($keyword) >= 2) $seo[] = $keyword; } $gsc = array_values(array_unique($gsc)); $seo = array_values(array_unique(array_diff($seo, $gsc))); wp_send_json_success(['gsc' => $gsc, 'seo' => $seo]); } // ================== // Autobraid // ================== public function autobraid_rules() { $this->verify_nonce(); wp_send_json_success(['rules' => Linkbraid_Autobraid::rules(), 'pending' => Linkbraid_Autobraid::pending_count()]); } public function autobraid_save_rule() { $this->verify_nonce(); $result = Linkbraid_Autobraid::save_rule(sanitize_text_field(wp_unslash($_POST['phrase'] ?? '')), absint($_POST['target_post_id'] ?? 0), absint($_POST['flexible'] ?? 0)); if (isset($result['error'])) wp_send_json_error($result['error']); wp_send_json_success($result); } public function autobraid_toggle_rule() { $this->verify_nonce(); Linkbraid_Autobraid::set_enabled(absint($_POST['id'] ?? 0), !empty($_POST['enabled'])); wp_send_json_success(); } public function autobraid_delete_rule() { $this->verify_nonce(); Linkbraid_Autobraid::delete_rule(absint($_POST['id'] ?? 0)); wp_send_json_success(); } public function autobraid_apply_all() { $this->verify_nonce(); wp_send_json_success(Linkbraid_Autobraid::apply_batch(25)); } // ================== // Clicks // ================== /** Public beacon: validates and stores the click, always answers 204. */ public function track_click() { if (Linkbraid_Clicks::enabled()) { Linkbraid_Clicks::record(absint($_POST['source'] ?? 0), esc_url_raw(wp_unslash($_POST['href'] ?? '')), sanitize_text_field(wp_unslash($_POST['anchor'] ?? ''))); } status_header(204); exit; } // ================== // Setup wizard // ================== /** Partial wizard save: only the fields present in the request; keys are never overwritten with an empty value. */ public function clear_debug() { $this->verify_nonce(); delete_option('lbraid_debug_log'); wp_send_json_success(); } // ================== // GSC Handlers // ================== }