settings = $settings; } /** * REST on every request; the editor box only in the admin. */ public function register() { add_action( 'rest_api_init', array( $this, 'register_rest' ) ); if ( is_admin() ) { // Priority 11: registered right AFTER the shared "Search & AI" box // (Topics::add_meta_box at 10), so in the default sidebar order this // box sits directly beneath it — the two Agentimus boxes read as one // group. (A user's saved drag order still wins, as it should.) add_action( 'add_meta_boxes', array( $this, 'add_meta_box' ), 11 ); add_action( 'admin_enqueue_scripts', array( $this, 'assets' ) ); } } /** * Whether the feature is offered. No settings card — it's a tool, not an * emission — but an owner can veto it in code. * * @return bool */ public static function enabled() { /** * Filter whether the internal-link suggestions are offered. * * @param bool $enabled Default true. */ return (bool) apply_filters( 'agentimus_internal_links_enabled', true ); } /* ---------------------------------------------------------------------- * * Finding — local signals only * ---------------------------------------------------------------------- */ /** * The posts this one should most plausibly link to, scored on local signals. * * @param \WP_Post $post The post being edited. * @param int $limit Max candidates. * @return array */ public static function candidates( \WP_Post $post, $limit = self::MAX_SUGGESTIONS ) { $pool = get_posts( array( 'post_type' => Content::post_types(), 'post_status' => 'publish', 'numberposts' => self::POOL, 'exclude' => array( $post->ID ), 'orderby' => 'modified', 'order' => 'DESC', 'suppress_filters' => false, ) ); $body_lc = self::body_text( $post, true ); $my_topics = self::topic_set( $post ); $my_terms = self::term_slugs( $post ); $scored = array(); foreach ( $pool as $candidate ) { if ( ! $candidate instanceof \WP_Post || $candidate->ID === $post->ID ) { continue; } $shared = array(); $score = 0; foreach ( array_intersect( $my_topics, self::topic_set( $candidate ) ) as $topic ) { $score += 3; $shared[] = $topic; } foreach ( array_intersect( $my_terms, self::term_slugs( $candidate ) ) as $slug ) { $score += 2; } // The candidate's title wording appearing in THIS post's text is the // strongest "you already talk about it" signal. $hits = 0; foreach ( self::meaningful_words( (string) $candidate->post_title ) as $word ) { if ( false !== strpos( $body_lc, $word ) ) { $hits++; } } $score += min( 5, $hits ); // One generic title word appearing once is noise ("single", "page"…): // a suggestion needs at least two points of evidence behind it. if ( $score >= 2 ) { $scored[] = array( 'post' => $candidate, 'score' => $score, 'shared' => array_slice( array_values( array_unique( $shared ) ), 0, 3 ), ); } } usort( $scored, static function ( $a, $b ) { return $b['score'] <=> $a['score']; } ); return array_slice( $scored, 0, max( 1, (int) $limit ) ); } /** * The longest run of a candidate title's words that appears verbatim in the * text — the phrase Insert would link. '' when nothing usable appears (the * suggestion then offers the append fallback instead). * * @param string $title The candidate post's title. * @param string $body The subject post's plain text. * @return string The phrase AS IT APPEARS in the body (original casing). */ public static function anchor_phrase( $title, $body ) { $words = preg_split( '/\s+/', trim( wp_strip_all_tags( (string) $title ) ), -1, PREG_SPLIT_NO_EMPTY ); if ( empty( $words ) || '' === $body ) { return ''; } $count = count( $words ); // Longest n-grams first; within a length, leftmost first. Single words must // be meaningful — linking "the" helps nobody. for ( $len = $count; $len >= 1; $len-- ) { for ( $start = 0; $start + $len <= $count; $start++ ) { $phrase = implode( ' ', array_slice( $words, $start, $len ) ); if ( 1 === $len && ! in_array( strtolower( $phrase ), self::meaningful_words( $phrase ), true ) ) { continue; } $found = self::find_in_text( $phrase, $body ); if ( '' !== $found ) { return $found; } } } return ''; } /** * Case-insensitive, word-bounded search returning the phrase in the body's * own casing ('' when absent). * * @param string $phrase Needle. * @param string $body Haystack (plain text). * @return string */ public static function find_in_text( $phrase, $body ) { $pattern = '/(? */ public function suggest( \WP_Post $post, $use_ai ) { $body = self::body_text( $post, false ); $rows = array(); foreach ( self::candidates( $post ) as $candidate ) { $target = $candidate['post']; $why = ! empty( $candidate['shared'] ) ? sprintf( /* translators: %s: comma-separated shared topic list. */ __( 'You both cover: %s.', 'agentimus' ), implode( ', ', $candidate['shared'] ) ) : __( 'Its subject appears in this post’s text.', 'agentimus' ); $rows[] = array( 'id' => (int) $target->ID, 'title' => (string) get_the_title( $target ), 'url' => (string) get_permalink( $target ), 'phrase' => self::anchor_phrase( (string) $target->post_title, $body ), 'why' => $why, ); } if ( $use_ai && ! empty( $rows ) ) { $rows = $this->dress_with_ai( $post, $body, $rows ); } return $rows; } /** * ONE AI request dresses every row: a better anchor phrase and a specific * "why" per candidate. The reply is data, not gospel — a proposed phrase * that isn't verbatim in the text is discarded in favour of the local one. * * @param \WP_Post $post The post. * @param string $body Its plain text. * @param array $rows Locally built rows. * @return array Same rows, possibly improved. */ private function dress_with_ai( \WP_Post $post, $body, array $rows ) { $list = ''; foreach ( $rows as $i => $row ) { $list .= ( $i + 1 ) . '. ' . $row['title'] . "\n"; } $system = __( 'You place internal links in an article. For EACH numbered candidate post, pick the best short phrase from the ARTICLE TEXT to turn into a link to it, and give one short plain reason. The phrase MUST be copied verbatim from the article text — never invent or alter words. Reply with one line per candidate, exactly: number | phrase | reason. No other text.', 'agentimus' ); $prompt = sprintf( "Article title: %s\n\nArticle text:\n%s\n\nCandidate posts to link to:\n%s", get_the_title( $post ), function_exists( 'mb_substr' ) ? mb_substr( $body, 0, 4000 ) : substr( $body, 0, 4000 ), $list ); $reply = ( new Assist( $this->settings ) )->generate( $system, $prompt, 800 ); if ( is_wp_error( $reply ) ) { return $rows; // The local rows already stand on their own. } foreach ( explode( "\n", (string) $reply ) as $line ) { $parts = array_map( 'trim', explode( '|', $line ) ); if ( count( $parts ) < 3 ) { continue; } $index = (int) $parts[0] - 1; if ( ! isset( $rows[ $index ] ) ) { continue; } $phrase = trim( $parts[1], " \t\"'“”‘’" ); $found = self::find_in_text( $phrase, $body ); if ( '' !== $found ) { $rows[ $index ]['phrase'] = $found; } $why = sanitize_text_field( $parts[2] ); if ( '' !== $why ) { $rows[ $index ]['why'] = $why; } } return $rows; } /* ---------------------------------------------------------------------- * * REST * ---------------------------------------------------------------------- */ /** * POST /internal-links/suggest — gated per post by edit_post, exactly like * the field the suggestions serve. */ public function register_rest() { register_rest_route( self::NS, self::ROUTE, array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'rest_suggest' ), 'permission_callback' => static function ( $request ) { return current_user_can( 'edit_post', absint( $request['post'] ) ); }, 'args' => array( 'post' => array( 'type' => 'integer', 'required' => true, 'sanitize_callback' => 'absint', ), ), ) ); } /** * The route handler. AI is used when available; its absence is a quieter * result, never an error. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function rest_suggest( \WP_REST_Request $request ) { if ( ! self::enabled() ) { return new \WP_Error( 'agentimus_field_off', __( 'Internal-link suggestions are turned off.', 'agentimus' ), array( 'status' => 400 ) ); } $post = get_post( absint( $request['post'] ) ); if ( ! $post || ! in_array( $post->post_type, Content::post_types(), true ) ) { return new \WP_Error( 'agentimus_not_found', __( 'That content is not available.', 'agentimus' ), array( 'status' => 404 ) ); } $suggestions = $this->suggest( $post, Assist::ai_available() ); return rest_ensure_response( array( 'suggestions' => $suggestions ) ); } /* ---------------------------------------------------------------------- * * Editor box * ---------------------------------------------------------------------- */ /** * Its own sidebar box, beside the AI description/topics box (Heera's D1). */ public function add_meta_box() { if ( ! self::enabled() ) { return; } foreach ( Content::post_types() as $type ) { add_meta_box( 'agentimus-internal-links', Topics::meta_box_title( __( 'Link to your own posts', 'agentimus' ) ), array( $this, 'render_meta_box' ), $type, 'side', 'default' ); } } /** * The box shell — the button and an empty list; the JS does the rest. * * @param \WP_Post $post The post being edited. */ public function render_meta_box( $post ) { ?>

post_type, Content::post_types(), true ) || ! self::enabled() ) { return; } $config = array( 'endpoint' => esc_url_raw( rest_url( self::NS . self::ROUTE ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'i18n' => array( 'working' => __( 'Looking for related posts…', 'agentimus' ), 'none' => __( 'No clearly related posts found yet — as your site grows, suggestions appear here.', 'agentimus' ), 'needsSave' => __( 'Save a draft first, then try again.', 'agentimus' ), 'error' => __( 'Couldn’t fetch suggestions — please try again.', 'agentimus' ), 'insert' => __( 'Insert', 'agentimus' ), 'append' => __( 'Add “See also”', 'agentimus' ), 'dismiss' => __( 'Dismiss', 'agentimus' ), 'linked' => __( 'Linked ✓', 'agentimus' ), 'added' => __( 'Added ✓', 'agentimus' ), 'links' => __( 'Links the phrase', 'agentimus' ), 'noPhrase' => __( 'No matching phrase in the text — adds a “See also” line at the end instead.', 'agentimus' ), 'blockOnly' => __( 'Inserting needs the block editor; copy the address instead:', 'agentimus' ), ), ); wp_register_script( 'agentimus-internal-links', false, array(), AGENTIMUS_VERSION, true ); wp_enqueue_script( 'agentimus-internal-links' ); wp_add_inline_script( 'agentimus-internal-links', 'window.agentimusIl=' . wp_json_encode( $config ) . ';', 'before' ); wp_add_inline_script( 'agentimus-internal-links', self::inline_js() ); wp_register_style( 'agentimus-internal-links', false, array(), AGENTIMUS_VERSION ); wp_enqueue_style( 'agentimus-internal-links' ); wp_add_inline_style( 'agentimus-internal-links', self::inline_css() ); } /* ---------------------------------------------------------------------- * * Shared helpers * ---------------------------------------------------------------------- */ /** * The post's plain text, bounded. * * @param \WP_Post $post Post. * @param bool $lower Lowercased for matching. * @return string */ private static function body_text( \WP_Post $post, $lower ) { $body = (string) Content::markdown_source( $post ); if ( '' === trim( $body ) ) { $body = (string) $post->post_content; } $body = wp_strip_all_tags( $body ); $body = trim( (string) preg_replace( '/\s+/', ' ', $body ) ); $body = function_exists( 'mb_substr' ) ? mb_substr( $body, 0, self::MAX_BODY_CHARS ) : substr( $body, 0, self::MAX_BODY_CHARS ); return $lower ? strtolower( $body ) : $body; } /** * Lowercased resolved topics for overlap scoring. * * @param \WP_Post $post Post. * @return string[] */ private static function topic_set( \WP_Post $post ) { return array_map( 'strtolower', Topics::for_post( $post ) ); } /** * Category + tag slugs. * * @param \WP_Post $post Post. * @return string[] */ private static function term_slugs( \WP_Post $post ) { $slugs = array(); foreach ( array( 'category', 'post_tag' ) as $tax ) { $terms = wp_get_post_terms( $post->ID, $tax ); foreach ( is_wp_error( $terms ) ? array() : (array) $terms as $term ) { if ( is_object( $term ) && ! empty( $term->slug ) && 'uncategorized' !== $term->slug ) { $slugs[] = (string) $term->slug; } } } return $slugs; } /** * The words in a string worth matching on: lowercased, four letters up. * * @param string $text Input. * @return string[] */ public static function meaningful_words( $text ) { $words = preg_split( '/[^a-z0-9]+/i', strtolower( (string) $text ), -1, PREG_SPLIT_NO_EMPTY ); return array_values( array_filter( (array) $words, static function ( $word ) { return strlen( $word ) >= 4; } ) ); } /** * The box's behaviour. Vanilla JS, no build step, mirroring the Assist * injector's conventions. * * @return string */ private static function inline_js() { return <<<'JS' (function(){ var CFG = window.agentimusIl || {}; if (!CFG.endpoint) { return; } function blocksReady(){ try { return !!(window.wp && wp.data && wp.blocks && wp.data.dispatch('core/block-editor')); } catch (e) { return false; } } function escAttr(s){ return String(s).replace(/&/g,'&').replace(/"/g,'"').replace(/)([^<]*?)(' + escReg(phrase) + ')', 'i'); function walk(list){ for (var i = 0; i < list.length; i++) { var b = list[i]; // content is a plain string on WP <= 6.4 but a RichTextData object from // 6.5 on — String() folds both to the HTML source. if ((b.name === 'core/paragraph' || b.name === 'core/heading') && (b.attributes||{}).content != null) { var html = String(b.attributes.content); if (html.indexOf('' + hit + ''; }); wp.data.dispatch('core/block-editor').updateBlockAttributes(b.clientId, { content: replaced }); return true; } } } if (b.innerBlocks && b.innerBlocks.length && walk(b.innerBlocks)) { return true; } } return false; } return walk(blocks); } function appendSeeAlso(title, url){ var block = wp.blocks.createBlock('core/paragraph', { content: 'See also: ' + escAttr(title) + '' }); wp.data.dispatch('core/block-editor').insertBlocks(block); return true; } function render(box, rows){ var list = box.querySelector('.agentimus-il__list'); list.innerHTML = ''; if (!rows.length) { list.innerHTML = '

' + escAttr(CFG.i18n.none) + '

'; return; } rows.forEach(function(row){ var item = document.createElement('div'); item.className = 'agentimus-il__item'; var phraseNote = row.phrase ? '

' + escAttr(CFG.i18n.links) + ' ' + escAttr(row.phrase) + '

' : '

' + escAttr(CFG.i18n.noPhrase) + '

'; item.innerHTML = '

' + escAttr(row.title) + '

' + '

' + escAttr(row.why) + '

' + phraseNote + '
' + '' + '' + '
'; item.querySelector('.agentimus-il__dismiss').addEventListener('click', function(){ item.remove(); }); item.querySelector('.agentimus-il__insert').addEventListener('click', function(){ var btn = this; if (!blocksReady()) { var fallback = document.createElement('p'); fallback.className = 'agentimus-il__why'; fallback.textContent = CFG.i18n.blockOnly + ' ' + row.url; item.appendChild(fallback); return; } var done = row.phrase ? linkPhrase(row.phrase, row.url) : appendSeeAlso(row.title, row.url); if (!done) { done = appendSeeAlso(row.title, row.url); } if (done) { btn.textContent = row.phrase ? CFG.i18n.linked : CFG.i18n.added; btn.disabled = true; } }); list.appendChild(item); }); } function boot(){ var box = document.querySelector('.agentimus-il'); if (!box) { return false; } var btn = box.querySelector('.agentimus-il__suggest'); var note = box.querySelector('.agentimus-il__note'); btn.addEventListener('click', function(){ var id = parseInt(box.getAttribute('data-post'), 10); if (!id) { note.textContent = CFG.i18n.needsSave; return; } btn.disabled = true; note.textContent = CFG.i18n.working; fetch(CFG.endpoint, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': CFG.nonce }, body: JSON.stringify({ post: id }) }).then(function(r){ return r.json().then(function(j){ return { ok: r.ok, body: j }; }); }) .then(function(res){ btn.disabled = false; if (!res.ok) { note.textContent = (res.body && res.body.message) || CFG.i18n.error; return; } note.textContent = ''; render(box, (res.body && res.body.suggestions) || []); }) .catch(function(){ btn.disabled = false; note.textContent = CFG.i18n.error; }); }); return true; } function run(tries){ if (!boot() && tries > 0) { setTimeout(function(){ run(tries - 1); }, 300); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function(){ run(20); }); } else { run(20); } })(); JS; } /** * Box styles — admin palette, scoped. * * @return string */ private static function inline_css() { // Header reads like a core sidebar panel: title + collapse only, the // move arrows hidden — the same treatment the Search & AI box got in // 1.29 (WP 7.1's tooltip-wrapped arrows force long titles to wrap in // the 280px sidebar). Drag-sort by the header keeps working. return '#agentimus-internal-links .handle-order-higher,#agentimus-internal-links .handle-order-lower{display:none}' . '.agentimus-il__intro{color:#646970;font-size:12px;margin:0 0 8px}' . '.agentimus-il__suggest{width:100%;text-align:center}' . '.agentimus-il__note{display:block;font-size:12px;color:#646970;margin-top:6px}' . '.agentimus-il__item{border-top:1px solid #f0f0f1;padding:10px 0}' . '.agentimus-il__title{font-weight:600;margin:0 0 2px}' . '.agentimus-il__why{color:#646970;font-size:12px;margin:0 0 4px}' . '.agentimus-il__phrase{font-size:12px;margin:0 0 8px}' . '.agentimus-il__phrase code{background:#f6f7f7;border:1px solid #e0e0e0;border-radius:3px;padding:1px 5px;font-size:11.5px}' . '.agentimus-il__phrase--none{color:#646970;font-style:italic}' . '.agentimus-il__row{display:flex;gap:6px}' . '.agentimus-il__none{color:#646970;font-size:12px;margin:8px 0 0}'; } }