prefix plus a fixed suffix from a private helper (never user input); all runtime values go through $wpdb->prepare() placeholders. /** * Autobraid — automatic linking rules: phrase -> target page. * * Rules do NOT insert links through the the_content filter. A scan creates ordinary * suggestions (Knots, method=autobraid), and saving goes through * Linkbraid_Link_Manager::apply_suggestion — the same validator, link limit, * content hash, history and undo as the AI suggestions. Cluster-level rules * (Strands) are planned for the next stage. */ class Linkbraid_Autobraid { private static function rules_table() { global $wpdb; return $wpdb->prefix . 'lbraid_autobraid_rules'; } private static function suggestions_table() { global $wpdb; return $wpdb->prefix . 'lbraid_link_suggestions'; } /** Rule list with suggestion statistics. */ public static function rules() { global $wpdb; $rules = $wpdb->get_results( "SELECT r.*, p.post_title, p.post_status, (SELECT COUNT(*) FROM " . self::suggestions_table() . " s WHERE s.rule_id = r.id AND s.status = 'pending') AS pending, (SELECT COUNT(*) FROM " . self::suggestions_table() . " s WHERE s.rule_id = r.id AND s.status = 'accepted') AS accepted FROM " . self::rules_table() . " r LEFT JOIN {$wpdb->posts} p ON p.ID = r.target_post_id ORDER BY r.id DESC", ARRAY_A); foreach ($rules as &$rule) { $rule['target_url'] = get_permalink($rule['target_post_id']); $rule['target_title'] = $rule['post_title'] ?: ('#' . $rule['target_post_id']); $rule['target_missing'] = $rule['post_status'] !== 'publish'; } return $rules; } public static function save_rule($phrase, $target_post_id, $flexible = 0) { global $wpdb; $phrase = trim(preg_replace('/\s+/u', ' ', (string) $phrase)); $target_post_id = absint($target_post_id); if (mb_strlen($phrase) < 2 || mb_strlen($phrase) > 190) return ['error' => __( 'The phrase must be between 2 and 190 characters.', 'linkbraid' )]; $target = get_post($target_post_id); if (!$target || $target->post_status !== 'publish' || !in_array($target->post_type, lbraid_post_types(), true)) return ['error' => __( 'Pick a published page from the enabled content types.', 'linkbraid' )]; if (!Linkbraid_Anchors::allows($target_post_id, $phrase)) return ['error' => __( 'The target page only allows anchors from its assigned list — add this phrase to its anchors or change the mode.', 'linkbraid' )]; $exists = $wpdb->get_var($wpdb->prepare( 'SELECT id FROM ' . self::rules_table() . ' WHERE phrase = %s AND target_post_id = %d', $phrase, $target_post_id)); if ($exists) return ['error' => __( 'That rule already exists.', 'linkbraid' )]; if (false === $wpdb->insert(self::rules_table(), ['phrase' => $phrase, 'target_post_id' => $target_post_id, 'enabled' => 1, 'flexible' => $flexible ? 1 : 0, 'created_at' => current_time('mysql')])) { return ['error' => __( 'The rule could not be saved.', 'linkbraid' )]; } return ['success' => true, 'id' => (int) $wpdb->insert_id]; } public static function set_enabled($id, $enabled) { global $wpdb; return false !== $wpdb->update(self::rules_table(), ['enabled' => $enabled ? 1 : 0], ['id' => absint($id)]); } /** Deletes a rule together with its unresolved suggestions (applied ones stay). */ public static function delete_rule($id) { global $wpdb; $id = absint($id); $wpdb->delete(self::suggestions_table(), ['rule_id' => $id, 'status' => 'pending']); return false !== $wpdb->delete(self::rules_table(), ['id' => $id]); } /** * Scan of a single post (one queue unit): for every enabled rule it creates * at most one suggestion, when the phrase can be inserted safely and the target * is not linked yet. */ public static function scan_post($post_id) { global $wpdb; // Ignored content gets no rule matches (Settings → Linking rules). if (in_array((int) $post_id, lbraid_ignored_posts(), true)) return ['success' => true, 'count' => 0]; $rules = $wpdb->get_results('SELECT * FROM ' . self::rules_table() . ' WHERE enabled = 1'); if (!$rules) return ['success' => true, 'count' => 0]; $fragments = null; // lazy - only once a rule passes the cheap filters $created = 0; foreach ($rules as $rule) { $target = (int) $rule->target_post_id; if ($target === (int) $post_id) continue; if (get_post_status($target) !== 'publish') continue; if (get_option('lbraid_exclude_home', 1) && $target === (int) get_option('page_on_front')) continue; if (in_array($target, lbraid_excluded_targets(), true)) continue; if (!Linkbraid_Anchors::allows($target, $rule->phrase)) continue; if (Linkbraid_Anchors::phrase_ignored($rule->phrase)) continue; if ($wpdb->get_var($wpdb->prepare( "SELECT 1 FROM {$wpdb->prefix}lbraid_existing_links WHERE source_post_id = %d AND target_post_id = %d LIMIT 1", $post_id, $target))) continue; if ($wpdb->get_var($wpdb->prepare( 'SELECT 1 FROM ' . self::suggestions_table() . " WHERE source_post_id = %d AND target_post_id = %d AND status = 'pending' LIMIT 1", $post_id, $target))) continue; if ($fragments === null) $fragments = Linkbraid_Content::fragments($post_id); $url = get_permalink($target); $found = false; $anchor = $rule->phrase; foreach ($fragments as $fragment) { if (!empty($rule->flexible)) { // Flexible rule: inflected forms match too; the SURFACE form // found in the text becomes the anchor, so everything // downstream (plan, preview, apply) stays a literal match. $match = Linkbraid_Content::insert_flexible($fragment['html'], $rule->phrase, $url); if ($match !== null) { $anchor = $match['anchor']; $found = true; break; } } elseif (Linkbraid_Content::insert($fragment['html'], $rule->phrase, $url) !== null) { $found = true; break; } } if (!$found) continue; // The surface form must pass the same anchor rules as the phrase itself. if (!Linkbraid_Anchors::allows($target, $anchor) || Linkbraid_Anchors::phrase_ignored($anchor) || !Linkbraid_Anchors::available_for($anchor, $target)) continue; $inserted = $wpdb->insert(self::suggestions_table(), [ 'source_post_id' => (int) $post_id, 'target_post_id' => $target, 'anchor_text' => mb_substr($anchor, 0, 500), 'context_snippet' => '', 'confidence' => 1, 'status' => 'pending', 'is_new_content' => 0, 'source_hash' => Linkbraid_Content::hash($post_id), /* translators: %1$s, %2$s: dynamic values in display order. */ 'reason' => sprintf( __( 'Autobraid rule: “%1$s” → %2$s', 'linkbraid' ), $rule->phrase, get_the_title($target) ), 'method' => 'autobraid', 'rule_id' => (int) $rule->id, 'created_at' => current_time('mysql'), ]); if ($inserted) $created++; } return ['success' => true, 'count' => $created]; } /** Applies a batch of pending matches (through the full validator). */ public static function apply_batch($limit = 25) { global $wpdb; $ids = $wpdb->get_col($wpdb->prepare( 'SELECT id FROM ' . self::suggestions_table() . " WHERE method = 'autobraid' AND status = 'pending' ORDER BY id ASC LIMIT %d", max(1, min(50, (int) $limit)))); $out = Linkbraid_Link_Manager::apply_bulk($ids); $out['remaining'] = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM ' . self::suggestions_table() . " WHERE method = 'autobraid' AND status = 'pending'"); return $out; } public static function pending_count() { global $wpdb; return (int) $wpdb->get_var('SELECT COUNT(*) FROM ' . self::suggestions_table() . " WHERE method = 'autobraid' AND status = 'pending'"); } }