settings = $settings;
}
/**
* Register the post meta, the editor UI and the cache-busting hooks.
*/
public function register() {
add_action( 'init', array( __CLASS__, 'register_meta' ) );
if ( is_admin() ) {
add_action( 'add_meta_boxes', array( $this, 'add_meta_box' ) );
add_action( 'save_post', array( $this, 'save' ), 10, 2 );
add_action( 'admin_enqueue_scripts', array( $this, 'assets' ) );
}
// A pure REST meta write (block-editor sidebar / headless) does not always
// fire save_post, and save_post is what busts the generated caches
// (Cache::register_flush_hooks). Flush on our meta changing too so llms.txt,
// the full-text edition and the JSON-LD can never serve stale topics. The
// editor path fires both; Cache::flush() is idempotent, so the overlap is free.
foreach ( array( 'added_post_meta', 'updated_post_meta', 'deleted_post_meta' ) as $hook ) {
add_action( $hook, array( __CLASS__, 'flush_on_meta' ), 10, 3 );
}
}
/**
* Register both post metas on every agent-visible post type, so they are
* sanitised on write and exposed to the REST/block editor (headless-friendly).
* The auth callback gates REST writes to users who can edit the post.
*/
public static function register_meta() {
$auth = array( __CLASS__, 'can_edit' );
foreach ( Content::post_types() as $type ) {
register_post_meta(
$type,
self::META_TOPICS,
array(
'type' => 'array',
'single' => true,
'show_in_rest' => array(
'schema' => array(
'type' => 'array',
'items' => array( 'type' => 'string' ),
),
),
'sanitize_callback' => array( __CLASS__, 'sanitize_manual' ),
'auth_callback' => $auth,
)
);
register_post_meta(
$type,
self::META_DERIVE,
array(
'type' => 'string',
'single' => true,
'default' => '',
'show_in_rest' => true,
'sanitize_callback' => array( __CLASS__, 'sanitize_flag' ),
'auth_callback' => $auth,
)
);
}
}
/**
* The resolved topic list for a post: manual ∪ derived (if derivation is on),
* trimmed, deduped case-insensitively and capped. Returns [] when the feature
* is off or the post is missing. Manual entries come first, so they win the
* dedupe against a derived term with the same text.
*
* @param int|\WP_Post $post Post or ID.
* @return string[]
*/
public static function for_post( $post ) {
if ( ! self::enabled() ) {
return array();
}
$post = get_post( $post );
if ( ! $post ) {
return array();
}
$topics = self::manual( $post->ID );
if ( self::derive_enabled( $post->ID ) ) {
$topics = array_merge( $topics, self::derived( $post ) );
}
$topics = self::normalize( $topics, self::cap() );
/**
* Filter the resolved topics for a post. An add-on can add topics an add-on
* knows about (e.g. WooCommerce product attributes) or refine the list.
*
* @param string[] $topics Resolved, normalised topics.
* @param \WP_Post $post The post.
*/
$topics = apply_filters( 'agentimus_post_topics', $topics, $post );
// Re-normalise after the filter: a provider must not be able to bust the
// cap, inject blanks, or slip markup/whitespace into a machine surface.
return self::normalize( is_array( $topics ) ? $topics : array(), self::cap() );
}
/**
* Whether the topics feature is enabled site-wide.
*
* @return bool
*/
public static function enabled() {
return (bool) ( new Settings() )->enabled( 'enable_topics' );
}
/**
* The editor-entered (static) topics for a post, as a raw list (normalised by
* the caller alongside the derived half).
*
* @param int $id Post ID.
* @return string[]
*/
private static function manual( $id ) {
return self::to_list( get_post_meta( $id, self::META_TOPICS, true ) );
}
/**
* Whether to also derive topics from this post's tags & categories: the saved
* per-post choice, or the site default when the post has never set one.
*
* @param int $id Post ID.
* @return bool
*/
private static function derive_enabled( $id ) {
$flag = get_post_meta( $id, self::META_DERIVE, true );
if ( '1' === $flag ) {
return true;
}
if ( '0' === $flag ) {
return false;
}
// Unset (never saved) → follow the site-wide default.
return (bool) ( new Settings() )->get( 'topics_derive_default', true );
}
/**
* The derived-topics seed for a post (its category + tag names, before merging
* with the manual list). Public so the editor UI can show a live preview that
* matches what {@see for_post()} will emit.
*
* @param int|\WP_Post $post Post or ID.
* @return string[]
*/
public static function derived_topics( $post ) {
$post = get_post( $post );
return $post ? self::derived( $post ) : array();
}
/**
* Topic names derived from a post's core taxonomies (categories then tags).
* Custom-post-type taxonomies (e.g. WooCommerce product_cat) are intentionally
* left to the `agentimus_post_topics` filter, so this stays deterministic.
*
* @param \WP_Post $post Post.
* @return string[]
*/
private static function derived( $post ) {
// Skip the default "Uncategorized" category (and any owner-excluded slugs) so a
// placeholder term never becomes an AI keyword — consistent with the site-level
// Topics section (see LlmsText::topics()), which honours this same filter.
$exclude = (array) apply_filters( 'agentimus_topic_exclude', array( 'uncategorized' ) );
$names = array();
foreach ( self::derive_taxonomies( $post ) as $tax ) {
// Full term objects (not just names) so we can filter by slug.
$terms = wp_get_post_terms( $post->ID, $tax );
if ( is_wp_error( $terms ) ) {
continue;
}
foreach ( (array) $terms as $term ) {
if ( ! is_object( $term ) || in_array( $term->slug, $exclude, true ) ) {
continue;
}
if ( self::is_meaningful_topic_name( $term->name, $term ) ) {
$names[] = $term->name;
}
}
}
return $names;
}
/**
* Whether a TAXONOMY-DERIVED term name is worth surfacing as an AI topic. Default:
* drop a name that is only digits and separators — a placeholder/junk category like
* "67", a stray numeric ID, a bare year used as a noise category. Such a value tells
* an assistant nothing (a wrong keyword is worse than a missing one), and no site
* owner should have to hunt these down for clean output. Applies only to the
* auto-derive paths (per-post {@see derived()} and the site-level llms.txt category
* list) — a manually typed topic is explicit intent and is never filtered here. It
* is overridable for the rare site where a number really is the subject.
*
* @param string $name Term name.
* @param object $term Term object (for filter context).
* @return bool
*/
public static function is_meaningful_topic_name( $name, $term = null ) {
$name = trim( (string) $name );
$meaningful = '' !== $name && ! preg_match( '/^[\d\s.,\/-]+$/', $name );
/**
* Filter whether an auto-derived taxonomy term becomes an AI topic. Default
* false for a purely-numeric name (junk/placeholder categories, stray IDs).
* Return true to keep a numeric term — e.g. a history or film site where "1984"
* is genuinely the subject.
*
* @param bool $meaningful Whether to keep the term as a topic.
* @param string $name The term name.
* @param object $term The term object (may be null).
*/
return (bool) apply_filters( 'agentimus_topic_meaningful', $meaningful, $name, $term );
}
/**
* The taxonomies whose terms feed the auto-derived topics, narrowed to those the
* post's type actually has. Core ships only the two WordPress built-ins; a vendor
* (e.g. WooCommerce) declares its own via the filter, and those terms then flow
* through the SAME path — the derive toggle, the exclude list, dedupe and the cap
* — so no vendor-specific code ever lives in core.
*
* @param \WP_Post $post Post.
* @return string[]
*/
private static function derive_taxonomies( $post ) {
/**
* Filter which taxonomies the auto-derive reads topics from. Default: the
* WordPress built-ins `category` and `post_tag`. A vendor adds its own
* (e.g. `product_cat`) so its terms are offered as topics wherever its
* content type is agent-visible — Agentimus needs no knowledge of the vendor.
*
* @param string[] $taxonomies Taxonomy slugs.
* @param \WP_Post $post The post being described.
*/
$taxonomies = (array) apply_filters( 'agentimus_derive_taxonomies', array( 'category', 'post_tag' ), $post );
$out = array();
foreach ( $taxonomies as $tax ) {
$tax = (string) $tax;
if ( '' !== $tax && ! in_array( $tax, $out, true ) && is_object_in_taxonomy( $post->post_type, $tax ) ) {
$out[] = $tax;
}
}
return $out;
}
/**
* Sanitiser for the manual topic list meta (also used by the meta-box save):
* accepts an array or a comma/newline string, strips tags, trims, drops blanks,
* dedupes case-insensitively and caps count + per-item length.
*
* @param mixed $value Raw meta/input value.
* @return string[]
*/
public static function sanitize_manual( $value ) {
return self::normalize( self::to_list( $value ), self::cap() );
}
/**
* Sanitiser for the derive flag meta: canonicalise any truthy form to '1',
* everything else to '0'.
*
* @param mixed $value Raw value.
* @return string
*/
public static function sanitize_flag( $value ) {
$truthy = array( '1', 1, true, 'true', 'on', 'yes' );
return in_array( $value, $truthy, true ) ? '1' : '0';
}
/**
* auth_callback for register_post_meta: only users who can edit the post may
* write these metas over REST.
*
* @param bool $allowed Current decision.
* @param string $meta_key Meta key.
* @param int $object_id Post ID.
* @return bool
*/
public static function can_edit( $allowed, $meta_key, $object_id ) {
return current_user_can( 'edit_post', $object_id );
}
/**
* Flush the generated caches when a post's topic meta changes via a path that
* doesn't fire save_post (e.g. a REST meta-only write). Hooked on
* added/updated/deleted_post_meta; a no-op for every other meta key.
*
* @param int|int[] $meta_id Meta row id(s) — unused.
* @param int $object_id Post ID — unused.
* @param string $meta_key The meta key that changed.
*/
public static function flush_on_meta( $meta_id, $object_id, $meta_key ) {
if ( self::META_TOPICS === $meta_key || self::META_DERIVE === $meta_key ) {
Cache::flush();
delete_transient( self::SUGGEST_TRANSIENT ); // A new topic should show up in suggestions.
}
}
/**
* Add the "Topics for AI" meta box to every agent-visible post type. Skipped
* entirely when the feature is off.
*/
public function add_meta_box() {
// One SHARED box for both AI-dressing features — description on top,
// topics below — instead of two stacked boxes fighting for sidebar
// space. Registered under the long-standing 'agentimus-topics' id so
// any saved metabox order (and muscle memory) carries over. The title
// tells the truth about which halves are on.
$topics = self::enabled();
$description = Description::enabled();
$seo_title = Seo::title_ui_enabled();
if ( ! $topics && ! $description && ! $seo_title ) {
return;
}
if ( $seo_title ) {
// With the solo-mode SEO title in the box, enumerating every half in
// the title stops scaling — this covers the box's whole job.
$title = __( 'Search & AI', 'agentimus' );
} elseif ( $topics && $description ) {
$title = __( 'AI Description & Topics', 'agentimus' );
} elseif ( $topics ) {
$title = __( 'Topics for AI', 'agentimus' );
} else {
$title = __( 'AI Description', 'agentimus' );
}
foreach ( Content::post_types() as $type ) {
add_meta_box(
'agentimus-topics',
self::meta_box_title( $title ),
array( $this, 'render_meta_box' ),
$type,
'side',
'default'
);
}
}
/**
* A meta-box title prefixed with the Agentimus "A" mark, so an owner sees at a
* glance which plugin owns the box. WordPress echoes meta-box titles as raw HTML,
* so the inline SVG renders in the handle; it's decorative (aria-hidden) because
* the text still labels the box. Same mark as the admin menu icon (Admin.php), in
* the brand teal. A small size + the no-wrap rule in inline_css() keep it on one
* line beside WordPress's own header controls in the narrow sidebar.
*
* @param string $text The plain-text title (already translated).
* @return string
*/
public static function meta_box_title( $text ) {
return Admin::brand_title( $text ); // The one shared brand tile.
}
/**
* Render the meta box: a comma-separated topics field (progressively enhanced
* into a chip editor by {@see assets()}), the derive checkbox, and a preview of
* what will be emitted. With JS the preview updates live as you type; without
* it, the plain textarea still works and the preview shows the saved state.
*
* @param \WP_Post $post Post being edited.
*/
public function render_meta_box( $post ) {
// Up to three fields share this one box, and three paragraphs of prose with
// no names read as a wall of text. Each section gets a short heading (what
// the field IS) with its sentence demoted to a hint below (what it does) —
// the natural scan order, and truer label semantics too. Printed HERE, by
// the box itself, because each field's own stylesheet only loads when that
// field is enabled — and the SEO title has no stylesheet at all.
echo '';
// SEARCH SNIPPET ORDER, top to bottom: title, then what the page is for,
// then the description. The focus led this box for a while on the
// argument that the title answers it — true, but it put a diagnosis
// above the field it diagnoses, and the field is what an author opens
// this box to edit. Each half keeps its own save hook; only the
// container is shared.
if ( Seo::title_ui_enabled() ) {
( new Seo( $this->settings ) )->render_title_field( $post );
echo '
';
}
( new Focus( $this->settings ) )->render_field( $post );
if ( Description::enabled() || self::enabled() ) {
echo '';
}
// The AI description, when that feature is on.
// Its own save/assets hooks are untouched — only the container is shared.
if ( Description::enabled() ) {
( new Description( $this->settings ) )->render_meta_box( $post );
if ( self::enabled() ) {
echo '';
}
}
if ( ! self::enabled() ) {
return;
}
wp_nonce_field( self::NONCE, self::NONCE );
$manual = self::manual( $post->ID );
$derive = self::derive_enabled( $post->ID );
// Seed for the client-side chip editor: the manual list, the page's derived
// tags/categories, the cap/length limits the resolver applies, plus the derive
// taxonomies and exclude slugs so the block editor can live-sync the "auto"
// chips as terms are added/removed (see the live-sync block in inline_js).
$config = wp_json_encode(
array(
'manual' => array_values( $manual ),
'derived' => array_values( self::derived_topics( $post ) ),
'cap' => self::cap(),
'maxLen' => self::MAX_LEN,
'deriveTax' => array_values( self::derive_taxonomies( $post ) ),
// FALSE POSITIVE, not a suppressed query: the VIP sniff matches the key name
// `exclude` and assumes a get_posts() call. This is a JSON config payload for the
// topic editor's Vue component — no query runs here.
'exclude' => array_values( (array) apply_filters( 'agentimus_topic_exclude', array( 'uncategorized' ) ) ), // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude
)
);
echo '
';
// Use the content type's own noun (post / page / product / …) so the copy
// reads right wherever the box appears, instead of a hardcoded "page".
$obj = get_post_type_object( $post->post_type );
$noun = ( $obj && ! empty( $obj->labels->singular_name ) ) ? strtolower( $obj->labels->singular_name ) : $post->post_type;
echo '';
echo '
'
. esc_html(
sprintf(
/* translators: %s: the content type in lowercase, e.g. "post", "page", "product". */
__( 'Topics this %s is about — so AI assistants understand and cite it correctly. Separate with commas.', 'agentimus' ),
$noun
)
)
. '
';
echo '';
// Only offer "derive from taxonomies" where the content type actually has one
// (core: tags/categories; a vendor may add its own via agentimus_derive_taxonomies).
// On a type with none — e.g. a plain Page — the checkbox would do nothing, so it's
// hidden rather than shown as a dead control.
if ( ! empty( self::derive_taxonomies( $post ) ) ) {
echo '';
}
// Autocomplete from a consistent vocabulary — the chip input references this
// via `list` (see inline_js), so the browser suggests as the author types.
$suggestions = self::suggestions();
if ( ! empty( $suggestions ) ) {
echo '';
}
echo '
';
}
/**
* Progressively enhance the editor meta box into a chip input with a live
* preview. Vanilla JS + CSS, inlined on a registered handle (the same pattern
* as the admin menu-icon style) so there is no build step and nothing loads on
* screens that don't need it — only the post/page editor for a covered type.
*
* @param string $hook Current admin page hook.
*/
public function assets( $hook ) {
if ( ! self::enabled() ) {
return;
}
if ( 'post.php' !== $hook && 'post-new.php' !== $hook ) {
return;
}
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
if ( ! $screen || ! in_array( $screen->post_type, Content::post_types(), true ) ) {
return;
}
$handle = 'agentimus-topics';
wp_register_script( $handle, false, array(), AGENTIMUS_VERSION, true );
wp_enqueue_script( $handle );
wp_add_inline_script( $handle, self::inline_js() );
wp_register_style( $handle, false, array(), AGENTIMUS_VERSION );
wp_enqueue_style( $handle );
wp_add_inline_style( $handle, self::inline_css() );
}
/**
* The chip-editor enhancement script. Self-contained vanilla JS: turns
* .agentimus-topics__raw into a chip strip (manual chips you can remove, plus
* tag/category "auto" chips when derivation is on) and keeps the hidden textarea in
* sync for submission.
*
* @return string
*/
private static function inline_js() {
return <<<'JS'
(function(){
// Arriving through an "Improve meta title & description" door: the link's
// hash names this box. Gutenberg mounts meta boxes late and moves them, so
// the browser's own anchor jump silently misses — retry briefly, land the
// box in view, and put the cursor ON the field the link promised. Focus is
// preventScroll so the box's own scroll position stays where we put it.
if (window.location.hash === '#agentimus-topics') {
var landTries = 40;
var land = function () {
var box = document.getElementById('agentimus-topics');
if (!box || box.offsetParent === null) {
if (landTries-- > 0) { setTimeout(land, 150); }
return;
}
// Center the FIELD, not the box: the box is tall, and centering it
// parks its top — where the title field lives — above the viewport,
// leaving the cursor in an input nobody can see.
var input = document.getElementById('agentimus-seo-title-input');
(input || box).scrollIntoView({ block: 'center' });
if (input) {
try { input.focus({ preventScroll: true }); } catch (e) { input.focus(); }
}
};
setTimeout(land, 300);
}
})();
(function(){
var root = document.querySelector('.agentimus-topics');
if (!root) { return; }
var cfg; try { cfg = JSON.parse(root.getAttribute('data-config') || '{}'); } catch (e) { return; }
var textarea = root.querySelector('.agentimus-topics__raw');
var checkbox = root.querySelector('input[name="agentimus_topics_derive"]');
if (!textarea) { return; }
var cap = cfg.cap > 0 ? cfg.cap : 12;
var maxLen = cfg.maxLen > 0 ? cfg.maxLen : 60;
var derived = Array.isArray(cfg.derived) ? cfg.derived : [];
var showAuto = checkbox ? checkbox.checked : false;
function lc(s){ return String(s).toLowerCase(); }
function clean(s){ return String(s).replace(/<[^>]*>/g,'').replace(/\s+/g,' ').trim().slice(0,maxLen); }
function splitList(str){ return String(str||'').split(/[\r\n,]+/).map(clean).filter(Boolean); }
function normalize(list){ var out=[],seen={}; for(var i=0;i=cap){break;} } return out; }
var manual = normalize(splitList(textarea.value));
// ---- chip editor (progressive enhancement of the textarea) ----
var editor = document.createElement('div');
editor.className = 'agentimus-topics__chips';
var input = document.createElement('input');
input.type = 'text';
input.className = 'agentimus-topics__input';
input.id = 'agentimus-topics-chipinput';
input.setAttribute('placeholder', textarea.getAttribute('placeholder') || '');
if (document.getElementById('agentimus-topics-suggest')) { input.setAttribute('list','agentimus-topics-suggest'); }
textarea.style.display = 'none';
textarea.setAttribute('tabindex','-1');
textarea.setAttribute('aria-hidden','true');
textarea.parentNode.insertBefore(editor, textarea);
editor.appendChild(input);
function syncTextarea(){ textarea.value = manual.join(', '); }
// The unified topic set: manual chips, then tag/category ("auto") chips when the
// derive box is on. Auto chips a manual entry already covers are dropped (dedupe).
function currentTopics(){
var out = manual.map(function(n){ return { name:n, kind:'manual' }; });
if (showAuto) {
var seen = {}; manual.forEach(function(n){ seen[lc(n)] = 1; });
derived.forEach(function(n){ var k = lc(n); if (!seen[k]) { seen[k] = 1; out.push({ name:n, kind:'auto' }); } });
}
return out;
}
function renderChips(){
var chips = editor.querySelectorAll('.agentimus-topics__chip');
for (var i=0;i 0) { setTimeout(function(){ start(tries - 1); }, 300); } return; }
wp.data.subscribe(function(){
var next = compute();
if (!next) { return; }
var key = next.slice().sort().join('');
if (key !== lastKey) { lastKey = key; derived = next; if (showAuto) { renderChips(); } }
});
}
start(20);
})();
})();
JS;
}
/**
* Styles for the chip editor. WordPress-admin palette; scoped to our container.
*
* @return string
*/
private static function inline_css() {
// Header reads like a core sidebar panel (Categories/Tags): title + the
// collapse toggle only — Heera's call 2026-07-25. The classic move-up/down
// arrows add ~88px of chrome (wider still since 7.1 wrapped them in
// tooltips) and forced long titles onto two lines in the 280px sidebar;
// the box stays drag-sortable by its header. Long titles can still wrap
// (no nowrap — see Admin::brand_title()) rather than overflow.
return '#agentimus-topics .handle-order-higher,#agentimus-topics .handle-order-lower{display:none}'
. '.agentimus-topics__chips{display:flex;flex-wrap:wrap;gap:5px;align-items:center;padding:5px;min-height:36px;border:1px solid #8c8f94;border-radius:4px;background:#fff;cursor:text}'
. '.agentimus-topics__chips:focus-within{border-color:#2271b1;box-shadow:0 0 0 1px #2271b1}'
. '.agentimus-topics__chip{display:inline-flex;align-items:center;gap:5px;background:#f0f0f1;border:1px solid #dcdcde;border-radius:3px;padding:2px 4px 2px 8px;font-size:12px;line-height:1.7;color:#2c3338}'
. '.agentimus-topics__chip.is-auto{background:#f4f1fa;border-color:#ddd4ee;border-style:dashed;color:#5b5170}'
. '.agentimus-topics__chip-auto{font-size:9px;letter-spacing:.05em;text-transform:uppercase;color:#8073a6;background:#ece5f7;border-radius:3px;padding:1px 4px}'
. '.agentimus-topics__chip-remove{border:0;background:transparent;cursor:pointer;font-size:14px;line-height:1;color:#787c82;padding:0 2px;border-radius:2px}'
. '.agentimus-topics__chip-remove:hover{color:#d63638}'
// The chip-add input sits on its own row below the chips (flex-basis:100%);
// the scoped selector out-specifies WordPress admin input chrome.
. '.agentimus-topics__chips .agentimus-topics__input,.agentimus-topics__chips .agentimus-topics__input:focus{flex:1 0 100%;min-width:0;margin:0;border:0;outline:0;box-shadow:none;background:transparent;min-height:0;padding:3px 2px;font-size:13px;color:inherit}';
}
/**
* Persist the meta box on save. Guarded by nonce, autosave/revision checks and
* the edit_post capability; only acts when this POST actually carried our meta
* box, so a quick-edit or REST save never wipes the topics.
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post — unused (kept for the save_post signature).
*/
public function save( $post_id, $post ) {
if ( ! MetaSave::verified( $post_id, self::NONCE ) ) {
return;
}
$raw = isset( $_POST['agentimus_topics'] ) ? wp_unslash( $_POST['agentimus_topics'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitised by sanitize_manual() below.
$manual = self::sanitize_manual( $raw );
if ( empty( $manual ) ) {
delete_post_meta( $post_id, self::META_TOPICS );
} else {
update_post_meta( $post_id, self::META_TOPICS, $manual );
}
// The checkbox is absent from the POST when unchecked — record the explicit
// choice either way, so the site default only applies until the first save.
update_post_meta( $post_id, self::META_DERIVE, isset( $_POST['agentimus_topics_derive'] ) ? '1' : '0' );
}
/**
* Coerce an array, or a comma/newline-delimited string, into a list of strings.
*
* @param mixed $value Raw value.
* @return string[]
*/
private static function to_list( $value ) {
if ( is_string( $value ) ) {
$value = preg_split( '/[\r\n,]+/', $value );
}
return is_array( $value ) ? array_values( $value ) : array();
}
/**
* Normalise a raw list: strip tags, trim, drop blanks, clip each item to
* MAX_LEN, dedupe case-insensitively (keeping first-seen casing) and cap the
* count.
*
* @param mixed $list Raw list.
* @param int $cap Max items.
* @return string[]
*/
private static function normalize( $list, $cap ) {
$out = array();
$seen = array();
foreach ( (array) $list as $item ) {
$item = trim( wp_strip_all_tags( (string) $item ) );
if ( '' === $item ) {
continue;
}
$item = self::clip( $item, self::MAX_LEN );
$key = strtolower( $item );
if ( isset( $seen[ $key ] ) ) {
continue;
}
$seen[ $key ] = true;
$out[] = $item;
if ( $cap > 0 && count( $out ) >= $cap ) {
break;
}
}
return $out;
}
/**
* Clip a string to a character length, multibyte-aware when the extension is
* present.
*
* @param string $value Value.
* @param int $len Max characters.
* @return string
*/
private static function clip( $value, $len ) {
if ( function_exists( 'mb_substr' ) ) {
return mb_substr( $value, 0, $len );
}
return substr( $value, 0, $len );
}
/**
* A de-duplicated pool of topics to suggest in the editor, so authors reuse a
* consistent vocabulary instead of fragmenting it ("WP" vs "WordPress"). Drawn
* from topics already used elsewhere, the site's own tags & categories, and the
* declared Expertise. Bounded and cached (busts when a post's topics change).
*
* @return string[]
*/
public static function suggestions() {
$cached = get_transient( self::SUGGEST_TRANSIENT );
if ( is_array( $cached ) ) {
return $cached;
}
$pool = array();
// 1. Topics already used on other posts (bounded scan, most-used first).
global $wpdb;
if ( isset( $wpdb ) && is_object( $wpdb ) ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$rows = $wpdb->get_col( $wpdb->prepare( "SELECT meta_value FROM {$wpdb->postmeta} WHERE meta_key = %s LIMIT 500", self::META_TOPICS ) );
$freq = array();
foreach ( (array) $rows as $val ) {
foreach ( (array) maybe_unserialize( $val ) as $topic ) {
$topic = trim( (string) $topic );
if ( '' === $topic ) {
continue;
}
$key = strtolower( $topic );
if ( ! isset( $freq[ $key ] ) ) {
$freq[ $key ] = array( 'label' => $topic, 'n' => 0 );
}
++$freq[ $key ]['n'];
}
}
uasort( $freq, static function ( $a, $b ) { return $b['n'] - $a['n']; } );
foreach ( $freq as $row ) {
$pool[] = $row['label'];
}
}
// 2. The site's own tags & categories — the canonical existing vocabulary.
foreach ( array( 'category', 'post_tag' ) as $tax ) {
$terms = get_terms( array( 'taxonomy' => $tax, 'hide_empty' => false, 'number' => 100, 'fields' => 'names' ) );
if ( ! is_wp_error( $terms ) ) {
$pool = array_merge( $pool, (array) $terms );
}
}
// 3. The site's declared Expertise (its knowsAbout pillars).
$pool = array_merge( $pool, (array) ( new Settings() )->identity( 'expertise', array() ) );
/**
* Filter the topic suggestions offered in the editor's Topics-for-AI box.
*
* @param string[] $pool Suggested topic strings.
*/
$pool = apply_filters( 'agentimus_topic_suggestions', $pool );
$out = array();
$seen = array();
foreach ( (array) $pool as $item ) {
$item = trim( wp_strip_all_tags( (string) $item ) );
if ( '' === $item ) {
continue;
}
// Never SUGGEST a bare number ("67", a stray ID) as a topic — same rule the
// derived/emitted topics use, so the editor's own autocomplete stays clean.
if ( ! self::is_meaningful_topic_name( $item ) ) {
continue;
}
$key = strtolower( $item );
if ( isset( $seen[ $key ] ) ) {
continue;
}
$seen[ $key ] = true;
$out[] = $item;
if ( count( $out ) >= 150 ) {
break;
}
}
set_transient( self::SUGGEST_TRANSIENT, $out, HOUR_IN_SECONDS );
return $out;
}
/**
* Topic coverage across published, agent-visible content, for the Readiness
* report: how many such posts exist and how many carry their own (manual) topics,
* plus whether auto-fill is on by default. Cheap — two counts, no per-post render.
*
* @return array{total:int,with_topics:int,derive:bool}
*/
public static function coverage() {
$types = Content::post_types();
$total = 0;
foreach ( $types as $type ) {
$counts = wp_count_posts( $type );
if ( is_object( $counts ) && isset( $counts->publish ) ) {
$total += (int) $counts->publish;
}
}
$with = 0;
global $wpdb;
if ( ! empty( $types ) && isset( $wpdb ) && is_object( $wpdb ) ) {
$placeholders = implode( ', ', array_fill( 0, count( $types ), '%s' ) );
$args = array_merge( array( self::META_TOPICS, 'a:0:{}' ), $types );
// ReplacementsWrongNumber is a FALSE POSITIVE here, not a suppressed bug: the sniff
// counts the literal placeholders it can see and cannot evaluate $placeholders, which
// array_fill() builds to match $types exactly. $args is passed as prepare()'s single
// array argument, which WPDB explicitly supports, so the counts do line up at runtime.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
$with = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(DISTINCT p.ID) FROM {$wpdb->posts} p INNER JOIN {$wpdb->postmeta} m ON m.post_id = p.ID WHERE m.meta_key = %s AND m.meta_value <> '' AND m.meta_value <> %s AND p.post_status = 'publish' AND p.post_type IN ($placeholders)", $args ) );
}
return array(
'total' => $total,
'with_topics' => $with,
'derive' => (bool) ( new Settings() )->get( 'topics_derive_default', true ),
);
}
/**
* The per-page topic cap, clamped to a sane range. Public so the editor UI can
* enforce the same limit client-side.
*
* @return int
*/
public static function cap() {
$cap = (int) ( new Settings() )->get( 'topics_max', 12 );
return max( 1, min( 50, $cap ) );
}
}