Docs'; return $links; }); // AI Content Strategist — its methods live in their own file (a trait). Loaded // before the class is declared so `use AICOBR_Strategist_Trait;` resolves. require_once __DIR__ . '/acb-strategist.php'; require_once __DIR__ . '/acb-link-scanner.php'; // AICOBR_LINK_SCANNER_2026_08 require_once __DIR__ . '/acb-inbound-links.php'; // AICOBR_INBOUND_LINKS_2026_08 require_once __DIR__ . '/acb-cluster-stats.php'; // AICOBR_CLUSTER_STATS_2026_08 class AICOBR_Plugin { use AICOBR_Strategist_Trait; /** * One-time, idempotent migration of stored data from the legacy option * prefixes (ai_content_* / acb_*) to the distinct aicobr_ prefix. * * Old options are intentionally KEPT for one release as a rollback safety * net; a later release removes them. Guarded by a versioned flag so the * copy runs exactly once. */ public static function aicobr_maybe_migrate_prefixes() { if (get_option('aicobr_prefix_migrated') === '2.8.1') { return; } $map = [ 'ai_content_settings' => 'aicobr_settings', 'ai_content_entries' => 'aicobr_entries', 'ai_content_refund_requests' => 'aicobr_refund_requests', 'ai_content_logs' => 'aicobr_logs', 'acb_generate_secret' => 'aicobr_generate_secret', 'acb_mailpoet_drafts' => 'aicobr_mailpoet_drafts', 'acb_tnp_drafts' => 'aicobr_tnp_drafts', ]; foreach ($map as $old => $new) { if (get_option($new, null) === null) { $value = get_option($old, null); if ($value !== null) { add_option($new, $value, '', false); } } } update_option('aicobr_prefix_migrated', '2.8.1', false); } /** * Read the "generated by this plugin" post meta, falling back to the * legacy _acb_generated key for posts created before 2.8.1. */ private static function aicobr_is_generated_post($post_id) { return get_post_meta($post_id, '_aicobr_generated', true) || get_post_meta($post_id, '_acb_generated', true); } /** Current plugin version — keep in sync with the header block above. */ const VERSION = '2.11.2'; /** * No shared secret is shipped in source. The live value is fetched from the * licence server (server-to-server) during verify/registration and stored in * the `aicobr_generate_secret` option — see get_secret() / maybe_store_generate_secret(). */ const GENERATE_SECRET = ''; /** Resolve the active shared secret (synced from the server into the aicobr_generate_secret option). */ public static function get_secret() { $opt = get_option('aicobr_generate_secret'); return (is_string($opt) && $opt !== '') ? $opt : self::GENERATE_SECRET; } /** * Persist the live generate secret the licence server returns on verify/register. * These calls are server-to-server (the value never reaches the browser), so this * keeps every install in sync with the server even if the secret is rotated, and * removes any reliance on the hardcoded fallback. */ public static function maybe_store_generate_secret($body) { if (is_array($body) && !empty($body['generate_secret'])) { $secret = sanitize_text_field($body['generate_secret']); if ($secret !== get_option('aicobr_generate_secret')) { update_option('aicobr_generate_secret', $secret); } } } /** Constant-time check that a request carries the shared secret header. */ public static function verify_secret($request) { $incoming = (string) $request->get_header('x-generate-secret'); return $incoming !== '' && hash_equals(self::get_secret(), $incoming); } /** * Fallback licence-server base URL. Override per install via the * `aicobr_license_server` option (set it to your ACB server domain). */ const LICENSE_SERVER = 'https://api.aicontentbridge.com'; /** Resolve the licence-server base URL (stored option preferred), trailing slash stripped. */ public static function get_server_url() { $opt = get_option('aicobr_license_server'); $url = (is_string($opt) && $opt !== '') ? $opt : self::LICENSE_SERVER; return rtrim($url, '/'); } /** * Refresh the cached plan tier from the licence server using the stored key. * * The UI gates paid features (Style Profiles, premium content types, YouTube, * ACF) on aicobr_settings['plan_tier']. That cache was previously only * written when the user manually verified their key, so after a Stripe * upgrade/renewal/downgrade the licence server tier would change while the * cache stayed stale — leaving features wrongly locked (or unlocked). This * pulls the authoritative tier from the server and updates the cache. * * Throttled to once per TTL via a transient so the Dashboard's frequent * settings poll can't hammer the licence server. On any failure the existing * cached tier is left untouched (fail-safe — never downgrade on a blip). * * @param bool $force Skip the TTL throttle and refresh immediately * (used on return from Stripe checkout). * @return string|null Normalised tier on a successful refresh, else null. */ public static function refresh_license_tier($force = false) { $settings = get_option('aicobr_settings', []); $license_key = $settings['license_key'] ?? ''; if (empty($license_key)) { return null; // no key stored — nothing to refresh against } $ttl = 60; // seconds if (!$force && get_transient('aicobr_tier_refreshed_at') !== false) { return null; // refreshed recently — skip } // Mark the attempt up-front so rapid concurrent polls don't all fire, // and so a persistently unreachable server isn't hit every poll. set_transient('aicobr_tier_refreshed_at', time(), $ttl); $response = wp_remote_post(self::get_server_url() . '/api/verify-license', [ 'timeout' => 8, 'headers' => ['Content-Type' => 'application/json'], 'body' => wp_json_encode([ 'license_key' => $license_key, 'email' => $settings['email'] ?? '', 'domain' => wp_parse_url(home_url(), PHP_URL_HOST), ]), ]); if (is_wp_error($response)) { return null; // keep existing cached tier on network error } $code = wp_remote_retrieve_response_code($response); $body = json_decode(wp_remote_retrieve_body($response), true); if ($code !== 200 || empty($body['success'])) { return null; // keep existing cached tier on bad/invalid response } self::maybe_store_generate_secret($body); // Normalise tier — identical mapping to verify_license(). $raw = strtolower(sanitize_text_field($body['plan'] ?? 'free')); if (strpos($raw, 'agency') !== false) $tier = 'agency'; elseif (strpos($raw, 'pro') !== false) $tier = 'pro'; elseif (strpos($raw, 'starter') !== false) $tier = 'starter'; else $tier = 'free'; if (($settings['plan_tier'] ?? null) !== $tier) { $settings['plan_tier'] = $tier; update_option('aicobr_settings', $settings); } return $tier; } public function __construct() { add_action('plugins_loaded', [self::class, 'aicobr_maybe_migrate_prefixes']); add_action('rest_api_init', [$this, 'register_endpoints']); add_action('admin_menu', [$this, 'add_admin_menu']); add_action('admin_enqueue_scripts', [$this, 'enqueue_assets']); // ── Front-end styled content (the .acb-content layer) ── add_action('wp_enqueue_scripts', [$this, 'enqueue_frontend_styles']); add_filter('wp_kses_allowed_html', [$this, 'aicobr_allow_accordion_tags'], 10, 2); // AICOBR_CLUSTER_SHORTCODE_2026_08 // Renders the cluster's spoke pages as a links block on the pillar page. // Registered on init (not plugins_loaded) so post types and permalinks // are ready. This is the ONLY downward-linking mechanism: ACB never // writes into the user's pillar page — the user places this shortcode // and WordPress renders the block from live diary data on each view. add_action('init', [$this, 'aicobr_register_shortcodes']); // A spoke can leave 'publish' outside the generation flow — the user // trashes it, drafts it, or schedules it. Without this the cached block // would keep listing it (and link to a 404) until the transient expired. add_action('transition_post_status', [$this, 'aicobr_cluster_status_changed'], 10, 3); // AICOBR_MB_GUARD_2026_08 — surface a missing mbstring extension rather // than letting it fail somewhere obscure. ACB's own code guards every // mb_* call and degrades to the byte-wise equivalent, but the bundled // Markdown parser does not, so a generation whose content contains a tab // character can still fatal. Declared as a requirement and stated here. add_action('admin_notices', [$this, 'aicobr_mbstring_notice']); } /** * AICOBR_MB_GUARD_2026_08 * * True when the mbstring extension is available. * * WordPress does not require mbstring, so this cannot be assumed. Kept as a * single named check rather than scattered extension_loaded() calls, so the * requirement has one definition. */ public static function aicobr_has_mbstring() { return function_exists('mb_substr') && function_exists('mb_strlen') && function_exists('mb_strtolower'); } /** * Admin warning when mbstring is absent. * * Deliberately restrained: shown only to users who could actually act on it, * and only on the Plugins screen and ACB's own pages, so it never becomes * furniture on unrelated screens. Not dismissible, because it describes a * live functional gap rather than an announcement, and a dismissed warning * turns into an unreproducible support ticket months later. */ public function aicobr_mbstring_notice() { if (self::aicobr_has_mbstring()) return; if (!current_user_can('activate_plugins')) return; $screen = function_exists('get_current_screen') ? get_current_screen() : null; $id = $screen ? (string) $screen->id : ''; $here = ($id === 'plugins' || strpos($id, 'ai-content-bridge') !== false); if (!$here) return; echo '

' . esc_html__('AI Content Bridge: the PHP mbstring extension is not enabled on this server.', 'ai-content-bridge') . '

' . esc_html__('Most of the plugin degrades safely without it, but content containing certain characters can fail to render, and text may be trimmed at the wrong point. Ask your host to enable mbstring. It is a standard PHP extension and enabling it is usually a one-line change.', 'ai-content-bridge') . '

'; } /** * Load the scoped content stylesheet + heading font, but only on singular * posts that ACB generated — never site-wide. */ public function enqueue_frontend_styles() { if (is_admin() || !is_singular()) return; $post = get_post(); if (!$post) return; // AICOBR_CLUSTER_SHORTCODE_2026_08 // The stylesheet normally loads only on ACB-generated posts. A pillar // page is the user's OWN page — not ACB-generated — so without this the // [content_cluster] block would render unstyled. Load the sheet when the // shortcode is present on the page regardless of who wrote it. $has_cluster_block = has_shortcode((string) $post->post_content, 'content_cluster') || has_shortcode((string) $post->post_content, 'aicobr_cluster') // AICOBR_INBODY_INSERT_2026_08 — the image fallback // shortcode is placed on posts ACB would not edit, // which by definition are not ACB-generated, so the // figure would render unstyled without this. || has_shortcode((string) $post->post_content, 'content_image') || has_shortcode((string) $post->post_content, 'content_featured_image') || has_shortcode((string) $post->post_content, 'aicobr_featured_image'); if (!self::aicobr_is_generated_post($post->ID) && !$has_cluster_block) return; $settings = get_option('aicobr_settings', []); // Master switch — default ON when never configured, so existing installs // keep the current styled output until a user opts out. $styling_on = !array_key_exists('enable_frontend_styling', $settings) || !empty($settings['enable_frontend_styling']); if (!$styling_on) return; $css_path = plugin_dir_path(__FILE__) . 'ai-content-bridge-frontend.css'; $ver = @filemtime($css_path) ?: '2.7.3'; // Fraunces is self-hosted (fonts/fraunces-var.woff2, SIL OFL — see // fonts/OFL.txt) via @font-face in the stylesheet below. No third-party // requests are made from visitors' browsers. wp_enqueue_style('aicobr-frontend', plugin_dir_url(__FILE__) . 'ai-content-bridge-frontend.css', [], $ver); // Inline overrides layered on top of the stylesheet. $inline = ''; // Accent colour: the brand_color setting drives --acb-accent; otherwise // the stylesheet's ACB-blue default applies. $accent = !empty($settings['brand_color']) ? sanitize_hex_color($settings['brand_color']) : ''; if ($accent) { $inline .= ':root{--acb-accent:' . esc_attr($accent) . ';}'; } // Serif post title — default ON. When disabled, reset just the title // font to inherit so it follows the theme; component styling and the // blockquote pull-quote serif are untouched. $serif_on = !array_key_exists('style_post_title_serif', $settings) || !empty($settings['style_post_title_serif']); if (!$serif_on) { $inline .= '.elementor-page-title .elementor-heading-title,' . '.elementor-widget-theme-post-title .elementor-heading-title,' . '.wp-block-post-title,.entry-title{font-family:inherit !important;}'; } if ($inline !== '') { wp_add_inline_style('aicobr-frontend', $inline); } } /** * AICOBR_CLUSTER_SHORTCODE_2026_08 * * [content_cluster] — renders the published spoke pages of the cluster whose * pillar is the CURRENT post. Placed by the user on their own pillar page. * * Why a shortcode rather than writing links into the page: * - ACB never edits a post it did not create. No guard to relax, no risk * of trampling a hand-edited or page-builder layout. * - It is always correct. New spokes appear as they publish; deleted or * unpublished spokes drop out. Nothing to reconcile, nothing to go stale. * - It renders server-side in PHP, so the links are in the delivered HTML * and are crawlable exactly as if they had been typed in. * * Attributes: * heading — block heading. Empty string renders no heading. Default set below. * limit — max links to show. 0 (default) = all. * * Renders an empty string when the cluster has no published spokes yet, so a * pillar page never shows an empty box. */ /** * Any status change on an ACB-generated post invalidates the cached cluster * block of the pillar it belongs to. Cheap: only touches posts ACB created, * and only reads the entries option for those. */ public function aicobr_cluster_status_changed($new_status, $old_status, $post) { if ($new_status === $old_status) return; if (!$post || !isset($post->ID)) return; if (!self::aicobr_is_generated_post($post->ID)) return; $entries = get_option('aicobr_entries', []); if (!is_array($entries)) return; foreach ($entries as $e) { if (absint($e['post_id'] ?? 0) !== absint($post->ID)) continue; if (empty($e['cluster_pillar']['post_id'])) continue; $this->aicobr_bust_cluster_cache($e['cluster_pillar']['post_id']); break; } } public function aicobr_register_shortcodes() { // AICOBR_CLUSTER_SHORTCODE_RENAME_2026_08 // [content_cluster] is the name users are told and the one in the docs. // [aicobr_cluster] stays registered PERMANENTLY as an alias: anyone who // placed the original would otherwise lose their block silently on // update — no error, the section simply vanishes from their page. add_shortcode('content_cluster', [$this, 'aicobr_cluster_shortcode']); add_shortcode('aicobr_cluster', [$this, 'aicobr_cluster_shortcode']); // AICOBR_INBODY_INSERT_2026_08 — the fallback for posts ACB will not // edit (page-builder pages, or a section that has since been renamed). // Renders the same
the direct insertion would have written, so // the result is identical either way. add_shortcode('content_image', [$this, 'aicobr_image_shortcode']); // AICOBR_HERO_IMAGE_2026_08 — renders the post's featured image at the // top of the content. Auto-inserted at publish so a post looks finished // on any theme, and deletable like any other line of the post. add_shortcode('content_featured_image', [$this, 'aicobr_hero_shortcode']); add_shortcode('aicobr_featured_image', [$this, 'aicobr_hero_shortcode']); } /** * Collect the published spokes whose pillar is $pillar_id. * Cached in a transient because this runs on a public page that may take * traffic; busted by aicobr_bust_cluster_cache() whenever a spoke publishes. */ private function aicobr_cluster_spokes($pillar_id) { $pillar_id = absint($pillar_id); if ($pillar_id <= 0) return []; $cache_key = 'aicobr_cluster_' . $pillar_id; $cached = get_transient($cache_key); if (is_array($cached)) return $cached; $entries = get_option('aicobr_entries', []); if (!is_array($entries)) $entries = []; $spokes = []; foreach ($entries as $e) { if (empty($e['cluster_pillar']) || !is_array($e['cluster_pillar'])) continue; if (absint($e['cluster_pillar']['post_id'] ?? 0) !== $pillar_id) continue; $sid = absint($e['post_id'] ?? 0); if ($sid <= 0 || $sid === $pillar_id) continue; // never link a page to itself $sp = get_post($sid); if (!$sp || $sp->post_status !== 'publish') continue; $spokes[$sid] = [ // keyed by id — dedupes if a brief was regenerated 'id' => $sid, 'title' => html_entity_decode(get_the_title($sp), ENT_QUOTES, 'UTF-8'), 'url' => get_permalink($sp), 'excerpt' => html_entity_decode(wp_strip_all_tags(get_the_excerpt($sp)), ENT_QUOTES, 'UTF-8'), ]; } $spokes = array_values($spokes); // An EMPTY result is cached briefly, not for 12 hours. ACB generates to // draft, so the common first-run state is "shortcode placed, nothing // published yet" — caching that for half a day makes the block look // broken long after the user publishes. The status-change hook also // busts it, this is belt-and-braces for edits made outside WP. set_transient($cache_key, $spokes, empty($spokes) ? 5 * MINUTE_IN_SECONDS : 12 * HOUR_IN_SECONDS); return $spokes; } /** * Clear the cached cluster block for a pillar. Called on publish so a newly * generated spoke shows up immediately rather than after the transient * expires — the "it just appeared" behaviour is the point of the feature. */ private function aicobr_bust_cluster_cache($pillar_id) { $pillar_id = absint($pillar_id); if ($pillar_id > 0) delete_transient('aicobr_cluster_' . $pillar_id); } public function aicobr_cluster_shortcode($atts) { $atts = shortcode_atts([ 'heading' => 'Explore this topic', 'limit' => 0, ], $atts, 'content_cluster'); $pillar_id = get_the_ID(); if (!$pillar_id) return ''; $spokes = $this->aicobr_cluster_spokes($pillar_id); if (empty($spokes)) { // Nothing published yet. Visitors see nothing at all — but an editor // needs to know WHY, because ACB generates to draft and "I added the // shortcode and nothing happened" is otherwise a dead end. This note // is only ever rendered for users who can edit posts. if (current_user_can('edit_posts')) { $pending = 0; $entries = get_option('aicobr_entries', []); if (is_array($entries)) { foreach ($entries as $e) { if (absint($e['cluster_pillar']['post_id'] ?? 0) === $pillar_id) $pending++; } } if ($pending > 0) { return '

AI Content Bridge: ' . esc_html(sprintf( _n( '%d post in this cluster is not published yet, so nothing is shown here. Publish it and it will appear. Only you can see this message.', '%d posts in this cluster are not published yet, so nothing is shown here. Publish them and they will appear. Only you can see this message.', $pending, 'ai-content-bridge' ), $pending )) . '

'; } } return ''; // no empty box on a page with no cluster yet } $limit = absint($atts['limit']); if ($limit > 0) $spokes = array_slice($spokes, 0, $limit); $heading = trim((string) $atts['heading']); $out = ''; return $out; } /** * AICOBR_HERO_IMAGE_2026_08 * * [content_featured_image ratio="16:9"] * * Renders the post's own featured image at the top of the content. * * Why this exists: whether a featured image appears above the article is a * THEME decision. Plenty of themes never render the thumbnail at all, so an * ACB post that paid a credit for an image could publish looking like it had * none. This puts the image in the content itself, where it is guaranteed to * show. * * Why a shortcode rather than an : the user can delete one line if their * theme already displays the thumbnail (which would otherwise show it twice), * and — because it reads the CURRENT thumbnail at render time — regenerating * the featured image updates the hero with nothing to reconcile. The same * argument as [content_cluster]: rendered live beats stored and stale. * * Renders NOTHING when the post has no featured image, so a post generated * with include_image off, or one whose thumbnail was later removed, never * shows a broken block. */ public function aicobr_hero_shortcode($atts) { $atts = shortcode_atts([ 'ratio' => '16:9', 'id' => 0, ], $atts, 'content_featured_image'); $post_id = absint($atts['id']) ?: get_the_ID(); if (!$post_id) return ''; $attachment_id = get_post_thumbnail_id($post_id); if (!$attachment_id) return ''; $url = wp_get_attachment_url($attachment_id); if (!$url) return ''; // Alt text: prefer the attachment's own, fall back to the post title so // the image is never announced as unlabelled. $alt = trim((string) get_post_meta($attachment_id, '_wp_attachment_image_alt', true)); if ($alt === '') $alt = (string) get_the_title($post_id); // Only a small set of ratios, each with a real CSS class — an arbitrary // inline aspect-ratio would be one more thing a page builder could fight. $ratios = ['16:9' => 'r169', '3:2' => 'r32', '4:3' => 'r43', '1:1' => 'r11']; $rclass = $ratios[trim((string) $atts['ratio'])] ?? 'r169'; $w = $h = 0; $meta = wp_get_attachment_metadata($attachment_id); if (is_array($meta)) { $w = absint($meta['width'] ?? 0); $h = absint($meta['height'] ?? 0); } $dim = ($w && $h) ? ' width="' . $w . '" height="' . $h . '"' : ''; return '
' . '' . esc_attr($alt) . '' . '
'; } /** * AICOBR_INBODY_INSERT_2026_08 * * [content_image id="123" layout="band" alt="..." caption="..."] * * Renders the same figure markup ACB would have inserted directly. Offered * whenever direct insertion is refused, so the user always has a way to get * the image where they want it — the feature degrades visibly rather than * just not working. */ public function aicobr_image_shortcode($atts) { $atts = shortcode_atts([ 'id' => 0, 'layout' => 'band', 'alt' => '', 'caption' => '', ], $atts, 'content_image'); $id = absint($atts['id']); if (!$id) return ''; return self::aicobr_build_figure( $id, sanitize_text_field($atts['alt']), sanitize_text_field($atts['caption']), sanitize_text_field($atts['layout']) ); } /** Allow
/ through KSES so FAQ accordions survive sanitising. */ public function aicobr_allow_accordion_tags($tags, $context) { if ($context === 'post') { $tags['details'] = ['open' => true, 'class' => true, 'id' => true]; $tags['summary'] = ['class' => true]; } return $tags; } /** * Register a route under the primary prefixed namespace (aicobr/v1) and, * for a transition period, under the legacy namespace (ai-content/v1) so * that (a) the external generation service can still reach installs that * have not yet updated and (b) integrations built against pre-2.8.1 * installs keep working. The legacy alias will be removed in a future * release once the fleet has moved over. */ public function aicobr_register_route($route, $args) { register_rest_route('aicobr/v1', $route, $args); register_rest_route('ai-content/v1', $route, $args); } public function register_endpoints() { $this->aicobr_register_route('/entries', [ 'methods' => 'GET', 'callback' => [$this, 'get_entries'], 'permission_callback' => fn() => current_user_can('edit_posts') ]); $this->aicobr_register_route('/entries', [ 'methods' => 'POST', 'callback' => [$this, 'save_entries'], 'permission_callback' => fn() => current_user_can(apply_filters('aicobr_rest_capability', 'manage_options', 'entries_save')) ]); $this->aicobr_register_route('/entries/(?P\d+)', [ 'methods' => 'DELETE', 'callback' => [$this, 'delete_entry'], 'permission_callback' => fn() => current_user_can(apply_filters('aicobr_rest_capability', 'manage_options', 'entries_delete')) ]); $this->aicobr_register_route('/logs', [ 'methods' => 'GET', 'callback' => [$this, 'get_logs'], 'permission_callback' => fn() => current_user_can('manage_options') ]); $this->aicobr_register_route('/settings', [ 'methods' => 'GET', 'callback' => [$this, 'get_settings'], 'permission_callback' => fn() => current_user_can('manage_options') ]); $this->aicobr_register_route('/settings', [ 'methods' => 'POST', 'callback' => [$this, 'save_settings'], 'permission_callback' => fn() => current_user_can('manage_options') ]); $this->aicobr_register_route('/stats', [ 'methods' => 'GET', 'callback' => [$this, 'get_stats'], 'permission_callback' => fn() => current_user_can('edit_posts') ]); $this->aicobr_register_route('/usage', [ 'methods' => 'GET', 'callback' => [$this, 'get_usage'], 'permission_callback' => fn() => current_user_can('manage_options') ]); $this->aicobr_register_route('/credits', [ 'methods' => 'GET', 'callback' => [$this, 'get_credits'], 'permission_callback' => fn() => current_user_can('manage_options') ]); $this->aicobr_register_route('/generation-stage', [ 'methods' => 'POST', 'callback' => [$this, 'generation_stage'], 'permission_callback' => '__return_true' // secured by shared-secret check in handler ]); $this->aicobr_register_route('/reset-entry', [ 'methods' => 'POST', 'callback' => [$this, 'reset_entry'], 'permission_callback' => fn() => current_user_can(apply_filters('aicobr_rest_capability', 'manage_options', 'entry_reset')) ]); // AICOBR_APPROVAL_2026_08 — editorial sign-off on a generated brief. $this->aicobr_register_route('/approve-entry', [ 'methods' => 'POST', 'callback' => [$this, 'approve_entry'], 'permission_callback' => fn() => current_user_can(apply_filters('aicobr_rest_capability', 'manage_options', 'entry_approve')) ]); // AICOBR_INBOUND_LINKS_2026_08 — read-only candidate discovery. // edit_posts (not manage_options): reads only, and per-post capability // is re-checked for every candidate inside the finder. $this->aicobr_register_route('/inbound-suggestions', [ 'methods' => 'POST', 'callback' => [$this, 'inbound_suggestions'], 'permission_callback' => fn() => current_user_can(apply_filters('aicobr_rest_capability', 'edit_posts', 'inbound_suggestions')) ]); // AICOBR_RESCAN_CONTEXT_2026_08 — insert / undo an approved inbound link. // Route-level gate is the general edit_posts; the handler re-checks // edit_post on the SPECIFIC source post before writing. $this->aicobr_register_route('/insert-inbound-link', [ 'methods' => 'POST', 'callback' => [$this, 'insert_inbound_link'], 'permission_callback' => fn() => current_user_can(apply_filters('aicobr_rest_capability', 'edit_posts', 'inbound_insert')) ]); $this->aicobr_register_route('/remove-inbound-link', [ 'methods' => 'POST', 'callback' => [$this, 'remove_inbound_link'], 'permission_callback' => fn() => current_user_can(apply_filters('aicobr_rest_capability', 'edit_posts', 'inbound_remove')) ]); // AICOBR_CLUSTER_STATS_2026_08 — read-only execution state for a cluster. $this->aicobr_register_route('/cluster-stats', [ 'methods' => 'POST', 'callback' => [$this, 'cluster_stats'], 'permission_callback' => fn() => current_user_can(apply_filters('aicobr_rest_capability', 'edit_posts', 'cluster_stats')) ]); // AICOBR_LINK_FORECAST_2026_08 — read-only, and read-only about a post // that does not exist yet. Same edit_posts gate as the scan it predicts. $this->aicobr_register_route('/link-forecast', [ 'methods' => 'POST', 'callback' => [$this, 'link_forecast'], 'permission_callback' => fn() => current_user_can(apply_filters('aicobr_rest_capability', 'edit_posts', 'link_forecast')) ]); $this->aicobr_register_route('/publish', [ 'methods' => 'POST', 'callback' => [$this, 'handle_publish'], // Authenticated by the shared-secret header (server-to-plugin call). 'permission_callback' => [self::class, 'verify_secret'] ]); $this->aicobr_register_route('/trigger-generate', [ 'methods' => 'POST', 'callback' => [$this, 'trigger_generate'], 'permission_callback' => fn() => current_user_can(apply_filters('aicobr_rest_capability', 'manage_options', 'generate')) ]); $this->aicobr_register_route('/upload-image', [ 'methods' => 'POST', 'callback' => [$this, 'upload_image'], 'permission_callback' => '__return_true' // secured by generate_secret header check inside ]); $this->aicobr_register_route('/create-checkout', [ 'methods' => 'POST', 'callback' => [$this, 'create_checkout'], 'permission_callback' => fn() => current_user_can('manage_options') ]); $this->aicobr_register_route('/post-types', [ 'methods' => 'GET', 'callback' => [$this, 'get_post_types'], 'permission_callback' => fn() => current_user_can('edit_posts') ]); $this->aicobr_register_route('/acf-fields', [ 'methods' => 'GET', 'callback' => [$this, 'get_acf_fields'], 'permission_callback' => fn() => current_user_can('edit_posts') ]); // AICOBR_CLUSTER_PILLAR_PICKER_2026_08: feeds the Content Strategist's // "supporting pages for" picker. Read-only search over the site's own // published pages/posts so the user can choose the pillar their planned // cluster will link up to. Never writes; the pillar page is never edited. $this->aicobr_register_route('/pages', [ 'methods' => 'GET', 'callback' => [$this, 'get_linkable_pages'], 'permission_callback' => fn() => current_user_can('edit_posts') ]); $this->aicobr_register_route('/set-featured-image', [ 'methods' => 'POST', 'callback' => [$this, 'set_featured_image'], 'permission_callback' => fn() => current_user_can('edit_posts') ]); // Image-only regeneration — server-to-server: the licence server calls // this to append a freshly-generated image to an entry's image_history // (never touches the post text). Secured by the shared generate secret, // same as /upload-image and /publish. $this->aicobr_register_route('/append-image', [ 'methods' => 'POST', 'callback' => [$this, 'append_image'], 'permission_callback' => [self::class, 'verify_secret'] ]); // Browser → WP → licence server proxy that kicks off an image-only regen // (mirrors /trigger-generate). The browser can't call the licence server // directly — the shared secret and licence key never leave the server. $this->aicobr_register_route('/trigger-regenerate-image', [ 'methods' => 'POST', 'callback' => [$this, 'trigger_regenerate_image'], 'permission_callback' => fn() => current_user_can(apply_filters('aicobr_rest_capability', 'manage_options', 'regenerate_image')) ]); // AICOBR_INBODY_INSERT_2026_08 — insert a generated image into the body // of an ACB post. This is the ONLY path that edits post_content after // creation, so it is gated on edit_post for the specific post, on ACB // ownership, and on the content fingerprint. Refusals return a shortcode // the user can place by hand instead. // AICOBR_INBODY_IMAGES_2026_08 — retroactive suggestions for a post that // already exists. Read-only: proxies to the licence server and returns // descriptions. Generates nothing and spends no credit. $this->aicobr_register_route('/image-suggestions', [ 'methods' => 'POST', 'callback' => [$this, 'get_image_suggestions'], 'permission_callback' => fn() => current_user_can('edit_posts') ]); // AICOBR_YT_INSERT_FIX_2026_08 — server-side YouTube insertion. Replaces // a browser-side splice against core /wp/v2/posts that could not work on // page-type content and wrote rendered HTML back as raw. Same guards as // /insert-image, because it is the same class of operation. $this->aicobr_register_route('/insert-youtube', [ 'methods' => 'POST', 'callback' => [$this, 'insert_youtube_into_post'], 'permission_callback' => fn() => current_user_can('edit_posts') ]); $this->aicobr_register_route('/insert-image', [ 'methods' => 'POST', 'callback' => [$this, 'insert_image_into_post'], 'permission_callback' => fn() => current_user_can('edit_posts') ]); $this->aicobr_register_route('/verify-license', [ 'methods' => 'POST', 'callback' => [$this, 'verify_license'], 'permission_callback' => fn() => current_user_can('manage_options') ]); $this->aicobr_register_route('/register-free', [ 'methods' => 'POST', 'callback' => [$this, 'register_free'], 'permission_callback' => fn() => current_user_can('manage_options') ]); // AICOBR_WIZARD_POLL_2026_07_29 — verification polling + funnel telemetry // for the onboarding wizard. Both proxy to the licence server so its URL // stays server-side. $this->aicobr_register_route('/license-status', [ 'methods' => 'GET', 'callback' => [$this, 'license_status'], 'permission_callback' => fn() => current_user_can('manage_options') ]); $this->aicobr_register_route('/wizard-event', [ 'methods' => 'POST', 'callback' => [$this, 'wizard_event'], 'permission_callback' => fn() => current_user_can('manage_options') ]); $this->aicobr_register_route('/mailpoet-lists', [ 'methods' => 'GET', 'callback' => [$this, 'get_mailpoet_lists'], 'permission_callback' => function() { return current_user_can('edit_posts'); }, ]); $this->aicobr_register_route('/tec-status', [ 'methods' => 'GET', 'callback' => [$this, 'get_tec_status'], 'permission_callback' => function() { return current_user_can('edit_posts'); }, ]); $this->aicobr_register_route('/newsletter-lists', [ 'methods' => 'GET', 'callback' => [$this, 'get_newsletter_lists'], 'permission_callback' => function() { return current_user_can('edit_posts'); }, ]); $this->aicobr_register_route('/lms-data', [ 'methods' => 'GET', 'callback' => [$this, 'get_lms_data'], 'permission_callback' => function() { return current_user_can('edit_posts'); }, ]); $this->aicobr_register_route('/extract-style', [ 'methods' => 'POST', 'callback' => [$this, 'extract_style'], 'permission_callback' => fn() => current_user_can('edit_posts') ]); $this->aicobr_register_route('/outline', [ 'methods' => 'POST', 'callback' => [$this, 'get_outline'], 'permission_callback' => fn() => current_user_can('edit_posts') ]); // AI Content Strategist routes (preview / plan / populate). $this->register_strategist_routes(); } public function add_admin_menu() { add_menu_page( 'Content Bridge', 'Content Bridge', 'edit_posts', 'ai-content-bridge', [$this, 'admin_page'], 'data:image/svg+xml;base64,' . base64_encode(' '), 30 ); } public function admin_page() { echo '
'; } public function enqueue_assets($hook) { if ($hook !== 'toplevel_page_ai-content-bridge') return; // Keep the cached plan tier in sync with the licence server so paid // features unlock immediately. Force a refresh when returning from Stripe // checkout (?upgrade=success) — this runs before the localize below, so // aicobrConfig.planTier is already correct when the app mounts. Other loads // refresh at most once per minute (throttled inside the helper). // Stripe returns the buyer to ?upgrade=success; this is a read-only signal used // to force a licence-tier refresh. The value is unslashed + sanitized, and a // third-party payment redirect cannot carry a WordPress nonce. $upgrade_param = isset($_GET['upgrade']) ? sanitize_text_field(wp_unslash($_GET['upgrade'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only handling of a Stripe return URL (see note above) $just_upgraded = ($upgrade_param === 'success'); self::refresh_license_tier($just_upgraded); $asset_file = plugin_dir_path(__FILE__) . 'build/index.asset.php'; if (file_exists($asset_file)) { $asset = include $asset_file; $settings = get_option('aicobr_settings', []); // re-read after refresh wp_enqueue_script('aicobr-react', plugin_dir_url(__FILE__) . 'build/index.js', $asset['dependencies'], $asset['version'], true); // Self-hosted admin fonts (see fonts/ + aicobr-admin-fonts.css) — no // third-party font CDN requests from the admin screens. wp_enqueue_style('aicobr-admin-fonts', plugin_dir_url(__FILE__) . 'aicobr-admin-fonts.css', [], $asset['version']); wp_enqueue_style('aicobr-style', plugin_dir_url(__FILE__) . 'build/index.css', ['aicobr-admin-fonts'], $asset['version']); wp_localize_script('aicobr-react', 'aicobrConfig', [ 'wizardDismissed' => !empty($settings['_wizard_dismissed']), 'planTier' => $settings['plan_tier'] ?? 'free', // Real plugin URL (trailing slash) so the React app can resolve // bundled assets (e.g. the logo) without hardcoding the folder // name — survives the plugin folder being renamed. 'pluginUrl' => plugin_dir_url(__FILE__), ]); } } public function get_entries() { $locked = $this->aicobr_lock_entries(); $entries = get_option('aicobr_entries', []); // Server-authoritative timeout: any entry left 'generating' past the // threshold is flipped to 'failed' at read time, so a crashed/lost // pipeline can never leave a card stuck forever — independent of any // browser being open. $timeout = 600; // seconds (10 minutes) $now = time(); $changed = false; foreach ($entries as &$e) { $st = $e['status'] ?? ''; if ($st === 'generating' || $st === 'regenerating') { $started = isset($e['started_ts']) ? intval($e['started_ts']) : 0; if ($started > 0 && ($now - $started) > $timeout) { $e['status'] = 'failed'; $e['stage'] = 'failed'; $e['error_message'] = 'Generation timed out. Please retry.'; $e['started_ts'] = null; $changed = true; } } } unset($e); if ($changed) update_option('aicobr_entries', $entries); if ($locked) $this->aicobr_unlock_entries(); // Attach a correct front-end URL for each entry that has a post — computed // at read time, NOT persisted, so draft preview links keep a fresh nonce. // get_permalink()/get_preview_post_link() resolve correctly for ANY post // type and status, unlike the old hardcoded "/?p={id}" (post-type 'post', // published-only) the UI used to build. foreach ($entries as &$e2) { $pid = isset($e2['post_id']) ? intval($e2['post_id']) : 0; if ($pid > 0) { $st = get_post_status($pid); if ($st) { // AICOBR_SCHEDULE_PUBLISH_2026_08 // Surface the REAL WordPress state, read live and never // persisted, so the diary card and the post can't disagree. // Without this the diary showed only its own planned date // and the user had to open the post to learn whether it was // a draft, scheduled, or already live. $e2['wp_status'] = $st; if ($st === 'future') { $e2['wp_scheduled_at'] = get_post_time('Y-m-d H:i', false, $pid); } if (in_array($st, ['publish', 'private'], true)) { $e2['view_url'] = get_permalink($pid); } else { // Draft/pending preview. get_preview_post_link() can return an // empty string in REST context, which made the UI fall back to a // bare "/?p={id}" link that 404s for custom post types like // tribe_events. Build the canonical preview URL ourselves so it // always resolves: "/?post_type={type}&p={id}&preview=true". $preview = get_preview_post_link($pid); if (empty($preview)) { $ptype = get_post_type($pid) ?: 'post'; $preview = add_query_arg( ['post_type' => $ptype, 'p' => $pid, 'preview' => 'true'], home_url('/') ); } $e2['view_url'] = $preview; } } } } unset($e2); return rest_ensure_response(['entries' => $entries]); } /** * True if $post_id is referenced by any content-diary entry. Used as a * fallback ownership check in /publish for posts created before the * _aicobr_generated meta was stamped: if ACB has the post on a diary entry it is * genuinely ours, so regeneration is allowed and the meta is backfilled. */ /** * AICOBR_CLUSTER_PILLAR_2026_08 * * Resolve the pillar page a diary entry's cluster hangs off, for the * generation prompt. Returns null unless the entry is a spoke AND the * pillar post still exists AND it is still published — a link to a trashed * or drafted page is worse than no link, so we silently omit rather than * emit a dead one. * * The permalink is re-resolved live (not read from the stored snapshot) so * a slug change between planning and generation still produces a correct * URL. The stored url is kept only as a display value / fallback. */ private function aicobr_resolve_cluster_pillar($entry_id) { if ($entry_id === null || $entry_id === '') return null; $entries = get_option('aicobr_entries', []); if (!is_array($entries)) return null; $entry = null; foreach ($entries as $e) { if (isset($e['id']) && (string) $e['id'] === (string) $entry_id) { $entry = $e; break; } } if (!$entry || empty($entry['cluster_pillar']) || !is_array($entry['cluster_pillar'])) return null; $pillar_id = absint($entry['cluster_pillar']['post_id'] ?? 0); if ($pillar_id <= 0) return null; $pillar_post = get_post($pillar_id); if (!$pillar_post || $pillar_post->post_status !== 'publish') { aicobr_dbg('[ACB] cluster: pillar #' . $pillar_id . ' missing or unpublished — skipping internal link.'); return null; } return [ 'post_id' => $pillar_id, 'cluster_id' => (string) ($entry['cluster_id'] ?? ''), 'title' => html_entity_decode(get_the_title($pillar_post), ENT_QUOTES, 'UTF-8'), 'url' => get_permalink($pillar_post), ]; } /** * AICOBR_SCHEDULE_PUBLISH_2026_08 * * Resolve a diary entry's publish_date into a WordPress-schedulable local * datetime string, or null when the entry has no usable future date. * * Why this reads the STORED entry rather than the request body: exactly the * same argument as aicobr_resolve_cluster_pillar(). The plugin already sends * publish_date to the licence server, but the server never reads it and * never returns it on the publish payload — so it round-trips to nowhere. * Resolving it here keeps the fix inside one deploy path (the plugin), and * means the browser cannot assert a publication date for a post. * * The diary stores a DATE only (YYYY-MM-DD), so a time of day is supplied * from the schedule_time setting (default 09:00, site timezone). A date that * is not strictly in the future returns null — a "scheduled" post in the * past is just an immediate publish, which is emphatically not what a draft * workflow wants. * * @return string|null 'YYYY-MM-DD HH:MM:SS' in SITE local time. */ private function aicobr_resolve_schedule_datetime($entry_id) { if ($entry_id === null || $entry_id === '') return null; $entries = get_option('aicobr_entries', []); if (!is_array($entries)) return null; $entry = null; foreach ($entries as $e) { if (isset($e['id']) && (string) $e['id'] === (string) $entry_id) { $entry = $e; break; } } if (!$entry) return null; $date = trim((string) ($entry['publish_date'] ?? '')); if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) return null; $settings = get_option('aicobr_settings', []); $time = (string) ($settings['schedule_time'] ?? ''); if (!preg_match('/^([01]\d|2[0-3]):([0-5]\d)$/', $time)) $time = '09:00'; $local = $date . ' ' . $time . ':00'; // Compare in site-local terms: current_time('mysql') is the site's local // clock, which is the same basis as $local, so a naive string/strtotime // comparison is correct here without any timezone juggling. $now_local = current_time('mysql'); if (strtotime($local) === false) return null; if (strtotime($local) <= strtotime($now_local)) return null; // not in the future return $local; } /** * AICOBR_INBODY_IMAGES_2026_08 * * Sanitise the image_suggestions array the licence server sends on the * publish payload. These are DESCRIPTIONS, not images — nothing has been * generated and no credit has been spent. The user turns one into a real * image (1 credit) from the image modal. * * Everything is bounded and whitelisted, because the diary is ONE serialised * wp_options row: an unbounded string here is multiplied across every entry * and bloats the option for the whole site. The server already clamps these * (lib/image-suggestions.js), but this endpoint is authenticated by a shared * secret rather than by a user, so it re-validates rather than trusting. * * Returns [] for anything malformed — suggestions are a nice-to-have and * must never be able to break a publish. */ /** * Multibyte-safe truncation with a plain-substr fallback. mb_* is not * guaranteed on every WordPress host, so it is guarded — matching the * existing convention in acb-strategist.php (AICOBR_CLUSTER_TITLE_CAP). */ /** * AICOBR_INBODY_INSERT_2026_08 * * Fingerprint of the content ACB wrote. Stored at publish time as * _aicobr_content_hash and compared before ACB edits a post's body. * * Whitespace is normalised first so a trivial reserialisation by the block * editor (which reflows newlines between blocks without changing a word) * does not read as "the user rewrote this". */ private static function aicobr_content_hash($content) { $norm = preg_replace('/\s+/', ' ', (string) $content); return md5(trim((string) $norm)); } /** * True when a page builder owns this post's layout. * * This is the HARD block on in-body insertion. Elementor stores its layout in * _elementor_data postmeta and renders from it; editing post_content would * either be ignored entirely or desynchronise the two, and the user would * see their page silently diverge from what they built. Beaver Builder and * Divi behave the same way. No fingerprint check can rescue that case, so it * is refused outright and the user is offered a shortcode instead. */ private static function aicobr_is_page_builder_post($post_id) { $keys = [ '_elementor_data', // Elementor '_elementor_edit_mode', '_fl_builder_data', // Beaver Builder '_et_pb_use_builder', // Divi 'panels_data', // SiteOrigin '_wpb_vc_js_status', // WPBakery ]; foreach ($keys as $k) { $v = get_post_meta($post_id, $k, true); if (!empty($v) && $v !== 'off' && $v !== 'false') return true; } return false; } /** * Build the
markup for an in-body image. * *
and NOT a
wrapper, deliberately: the step-card grouping in * AICOBR_Content_Formatter::steps() relies on step bodies containing no * nested
, so a wp-block-image div dropped inside a "### Step 3" section * silently breaks the numbered cards. * * Attributes are limited to src / alt / class / width / height, all of which * wp_kses_allowed_html('post') permits, so the markup survives both the * sanitiser here and any later save by a user without unfiltered_html. */ private static function aicobr_build_figure($attachment_id, $alt, $caption, $layout) { $url = wp_get_attachment_url($attachment_id); if (!$url) return ''; $layouts = ['band', 'inline-left', 'inline-right']; if (!in_array($layout, $layouts, true)) $layout = 'band'; $w = $h = 0; $meta = wp_get_attachment_metadata($attachment_id); if (is_array($meta)) { $w = absint($meta['width'] ?? 0); $h = absint($meta['height'] ?? 0); } $dim = ($w && $h) ? ' width="' . $w . '" height="' . $h . '"' : ''; $out = '
'; $out .= '' . esc_attr($alt) . ''; if ($caption !== '') { $out .= '
' . esc_html($caption) . '
'; } $out .= '
'; return $out; } /** * Locate every H2 in rendered content, with byte offsets. * Returns [ ['text'=>..., 'start'=>int, 'end'=>int], ... ] in document order. */ private static function aicobr_find_headings($html) { $out = []; if (!preg_match_all('/]*>(.*?)<\/h2>/is', (string) $html, $m, PREG_OFFSET_CAPTURE | PREG_SET_ORDER)) { return $out; } foreach ($m as $hit) { $out[] = [ 'text' => trim(wp_strip_all_tags($hit[1][0])), 'start' => $hit[0][1], 'end' => $hit[0][1] + strlen($hit[0][0]), ]; } return $out; } /** Comparable form of a heading — case, punctuation and spacing collapsed. */ private static function aicobr_heading_key($s) { $s = html_entity_decode(wp_strip_all_tags((string) $s), ENT_QUOTES, 'UTF-8'); $s = strtolower($s); $s = preg_replace('/[^\p{L}\p{N}\s]/u', ' ', $s); return trim(preg_replace('/\s+/', ' ', $s)); } /** * AICOBR_INBODY_FLOAT_ANCHOR_2026_08 * * Where inside a section a floated figure may safely be inserted. * * A float is wrapped by the boxes that FOLLOW it, and several things in an * ACB article cannot do that job: * - .acb-steps is display:grid and .acb-faq / .acb-tablewrap use overflow, * so each establishes its own formatting context and sits BESIDE the * float rather than flowing around it; * - .acb-callout and .acb-takeaways render a background and border that * would slide underneath the float. * So the figure has to go after any component that opens the section, and * before the prose. * * This must not assume

tags exist. Classic-editor content stores * paragraphs as bare text between block elements and lets wpautop() create * the

at render time — a real ACB landing page can contain zero

in * post_content. Rather than hunting for paragraphs, this skips forward past * any leading block components and


rules and returns the first offset * that is not one, which is correct for both content shapes. * * Returns an offset in [$from, $to], never false: with nothing to skip it * returns $from, which is the position directly under the heading. */ private static function aicobr_prose_anchor_offset($html, $from, $to) { $offset = $from; while ($offset < $to) { // Skip whitespace and stray newlines between blocks. while ($offset < $to && preg_match('/\s/', $html[$offset])) $offset++; if ($offset >= $to) break; if ($html[$offset] !== '<') break; // bare text — prose starts here // A horizontal rule is a section divider, not prose to wrap. if (preg_match('/^]*>/i', substr($html, $offset, 40), $m)) { $offset += strlen($m[0]); continue; } // A leading block component: skip to just past its matching close. if (preg_match('/^<(div|table|figure|ul|ol|blockquote|pre)\b[^>]*>/i', substr($html, $offset, 200), $m)) { $tag = strtolower($m[1]); $depth = 0; $scan = $offset; $end = false; while ($scan < $to && preg_match('/<(\/?)' . $tag . '\b[^>]*>/i', $html, $t, PREG_OFFSET_CAPTURE, $scan)) { $pos = $t[0][1]; if ($pos >= $to) break; $depth += ($t[1][0] === '/') ? -1 : 1; $scan = $pos + strlen($t[0][0]); if ($depth === 0) { $end = $scan; break; } } if ($end === false) break; // unbalanced — do not try to be clever $offset = $end; continue; } break; // a heading, , , or anything else inline: prose } // Walked the whole section without finding prose — it is entirely // components (an FAQ block, a steps grid). There is nothing to wrap // either way, so put the figure under the heading rather than stranding // it against the next one; the components now clear the float, so it // reads as a standalone image introducing the section. while ($offset < $to && preg_match('/\s/', $html[$offset])) $offset++; if ($offset >= $to) return $from; return min($offset, $to); } /** * Insert $figure into $html at the requested anchor. * * Returns the new HTML, or WP_Error when the anchor cannot be located. It * NEVER guesses: a wrong placement is worse than no placement, because the * user has to find and undo it by hand. * * Positions: * before-heading — immediately before the matched

* after-intro — after the first

inside that section * end-of-section — immediately before the NEXT

, or at the end of the * section content (inside the .acb-content wrapper when * one is present, so the figure stays scoped and styled) */ private static function aicobr_insert_figure($html, $figure, $section_heading, $position) { $html = (string) $html; if ($figure === '') return new WP_Error('no_figure', 'Could not build the image markup.'); $headings = self::aicobr_find_headings($html); if (empty($headings)) { return new WP_Error('no_headings', 'This post has no section headings to place an image against.'); } $want = self::aicobr_heading_key($section_heading); $idx = -1; foreach ($headings as $i => $h) { if (self::aicobr_heading_key($h['text']) === $want) { $idx = $i; break; } } if ($idx === -1) { return new WP_Error('no_section', 'That section is no longer in this post, so ACB will not guess where the image should go.'); } $sectionStart = $headings[$idx]['end']; $sectionEnd = isset($headings[$idx + 1]) ? $headings[$idx + 1]['start'] : strlen($html); if ($position === 'before-heading') { $at = $headings[$idx]['start']; } elseif ($position === 'after-heading' || $position === 'after-intro') { // AICOBR_INBODY_AFTER_HEADING_2026_08 // Directly beneath the heading, ahead of the section's prose, so the // whole section body wraps around the float. // // AICOBR_INBODY_FLOAT_ANCHOR_2026_08: anchored to the first // TOP-LEVEL paragraph rather than to the heading itself. A float is // wrapped by what follows it, and a section that opens with a // component — steps (display:grid), an FAQ or table wrapper // (overflow), a callout or takeaways box (background and border // rendering under the float) — gives it nothing that can wrap. The // image still floats, so it looks aligned but stranded. // // In the usual case (heading, then a paragraph) this is the same // position. It only differs when a component comes first. // // 'after-intro' is the legacy key for the same intent and is handled // here rather than migrated, so images placed before this change // land in the improved position too. $at = self::aicobr_prose_anchor_offset($html, $sectionStart, $sectionEnd); } else { // end-of-section $at = $sectionEnd; // When the section runs to the end of the document, stay INSIDE the // .acb-content wrapper — outside it the figure loses every scoped // style and lands after the container the theme is laying out. if ($at >= strlen($html)) { $close = strrpos($html, '

'); if ($close !== false && trim(substr($html, $close + 6)) === '') { $at = $close; } } } return substr($html, 0, $at) . "\n" . $figure . "\n" . substr($html, $at); } private function aicobr_str_cap($s, $len) { $s = (string) $s; return function_exists('mb_substr') ? mb_substr($s, 0, $len) : substr($s, 0, $len); } private function aicobr_sanitise_image_suggestions($raw) { if (empty($raw) || !is_array($raw)) return []; $layouts = ['band', 'inline-left', 'inline-right']; // AICOBR_INBODY_POSITION_2026_08 — mirrors POSITION_LAYOUTS in // lib/image-suggestions.js. Only 'after-intro' survives: the other two // positions leave no body text after the figure inside that section, and // the next heading carries `clear: both`, so a floated image ends up // alone on its own row with a blank column beside it. // // Legacy entries may still carry the old values; aicobr_insert_figure() // still understands them, so anything already stored keeps working. New // suggestions are normalised to the one position that reads correctly. $positions = ['after-heading']; $allowed = [ 'after-heading' => ['band', 'inline-left', 'inline-right'], ]; $out = []; $seen = []; foreach ($raw as $item) { if (!is_array($item)) continue; $heading = sanitize_text_field($item['section_heading'] ?? ''); $desc = sanitize_textarea_field($item['description'] ?? ''); if ($heading === '' || $desc === '') continue; // One suggestion per section, matching the server's dedupe rule. $key = strtolower(trim(preg_replace('/\s+/', ' ', $heading))); if (isset($seen[$key])) continue; $seen[$key] = true; // AICOBR_INBODY_POSITION_DEFAULT_2026_08 // Fall back to 'after-heading' — the only position still produced, // and crucially the only key present in $allowed below. // // This previously defaulted to the retired 'end-of-section'. That // value is absent from $allowed, so the very next line indexed a // missing key and passed null to in_array(), which is a FATAL // TypeError on PHP 8 — taking down the whole publish handler and // stranding the entry mid-generation. It never fired only because // the licence server always sends a valid position; any older // server, a malformed payload, or an omitted key would have hit it. $position = sanitize_key($item['position'] ?? ''); if (!in_array($position, $positions, true)) $position = 'after-heading'; $layout = sanitize_text_field($item['layout'] ?? ''); if (!in_array($layout, $layouts, true)) $layout = 'band'; if (!in_array($layout, $allowed[$position], true)) $layout = $allowed[$position][0]; $alt = sanitize_text_field($item['alt_text'] ?? ''); $out[] = [ 'id' => 'sug_' . substr(md5($key . '|' . $desc), 0, 12), 'section_heading' => $this->aicobr_str_cap($heading, 200), 'position' => $position, 'layout' => $layout, 'description' => $this->aicobr_str_cap($desc, 400), // Alt text is mandatory for accessibility; fall back to the // description rather than storing an image with none. 'alt_text' => $this->aicobr_str_cap($alt !== '' ? $alt : $desc, 250), 'caption' => $this->aicobr_str_cap(sanitize_text_field($item['caption'] ?? ''), 200), 'used' => false, ]; if (count($out) >= 3) break; // SUGGESTIONS_MAX } return $out; } private function aicobr_post_in_diary($post_id) { $post_id = intval($post_id); if ($post_id <= 0) return false; $entries = get_option('aicobr_entries', []); if (!is_array($entries)) return false; foreach ($entries as $e) { if (isset($e['post_id']) && intval($e['post_id']) === $post_id) { return true; } } return false; } /** * Cross-request lock for read-modify-write of the aicobr_entries option. * The option is a single serialised array; concurrent /publish (and start-of- * generation) requests that each get_option -> modify one entry -> update_option * will clobber each other's writes (lost update), which is what left a * regenerated card stuck on "regenerating" when two items finished within ~1s. * A MySQL named lock serialises those critical sections. GET_LOCK is connection * scoped and auto-released when the request ends, so a leaked lock self-heals. * On failure to acquire, the caller proceeds unlocked rather than block a publish. */ private function aicobr_entries_lock_name() { // MySQL lock names max 64 chars; scope per site for multisite safety. return 'aicobr_entries_' . get_current_blog_id(); } private function aicobr_lock_entries($timeout = 10) { global $wpdb; $got = $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, %d)', $this->aicobr_entries_lock_name(), $timeout)); if ((string) $got !== '1') { aicobr_dbg('[ACB] entries lock not acquired (got=' . var_export($got, true) . ') — proceeding unlocked'); return false; } return true; } private function aicobr_unlock_entries() { global $wpdb; $wpdb->query($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $this->aicobr_entries_lock_name())); } /** * Find a diary entry by id (preferred), else post_id, else normalised title, * apply $fields, and persist. If $require_status is given, the entry is only * updated when its current status is in that list. Returns true if updated. * The read-modify-write runs under the entries lock with a fresh re-read so a * concurrent writer cannot clobber the status it sets. */ private function aicobr_apply_entry_fields($match, $fields, $require_status = null) { $locked = $this->aicobr_lock_entries(); try { $entries = get_option('aicobr_entries', []); $entry_id = isset($match['entry_id']) && $match['entry_id'] !== '' ? (string) $match['entry_id'] : null; $post_id = !empty($match['post_id']) ? intval($match['post_id']) : 0; $title = isset($match['title']) && $match['title'] !== '' ? strtolower(trim(preg_replace('/\s+/', ' ', $match['title']))) : null; // Resolve the target by PRIORITY, not by "first entry matching anything". // A title can collide across entries (same title generated twice), so an // exact entry-id match must always win over a title match — otherwise an // older same-titled entry hijacks the update and the real one is orphaned. $targetIdx = -1; if ($entry_id !== null) { foreach ($entries as $idx => $e) { if (isset($e['id']) && (string) $e['id'] === $entry_id) { $targetIdx = $idx; break; } } } if ($targetIdx === -1 && $post_id) { foreach ($entries as $idx => $e) { if (isset($e['post_id']) && intval($e['post_id']) === $post_id) { $targetIdx = $idx; break; } } } if ($targetIdx === -1 && $title !== null) { foreach ($entries as $idx => $e) { if (strtolower(trim(preg_replace('/\s+/', ' ', $e['title'] ?? ''))) === $title) { $targetIdx = $idx; break; } } } if ($targetIdx === -1) return false; if (is_array($require_status) && !in_array(($entries[$targetIdx]['status'] ?? ''), $require_status, true)) { return false; // matched, but not in an updatable state } foreach ($fields as $k => $v) { $entries[$targetIdx][$k] = $v; } update_option('aicobr_entries', $entries); return true; } finally { if ($locked) $this->aicobr_unlock_entries(); } } public function save_entries($request) { $data = $request->get_json_params() ?: $request->get_body_params(); // Handle manual reset of stuck item if (isset($data['action']) && $data['action'] === 'reset_stuck' && !empty($data['entry_id'])) { $entryId = absint($data['entry_id']); $locked = $this->aicobr_lock_entries(); $entries = get_option('aicobr_entries', []); foreach ($entries as &$entry) { if ($entry['id'] == $entryId) { $entry['status'] = 'pending'; $entry['generationStartTime'] = null; break; } } update_option('aicobr_entries', $entries); if ($locked) $this->aicobr_unlock_entries(); return rest_ensure_response(['success' => true, 'message' => 'Item reset']); } // Normal full entries update — MERGE so the client can never overwrite // server-owned fields (status, stage, post_id, etc.). The browser owns the // editable content fields; the server owns the generation lifecycle. if (isset($data['entries'])) { $locked = $this->aicobr_lock_entries(); $existing = get_option('aicobr_entries', []); $byId = []; foreach ($existing as $e) { if (isset($e['id'])) $byId[(string) $e['id']] = $e; } // Fields the server stamps during publish/finalise and the client must // never overwrite with stale local state. The browser owns editable // content fields; the server owns the generation lifecycle AND the // record of what was produced. mailpoet_newsletter_id/tnp_newsletter_id // were missing here, so the entries-write-back useEffect could race the // publish and strip a freshly-stamped newsletter id (dropping the // "View in Newsletter/MailPoet" button). // // image_history / featured_image / featured_image_id are ALSO // server-owned: they're written only by the publish path, // set_featured_image, and append_image (image-only regeneration). The // client merely mirrors them optimistically. Without protecting them, // the ~2.5s entries POST-back races an image regen and overwrites the // freshly-appended image with the client's stale copy — the new image // vanishes from the diary card and the regen's completion (which keys // off image_history growing) never fires. Same last-writer-wins class // as the 2026-07-06 thumbnail fix; this closes it for the append path. // AICOBR_CLUSTER_PILLAR_2026_08: cluster_id / cluster_role / // cluster_pillar are written by strategist_populate and read by the // publish path to build the internal link. They are structural, not // editable content, so the browser must never write them back — the // entries POST-back would otherwise strip the cluster from every // entry the moment the diary re-renders, silently killing the // interlinking. Same last-writer-wins class as image_history. // AICOBR_INBODY_IMAGES_2026_08: image_suggestions is stamped by the // publish path from the licence server's payload and mutated only by // the server-side routes, so it is server-owned for the same reason. // AICOBR_APPROVAL_2026_08: approved / approved_at / approved_by are // written only by /approve-entry under this same lock. Left off this // list, the diary's periodic write-back would post a stale local copy // and silently un-approve a brief the user had just signed off — // exactly the race that stripped freshly-appended images. $serverOwned = ['status', 'stage', 'started_ts', 'post_id', 'error_message', 'updated_at', 'generationStartTime', 'mailpoet_newsletter_id', 'tnp_newsletter_id', 'excerpt', 'post_type', 'image_history', 'featured_image', 'featured_image_id', 'seo_report', 'cluster_id', 'cluster_role', 'cluster_pillar', 'image_suggestions', 'approved', 'approved_at', 'approved_by']; $merged = array_map(function ($e) use ($byId, $serverOwned) { $id = isset($e['id']) ? (string) $e['id'] : null; if ($id !== null && isset($byId[$id])) { foreach ($serverOwned as $f) { if (array_key_exists($f, $byId[$id])) { $e[$f] = $byId[$id][$f]; } } } return $e; }, $data['entries']); update_option('aicobr_entries', $merged); if ($locked) $this->aicobr_unlock_entries(); } return rest_ensure_response(['success' => true]); } /** * Delete a diary entry. Optionally also move its generated post to TRASH. * * AICOBR_DELETE_TRASHES_POST_2026_08 * Previously the post was orphaned: still published, no longer tracked by * ACB. `trash_post=1` (opt-in per deletion, never a stored setting) moves it * to the WordPress trash — trash, never wp_delete_post(force), so the user * can always recover content they didn't mean to unpublish. * * Guarded: only posts ACB actually created are trashable, so a brief that * was pointed at a pre-existing page can never take that page down. */ public function delete_entry($request) { $id = absint($request->get_param('id')); $trash_post = filter_var($request->get_param('trash_post'), FILTER_VALIDATE_BOOLEAN); $locked = $this->aicobr_lock_entries(); $entries = get_option('aicobr_entries', []); if (!is_array($entries)) $entries = []; // Capture the entry before removing it — we need its post_id and pillar. $target = null; foreach ($entries as $e) { if (isset($e['id']) && $e['id'] == $id) { $target = $e; break; } } $entries = array_filter($entries, fn($entry) => $entry['id'] != $id); update_option('aicobr_entries', array_values($entries)); if ($locked) $this->aicobr_unlock_entries(); $trashed = false; if ($trash_post && $target) { $pid = absint($target['post_id'] ?? 0); if ($pid > 0 && get_post($pid) && self::aicobr_is_generated_post($pid)) { $trashed = (bool) wp_trash_post($pid); aicobr_dbg('[ACB] delete_entry: post ' . $pid . ' trashed=' . ($trashed ? '1' : '0')); } } // The removed entry may have been a spoke — refresh its pillar's block. if ($target && !empty($target['cluster_pillar']['post_id'])) { $this->aicobr_bust_cluster_cache($target['cluster_pillar']['post_id']); } return rest_ensure_response(['success' => true, 'trashed' => $trashed]); } public function get_logs($request) { $logs = get_option('aicobr_logs', []); $per_page = absint($request->get_param('per_page') ?? 100); return rest_ensure_response(array_slice(array_reverse($logs), 0, $per_page)); } public function get_settings() { // Opportunistically resync the cached tier from the licence server // (throttled to once/minute inside the helper) so the Dashboard's // settings poll transparently reflects upgrades, renewals, downgrades // and cancellations without a manual key re-verify. self::refresh_license_tier(); $settings = get_option('aicobr_settings', [ 'brand_voice' => '{"voice": "Professional yet approachable"}', 'tone' => 'friendly' ]); // Never return the raw licence key — expose a masked hint + boolean instead. if (!empty($settings['license_key'])) { $key = (string) $settings['license_key']; $settings['license_key_set'] = true; $settings['license_key_hint'] = str_repeat('*', max(0, strlen($key) - 4)) . substr($key, -4); unset($settings['license_key']); } else { $settings['license_key_set'] = false; } // AICOBR_INBOUND_CPT_2026_08 — the choices, and what is in force. The // effective list is returned as well as the stored one because a filter // can override the setting, and a settings screen that shows a value // the plugin is not actually using is worse than showing nothing. $settings['available_post_types'] = $this->aicobr_available_post_types(); $settings['inbound_post_types'] = $settings['inbound_post_types'] ?? ['post']; $settings['inbound_post_types_effective'] = $this->aicobr_inbound_post_types(); // AICOBR_ACF_CONTENT_FIELDS_2026_08 — the stored map, plus a small live // preview so the user can confirm they named the right fields instead of // configuring blind. The preview reads the most recent published post of // each configured type and shows what text those fields actually yield. $settings['acf_content_fields'] = $settings['acf_content_fields'] ?? []; $settings['acf_content_preview'] = $this->aicobr_acf_fields_preview($settings['acf_content_fields']); $settings['acf_field_types'] = $settings['acf_field_types'] ?? []; // AICOBR_ACF_FIELD_LIST_2026_08 — the fields the picker offers, per type. $settings['acf_available_fields'] = $this->aicobr_acf_available_fields_map(); return rest_ensure_response($settings); } public function save_settings($request) { $data = $request->get_json_params() ?: $request->get_body_params(); $saved = get_option('aicobr_settings', []); // preserve fields not sent in this request // AICOBR_INBOUND_CPT_2026_08 — remember the effective source pool before // the write, so a change to it can invalidate cached scans below. $types_before = $this->aicobr_inbound_post_types(); $acf_fields_before = $this->aicobr_acf_content_fields(); update_option('aicobr_settings', [ 'brand_voice' => wp_kses_post($data['brand_voice'] ?? ($saved['brand_voice'] ?? '')), 'tone' => sanitize_text_field($data['tone'] ?? ($saved['tone'] ?? 'friendly')), 'default_word_count' => (intval($data['default_word_count'] ?? ($saved['default_word_count'] ?? 1500)) ?: 1500), 'default_image_style' => sanitize_text_field($data['default_image_style'] ?? ($saved['default_image_style'] ?? 'professional')), 'default_post_type' => sanitize_text_field($data['default_post_type'] ?? ($saved['default_post_type'] ?? 'post')), 'notifications_enabled' => !empty($data['notifications_enabled']), // AICOBR_INBOUND_CPT_2026_08 — which post types inbound linking and // the forecast search. Sanitised to existing public types here as // well as on read: a type saved today can be removed tomorrow when a // plugin is deactivated, and the stored value would then quietly // return nothing with no way to tell why. 'inbound_post_types' => (function () use ($data, $saved) { if (!array_key_exists('inbound_post_types', $data)) { return $saved['inbound_post_types'] ?? ['post']; } $out = []; foreach ((array) $data['inbound_post_types'] as $t) { $t = sanitize_key((string) $t); if ($t && post_type_exists($t) && is_post_type_viewable($t)) $out[] = $t; } return $out ? array_values(array_unique($out)) : ['post']; })(), // AICOBR_ACF_CONTENT_FIELDS_2026_08 // Per-post-type list of meta fields whose text ACB should read as // though it were post_content. Sites that store body copy in ACF // fields (common on custom post types) are otherwise invisible to // link scanning, because the scanner reads post_content and theirs // is empty. Shape: { post_type: ['field_a','field_b'] }. // // Sanitised hard: keys must be existing post types, values are // meta_key strings, and meta keys allow more than sanitize_key would // pass (ACF uses lowercase with underscores, but third parties use // hyphens and digits), so the filter is "printable, no whitespace, // no quotes" rather than sanitize_key, which would silently mangle a // real field name into one that reads nothing. 'acf_content_fields' => (function () use ($data, $saved) { if (!array_key_exists('acf_content_fields', $data)) { return $saved['acf_content_fields'] ?? []; } $out = []; foreach ((array) $data['acf_content_fields'] as $pt => $fields) { $pt = sanitize_key((string) $pt); if (!$pt || !post_type_exists($pt)) continue; $clean = []; foreach ((array) $fields as $f) { $f = trim((string) $f); // A meta key: no whitespace, no quotes, printable. if ($f === '' || preg_match('/[\s"\'`]/', $f)) continue; if (!preg_match('/^[A-Za-z0-9_\-]+$/', $f)) continue; $clean[] = $f; } if ($clean) $out[$pt] = array_values(array_unique($clean)); } return $out; })(), // AICOBR_ACF_WRITE_2026_08 — per-field type, so insertion knows which // fields can safely take a link. { post_type: { field: type } }. 'acf_field_types' => (function () use ($data, $saved) { if (!array_key_exists('acf_field_types', $data)) { return $saved['acf_field_types'] ?? []; } $allowed = ['wysiwyg', 'text', 'other']; $out = []; foreach ((array) $data['acf_field_types'] as $pt => $fields) { $pt = sanitize_key((string) $pt); if (!$pt || !post_type_exists($pt)) continue; $clean = []; foreach ((array) $fields as $f => $type) { $f = trim((string) $f); if ($f === '' || !preg_match('/^[A-Za-z0-9_\-]+$/', $f)) continue; $type = in_array($type, $allowed, true) ? $type : 'text'; $clean[$f] = $type; } if ($clean) $out[$pt] = $clean; } return $out; })(), // ── Output styling (presentation layer) ────────────────────────── // Booleans default to TRUE when never configured (preserves the // current always-on behaviour); use the form value when sent, else // the saved value, else true. 'enable_frontend_styling' => array_key_exists('enable_frontend_styling', $data) ? (bool) $data['enable_frontend_styling'] : (array_key_exists('enable_frontend_styling', $saved) ? !empty($saved['enable_frontend_styling']) : true), 'style_post_title_serif' => array_key_exists('style_post_title_serif', $data) ? (bool) $data['style_post_title_serif'] : (array_key_exists('style_post_title_serif', $saved) ? !empty($saved['style_post_title_serif']) : true), // AICOBR_HERO_IMAGE_2026_08 — default ON (absent means true), same // convention as the two settings above, so existing installs get the // hero without having to opt in. Turn off if the theme already // renders the thumbnail above the content. 'auto_hero_image' => array_key_exists('auto_hero_image', $data) ? (bool) $data['auto_hero_image'] : (array_key_exists('auto_hero_image', $saved) ? !empty($saved['auto_hero_image']) : true), // AICOBR_SCHEDULE_PUBLISH_2026_08 — time of day a scheduled brief // publishes, in the SITE timezone. The diary stores a date only, so // this supplies the clock half. Validated to HH:MM 24-hour; anything // else falls back to 09:00 rather than producing an unusable // post_date (aicobr_resolve_schedule_datetime revalidates on read). 'schedule_time' => (function ($v, $fallback) { $v = sanitize_text_field((string) $v); if (preg_match('/^([01]\d|2[0-3]):([0-5]\d)$/', $v)) return $v; return preg_match('/^([01]\d|2[0-3]):([0-5]\d)$/', (string) $fallback) ? (string) $fallback : '09:00'; })($data['schedule_time'] ?? ($saved['schedule_time'] ?? '09:00'), $saved['schedule_time'] ?? '09:00'), // Accent colour: present-but-empty clears it (falls back to default); // absent preserves the saved value. sanitize_hex_color rejects junk. 'brand_color' => array_key_exists('brand_color', $data) ? (sanitize_hex_color($data['brand_color']) ?: '') : ($saved['brand_color'] ?? ''), 'domain' => sanitize_text_field(!empty($data['domain']) ? $data['domain'] : (!empty($saved['domain']) ? $saved['domain'] : wp_parse_url(home_url(), PHP_URL_HOST))), 'email' => sanitize_email($data['email'] ?? ($saved['email'] ?? '')), 'license_key' => sanitize_text_field(!empty($data['license_key']) ? $data['license_key'] : ($saved['license_key'] ?? '')), 'email_verified' => !empty($saved['email_verified']), // never set from form — only set by verify-license endpoint 'plan_tier' => sanitize_text_field(($saved['plan_tier'] ?? '') ?: 'free'), // never overwrite from form — only set by verify-license // Wizard dismissed — use strict boolean check so false is preserved correctly '_wizard_dismissed' => array_key_exists('_wizard_dismissed', $data) ? ($data['_wizard_dismissed'] === true) : !empty($saved['_wizard_dismissed']), // Writing Style Profiles 'writing_style_profiles' => $data['writing_style_profiles'] ?? ($saved['writing_style_profiles'] ?? []), 'active_style_profile_id' => sanitize_text_field($data['active_style_profile_id'] ?? ($saved['active_style_profile_id'] ?? '')), // User instruction snippets 'user_snippets' => $data['user_snippets'] ?? ($saved['user_snippets'] ?? []), 'global_snippets' => $data['global_snippets'] ?? ($saved['global_snippets'] ?? []), // ── AI Content Strategist ──────────────────────────────────────── 'business_context' => [ 'what' => sanitize_text_field($data['business_context']['what'] ?? ($saved['business_context']['what'] ?? '')), 'industry' => sanitize_text_field($data['business_context']['industry'] ?? ($saved['business_context']['industry'] ?? '')), 'location' => sanitize_text_field($data['business_context']['location'] ?? ($saved['business_context']['location'] ?? '')), ], 'target_keywords' => array_values(array_filter(array_map( 'sanitize_text_field', is_array($data['target_keywords'] ?? null) ? $data['target_keywords'] : ($saved['target_keywords'] ?? []) ))), 'serp_gl' => sanitize_text_field($data['serp_gl'] ?? ($saved['serp_gl'] ?? '')), ]); // AICOBR_INBOUND_CPT_2026_08 // The searchable post types ARE the source pool, so changing them // changes the answer to every cached scan and every cached forecast. // Without this the user ticks "Pages", sees no change for fifteen // minutes, and concludes the setting does nothing. Same shape as the // version-in-cache-key fix: the cache encodes a world state, and // something changed the world without telling it. // AICOBR_ACF_CONTENT_FIELDS_2026_08 — the field map changes what text the // scanner reads, so a change to it must invalidate cached scans exactly // as a change to the searched post types does. $acf_before = wp_json_encode($acf_fields_before); $acf_after = wp_json_encode($this->aicobr_acf_content_fields()); if ($this->aicobr_inbound_post_types() !== $types_before || $acf_before !== $acf_after) { $this->aicobr_bump_inbound_gen(); } return rest_ensure_response(['success' => true]); } public function get_stats() { $logs = get_option('aicobr_logs', []); $recent = array_filter($logs, fn($l) => strtotime($l['created_at'] ?? 'now') > strtotime('-30 days')); $totalRuns = count($recent); $successful = count(array_filter($recent, fn($l) => ($l['status'] ?? '') === 'success')); $tokens = array_filter(array_column($recent, 'tokens_used')); $avgTokens = $tokens ? (int)round(array_sum($tokens) / count($tokens)) : 0; $image_costs = array_filter(array_column($recent, 'image_cost')); $avgImageCost = $image_costs ? round(array_sum($image_costs) / count($image_costs), 4) : 0.0; $lastRun = !empty($logs) ? human_time_diff(strtotime(end($logs)['created_at']), current_time('timestamp')) . ' ago' : null; return rest_ensure_response([ 'totalRuns' => $totalRuns, 'successful' => $successful, 'avgTokens' => $avgTokens, 'avgImageCost' => $avgImageCost, 'lastRun' => $lastRun, ]); } public function get_usage() { // Usage tab + Settings page. Plan, monthly allowance, balance and reset // date come from the licence server's /api/credits; activity is local. $logs = get_option('aicobr_logs', []); $totalPosts = count($logs); $settings = get_option('aicobr_settings', []); $license_key = $settings['license_key'] ?? ''; // Local activity history is always available regardless of licence state. $base = [ 'totalPostsEver' => $totalPosts, 'avgPostsPerMonth' => $totalPosts > 0 ? max(1, round($totalPosts / 3)) : 0, 'recentActivity' => array_slice(array_reverse($logs), 0, 3), ]; $TIER_LIMITS = ['free' => 5, 'starter' => 30, 'pro' => 100, 'agency' => 300]; $fallback_tier = strtolower($settings['plan_tier'] ?? 'free'); $fallback_limit = $TIER_LIMITS[$fallback_tier] ?? 5; // If we can't reach the licence server (or there's no key), fall back to the // known tier limit rather than the misleading free/5 stub. $degraded = function () use ($base, $fallback_tier, $fallback_limit) { return rest_ensure_response(array_merge($base, [ 'plan' => $fallback_tier, 'postsLimit' => $fallback_limit, 'postsUsed' => 0, 'creditsRemaining' => 0, 'daysUntilReset' => null, ])); }; if (empty($license_key)) { return $degraded(); } $license_url = self::get_server_url(); $response = wp_remote_get( "{$license_url}/api/credits?license_key=" . rawurlencode($license_key), ['timeout' => 20] ); $body = is_wp_error($response) ? null : json_decode(wp_remote_retrieve_body($response), true); if (!is_array($body) || empty($body['success'])) { return $degraded(); } $tier = strtolower($body['tier'] ?? $fallback_tier); $limit = isset($body['monthly_credit_limit']) ? (int) $body['monthly_credit_limit'] : ($TIER_LIMITS[$tier] ?? $fallback_limit); $remaining = isset($body['credits_remaining']) ? (int) $body['credits_remaining'] : 0; $days = null; if (!empty($body['next_expiry_date'])) { $exp = strtotime($body['next_expiry_date']); if ($exp) { $days = (int) ceil(($exp - current_time('timestamp')) / DAY_IN_SECONDS); if ($days < 0) { $days = 0; } } } return rest_ensure_response(array_merge($base, [ 'plan' => $tier, 'postsLimit' => $limit, 'postsUsed' => max(0, $limit - $remaining), 'creditsRemaining' => $remaining, 'daysUntilReset' => $days, ])); } /** * POST /ai-content/v1/generation-stage * Lightweight progress beacon from the licence server pipeline. Secured by the * shared generate secret. Only advances the stage of an entry that is still * generating, so a late beacon can never overwrite a finished/failed status. */ public function generation_stage($request) { $secret = $request->get_header('x-generate-secret'); if (!hash_equals((string) self::get_secret(), (string) $secret)) { return new WP_Error('unauthorized', 'Unauthorized', ['status' => 401]); } $data = $request->get_json_params() ?: []; $stage = sanitize_text_field($data['stage'] ?? ''); if ($stage === '') { return rest_ensure_response(['success' => false, 'error' => 'no_stage']); } $this->aicobr_apply_entry_fields( ['title' => $data['title'] ?? '', 'post_id' => $data['post_id'] ?? 0], ['stage' => $stage], ['generating', 'regenerating'] ); return rest_ensure_response(['success' => true]); } /** * GET /ai-content/v1/credits * Proxies the licence server's /api/credits using the server-stored licence key, * so the raw key never travels to the browser or appears in a URL. */ public function get_credits() { $settings = get_option('aicobr_settings', []); $license_key = $settings['license_key'] ?? ''; if (empty($license_key)) { return rest_ensure_response(['success' => false, 'error' => 'no_license']); } $license_url = self::get_server_url(); $response = wp_remote_get( "{$license_url}/api/credits?license_key=" . rawurlencode($license_key), ['timeout' => 20] ); if (is_wp_error($response)) { return new WP_Error('server_unreachable', 'Could not reach the licence server.', ['status' => 502]); } $body = json_decode(wp_remote_retrieve_body($response), true); if (!is_array($body)) { return rest_ensure_response(['success' => false, 'error' => 'bad_response']); } return rest_ensure_response($body); } /** * POST /aicobr/v1/approve-entry * * AICOBR_APPROVAL_2026_08 * * Mark a generated brief as reviewed and approved by a human. * * This is an EDITORIAL marker, deliberately with no mechanical power: it * does not gate scheduling, does not hold a post back, and does not change * post_status. Scheduling stays automatic (a planned date should mean the * post publishes on that date), so approval is here to answer "which of * these have I actually read?" — a question that gets harder with every * cluster planned, and which the diary previously could not answer at all. * * Making it a blocking gate was considered and rejected: a brief that * silently fails to publish because nobody ticked a box is a worse failure * than one that publishes unreviewed, and it would undo the point of * planning dates in the first place. * * Written through a dedicated endpoint under the SAME entries lock every * other read-modify-write uses, rather than relying on the browser's * ~2.5s entries POST-back — that write races the publish path (the same * class of bug as the image_history overwrite), so the flag is server-owned. */ public function approve_entry($request) { $data = $request->get_json_params() ?: []; $entry_id = isset($data['entry_id']) && $data['entry_id'] !== '' ? (string) $data['entry_id'] : ''; if ($entry_id === '') { return new WP_Error('missing_entry', 'entry_id is required.', ['status' => 400]); } // Absent 'approved' means "approve"; send false explicitly to withdraw. $approved = array_key_exists('approved', $data) ? (bool) $data['approved'] : true; $locked = $this->aicobr_lock_entries(); try { $entries = get_option('aicobr_entries', []); if (!is_array($entries)) $entries = []; $found = false; foreach ($entries as &$entry) { if ((string) ($entry['id'] ?? '') !== $entry_id) continue; if ($approved) { $user = wp_get_current_user(); $entry['approved'] = true; $entry['approved_at'] = current_time('mysql'); $entry['approved_by'] = sanitize_text_field( ($user && $user->exists()) ? ($user->display_name ?: $user->user_login) : '' ); } else { // Withdrawn: clear the whole record rather than leaving a // stale name and timestamp against approved=false. $entry['approved'] = false; $entry['approved_at'] = ''; $entry['approved_by'] = ''; } $found = true; break; } unset($entry); if (!$found) { return new WP_Error('not_found', 'Entry not found.', ['status' => 404]); } update_option('aicobr_entries', $entries); } finally { if ($locked) $this->aicobr_unlock_entries(); } return rest_ensure_response([ 'success' => true, 'approved' => $approved, ]); } /** * POST /aicobr/v1/inbound-suggestions * * AICOBR_INBOUND_LINKS_2026_08 * * Which existing published posts could link to this spoke, and where. * READ-ONLY — writes nothing. The user approves each suggestion individually * in a later phase; nothing here changes any post. */ public function inbound_suggestions($request) { $data = $request->get_json_params() ?: []; $entry_id = isset($data['entry_id']) && $data['entry_id'] !== '' ? (string) $data['entry_id'] : ''; if ($entry_id === '') { return new WP_Error('missing_entry', 'entry_id is required.', ['status' => 400]); } $entries = get_option('aicobr_entries', []); $entry = null; foreach ((array) $entries as $e) { if ((string) ($e['id'] ?? '') === $entry_id) { $entry = $e; break; } } if (!$entry) { return new WP_Error('not_found', 'Entry not found.', ['status' => 404]); } $post_id = absint($entry['post_id'] ?? 0); if (!$post_id || !get_post($post_id)) { return new WP_Error('no_post', 'This brief has no generated post yet.', ['status' => 400]); } // Published only. 2.9.2 schedules future-dated briefs, so a link to a // 'future' post would 404 for every reader until its date arrives. if (get_post_status($post_id) !== 'publish') { return rest_ensure_response([ 'success' => false, 'reason' => 'not_published', 'message' => 'Inbound links can only point at a published post. Publish this one first.', 'suggestions' => [], ]); } $target = [ 'post_id' => $post_id, 'url' => get_permalink($post_id), 'title' => html_entity_decode(get_the_title($post_id), ENT_QUOTES, 'UTF-8'), 'date' => get_post_field('post_date', $post_id), 'categories' => array_merge( wp_get_post_categories($post_id) ?: [], wp_get_post_tags($post_id, ['fields' => 'ids']) ?: [] ), ]; $opts = [ 'limit' => min(20, max(1, absint($data['limit'] ?? AICOBR_Inbound_Links::MAX_PER_TARGET))), 'post_types' => $this->aicobr_inbound_post_types(), // AICOBR_ACF_CONTENT_FIELDS_2026_08 — so posts whose body lives in // ACF fields are scanned, not silently skipped for empty content. 'acf_content_fields' => $this->aicobr_acf_content_fields(), ]; // Cached on the target and its content hash: reopening the panel should // not rescan, but an edited post must not serve stale offsets to phase 4. // // AICOBR_INBOUND_RESCAN_2026_08 // post_modified covers the TARGET, but a suggestion's offsets and its // re-scan context fingerprint describe the SOURCE post — which this key // cannot see. So the generation salt below is bumped whenever ACB writes // to any source post (see aicobr_bump_inbound_gen), and `force` lets the // UI demand a fresh scan outright. // // This is what makes the insert route's own advice actionable: when an // insert is refused with `spot_changed` it tells the user to re-scan, and // before this the next scan inside the 15-minute window handed back the // identical stale context and refused again, with no way out but waiting. $force = !empty($data['force']); $key = 'aicobr_inb_' . md5($post_id . '|' . $opts['limit'] . '|' . wp_json_encode($opts['post_types']) . '|' . get_post_field('post_modified', $post_id) . '|' . (int) get_option('aicobr_inb_gen', 0) // AICOBR_NAME_GUARD_2026_08 — the plugin VERSION is // part of the key because the matching RULES are part // of what a cached scan encodes. Without it, updating // the plugin leaves every cached scan answering with // the old rules for up to fifteen minutes, and the // update looks like it did nothing. That is exactly // how the name guard appeared not to work after the // deploy that shipped it. . '|' . self::VERSION); if (!$force) { $cached = get_transient($key); if (is_array($cached)) { $cached['cached'] = true; return rest_ensure_response($cached); } } $result = AICOBR_Inbound_Links::suggestions($entry, $target, $opts); $result['success'] = true; $result['target'] = [ 'post_id' => $post_id, 'title' => $target['title'], 'url' => $target['url'], ]; set_transient($key, $result, 15 * MINUTE_IN_SECONDS); return rest_ensure_response($result); } /** * AICOBR_INBOUND_RESCAN_2026_08 * * Invalidate every cached inbound-link scan. * * Deliberately global rather than per-target. A cached scan's value is a set * of (source post, byte offset, surrounding context) triples, so ANY edit to * ANY source post can stale a scan run against a completely different target. * Since ACB only writes to a source when the user approves or undoes a link — * a rare, deliberate act — the cheapest correct rule is "any write invalidates * everything". Re-scanning costs one indexed query plus ~0.5 ms per candidate; * serving a stale offset costs the user a refused insert they cannot clear. * * Not autoloaded: read only on the inbound-scan path. */ /** * POST /aicobr/v1/link-forecast * * AICOBR_LINK_FORECAST_2026_08 * * How connected a brief WILL be, before it is written. READ-ONLY, and about * a post that does not exist yet, so it writes nothing anywhere. * * Body: entry_id, optional limit (named examples, max 10) and force. * Returns: spoke_sources, pillar_sources, pillar, suggestions[] (named * examples), scanned, skipped{}, phrases[]. * * A brief that HAS been generated and published is refused with * reason:'already_published' and pointed at inbound-suggestions instead: the * forecast is a prediction, and predicting something that has already * happened would report a number the live scan disagrees with (the live scan * excludes sources that already link, the forecast does not). */ public function link_forecast($request) { $data = $request->get_json_params() ?: []; $entry_id = isset($data['entry_id']) && $data['entry_id'] !== '' ? (string) $data['entry_id'] : ''; $phrase = isset($data['phrase']) ? trim((string) $data['phrase']) : ''; // AICOBR_LINK_FORECAST_2026_08 — two callers, two shapes. // // A saved diary brief is looked up by entry_id. A row in the Strategist // plan review has NO entry_id, because nothing is written to the diary // until the user approves the plan, and the review gate is exactly where // this forecast is worth the most: it is the last moment a bad keyword // can be fixed for free. So a bare phrase is accepted too. // // The pillar is passed by ID and resolved here rather than accepted as a // URL from the client, so a caller cannot aim the forecast at an // arbitrary address. if ($entry_id === '' && $phrase === '') { return new WP_Error('missing_entry', 'entry_id or phrase is required.', ['status' => 400]); } if ($entry_id === '') { $pillar_id = absint($data['pillar_post_id'] ?? 0); $pillar = null; if ($pillar_id && get_post($pillar_id) && get_post_status($pillar_id) === 'publish') { $pc = get_post($pillar_id); $pillar = [ 'post_id' => $pillar_id, 'url' => (string) get_permalink($pillar_id), 'title' => (string) get_the_title($pillar_id), // AICOBR_CLUSTER_LINKDOWN_2026_08 — a pillar carrying // [content_cluster] lists each spoke as it publishes, so the // spoke gets an inbound link from the pillar. Without this // check the forecast calls such a spoke an orphan, which is // simply untrue. 'lists_spokes' => $pc && has_shortcode((string) $pc->post_content, 'content_cluster'), ]; } $entry = [ 'id' => 'plan_' . md5($phrase . '|' . $pillar_id), 'title' => (string) ($data['title'] ?? $phrase), 'primary_keyword' => $phrase, 'seo_focus_keyword' => '', // AICOBR_ANCHOR_FORECAST_2026_08 — the phrasings that actually // match prose. A spoke's keyphrase is long-tail by design and // will forecast zero on any real site; the variants are what // close that gap, so dropping them measured the wrong thing. 'anchor_variants' => array_values(array_filter(array_map( fn($v) => trim((string) $v), (array) ($data['anchor_variants'] ?? []) ))), 'cluster_pillar' => $pillar, ]; } else { $entry = null; foreach ((array) get_option('aicobr_entries', []) as $e) { if ((string) ($e['id'] ?? '') === $entry_id) { $entry = $e; break; } } if (!$entry) { return new WP_Error('not_found', 'Entry not found.', ['status' => 404]); } $post_id = absint($entry['post_id'] ?? 0); if ($post_id && get_post($post_id) && get_post_status($post_id) === 'publish') { return rest_ensure_response([ 'success' => false, 'reason' => 'already_published', 'message' => 'This brief is already published. Scan it for inbound links instead.', ]); } } $opts = [ 'limit' => min(10, max(1, absint($data['limit'] ?? 3))), 'post_types' => $this->aicobr_inbound_post_types(), // AICOBR_ACF_CONTENT_FIELDS_2026_08 — so posts whose body lives in // ACF fields are scanned, not silently skipped for empty content. 'acf_content_fields' => $this->aicobr_acf_content_fields(), ]; // Cached on the entry's declared phrases rather than on a post that does // not exist. The generation salt covers publication (see // aicobr_bump_inbound_gen, now also bumped by handle_publish), and the // plugin VERSION covers a change to the matching rules, for the same // reason the scan cache carries it. $fingerprint = wp_json_encode([ $entry['primary_keyword'] ?? '', $entry['seo_focus_keyword'] ?? '', $entry['anchor_variants'] ?? [], $entry['cluster_pillar']['post_id'] ?? 0, ]); $key = 'aicobr_fc_' . md5(($entry['id'] ?? '') . '|' . $fingerprint . '|' . $opts['limit'] . '|' . wp_json_encode($opts['post_types']) . '|' . (int) get_option('aicobr_inb_gen', 0) . '|' . self::VERSION); if (empty($data['force'])) { $cached = get_transient($key); if (is_array($cached)) { $cached['cached'] = true; return rest_ensure_response($cached); } } $result = AICOBR_Inbound_Links::forecast($entry, $opts); $result['success'] = true; $result['entry_id'] = (string) ($entry['id'] ?? ''); set_transient($key, $result, 15 * MINUTE_IN_SECONDS); return rest_ensure_response($result); } /** * AICOBR_INBOUND_CPT_2026_08 * * Which post types inbound linking and the forecast search for candidate * sources. * * This defaulted to ['post'] with a filter as the only way to change it, * which meant pages and custom post types were invisible to every scan and * no user without a developer would ever discover why. On a site whose * catalogue is largely CPTs (locations, makes, models on a vehicle site, * say) that is most of the content, so every count the plugin reported was * a fraction of the truth with nothing on screen to say so. * * Now a setting. The filter still runs last, so existing code overrides * keep working. */ /** * AICOBR_INBOUND_CPT_2026_08 — public post types the user can choose from, * for the settings UI. Attachments excluded: they hold no prose to link in. */ /** * AICOBR_ACF_FIELD_LIST_2026_08 * * The ACF fields registered for a post type that could hold body text, so * the settings screen can offer a pick-list instead of asking the user to * recall a meta key. Same principle as reading the type from ACF: the names * already exist in the field group, so do not make the user retype them. * * Limited to the prose-bearing field types: text, textarea, wysiwyg. Three * reasons, and they line up. Those are the types that hold sentences worth * scanning for links; they are the types stored as a single scalar in * postmeta, which is exactly what the reader can flatten (a repeater stores * its rows across many keys, so a raw postmeta read of the parent key would * not return its text anyway); and it stops the user picking an image or * relationship field that holds no prose. * * Requires ACF. Returns [] when ACF is inactive, when a post type has no * field groups, or when none of its fields are prose-bearing. The UI then * falls back to a free-text input, so a non-ACF meta key stays reachable. */ public function aicobr_acf_available_fields($post_type) { if (!function_exists('acf_get_field_groups') || !function_exists('acf_get_fields')) { return []; } $groups = acf_get_field_groups(['post_type' => $post_type]); if (!is_array($groups) || !$groups) return []; $prose = ['text', 'textarea', 'wysiwyg']; $out = []; $seen = []; foreach ($groups as $g) { $key = $g['key'] ?? ''; if ($key === '') continue; $fields = acf_get_fields($key); if (!is_array($fields)) continue; foreach ($fields as $f) { $name = (string) ($f['name'] ?? ''); $type = (string) ($f['type'] ?? ''); if ($name === '' || isset($seen[$name])) continue; if (!in_array($type, $prose, true)) continue; $seen[$name] = true; $out[] = [ 'name' => $name, 'label' => (string) ($f['label'] ?? $name), 'type' => $type, ]; } } return $out; } /** Available prose fields for every viewable non-post/page type, for the picker. */ public function aicobr_acf_available_fields_map() { $out = []; foreach ($this->aicobr_available_post_types() as $pt) { $name = $pt['name']; if ($name === 'post' || $name === 'page') continue; $fields = $this->aicobr_acf_available_fields($name); if ($fields) $out[$name] = $fields; } return $out; } public function aicobr_available_post_types() { $out = []; foreach (get_post_types(['public' => true], 'objects') as $pt) { if ($pt->name === 'attachment') continue; // wp_count_posts() can return an object with no `publish` property // for oddly-registered types, and reading it unguarded is a fatal // on the settings screen. The count is decoration; the type list is // the point, so degrade to zero rather than take the page down. $counts = wp_count_posts($pt->name); $out[] = [ 'name' => $pt->name, 'label' => $pt->labels->name ?? $pt->name, 'count' => (is_object($counts) && isset($counts->publish)) ? (int) $counts->publish : 0, ]; } return $out; } /** * AICOBR_ACF_CONTENT_FIELDS_2026_08 * The configured ACF-field map: { post_type: ['field', ...] }. */ /** * AICOBR_ACF_WRITE_2026_08 * * Insertion into ACF WYSIWYG fields. * * The insert/undo lifecycle is written against post_content. To make it work * for a WYSIWYG ACF field too, the three operations it depends on — read the * body, write it back, and know whether writing is allowed — are abstracted * here and keyed on an optional field name. * * field === '' -> post_content, exactly as before. Nothing changes. * field !== '' -> the named meta value, but ONLY if the user declared that * field WYSIWYG. A plain-text or repeater field cannot hold * a rendering tag, so writing is refused; reading such a * field for MATCHING stays enabled. Only WRITING is gated. * * Type map, beside the field-name map in settings: * acf_field_types = { post_type: { field_name: 'wysiwyg'|'text'|'other' } } */ public function aicobr_acf_field_types() { $map = get_option('aicobr_settings', [])['acf_field_types'] ?? []; return is_array($map) ? $map : []; } /** * AICOBR_ACF_TYPE_DETECT_2026_08 * * The real type of an ACF field, read from ACF itself. * * The field type is not something the user should re-declare: ACF already * stores it, set when the field was created, and asking the user to repeat * it invites a mismatch that would write a link into a field that renders it * as literal text. So ask ACF. * * get_field_object() needs the field REGISTERED and ACF LOADED, and reads * the type from the field group's definition. It returns false (or no * 'type') when ACF is inactive, or when a field was never registered through * ACF (a bare postmeta key with no field group). In those cases detection is * genuinely impossible, and the honest answer is "unknown" — the caller then * falls back to the user's declared type, and failing that, refuses. A post * id is needed to resolve the field against a real object; the most recent * published post of the type is representative, since the field group is a * property of the type, not the individual post. * * Returns '' when the type cannot be determined. */ public function aicobr_acf_detect_field_type($post_type, $field) { if ($field === '' || !function_exists('get_field_object')) return ''; static $cache = []; $ck = $post_type . '|' . $field; if (array_key_exists($ck, $cache)) return $cache[$ck]; $recent = get_posts([ 'post_type' => $post_type, 'post_status' => 'publish', 'posts_per_page' => 1, 'fields' => 'ids', 'orderby' => 'date', 'order' => 'DESC', 'suppress_filters' => true, ]); $type = ''; if ($recent) { $obj = get_field_object($field, (int) $recent[0], false, false); if (is_array($obj) && !empty($obj['type'])) { $type = (string) $obj['type']; } } return $cache[$ck] = $type; } /** * The effective type ACB will act on: detected from ACF when possible, else * the user's declared type, else empty. Detection wins because it is the * source of truth; the manual setting is only a fallback for when ACF cannot * answer (inactive, or a non-ACF meta key). */ public function aicobr_acf_effective_field_type($post_type, $field) { if ($field === '') return ''; $detected = $this->aicobr_acf_detect_field_type($post_type, $field); if ($detected !== '') return $detected; return $this->aicobr_acf_field_types()[$post_type][$field] ?? ''; } public function aicobr_acf_field_is_writable($post_type, $field) { if ($field === '') return true; // AICOBR_ACF_TYPE_DETECT_2026_08 — writable only when the field's REAL // type is WYSIWYG. Detection is preferred; the manual setting is the // fallback when ACF cannot be asked. Anything else, including an // undetermined type, refuses: a wrong write corrupts a live page, so the // safe default is not to write. return $this->aicobr_acf_effective_field_type($post_type, $field) === 'wysiwyg'; } public function aicobr_source_body($post, $field) { if ($field === '') { return [(string) $post->post_content, false]; } return [(string) get_post_meta((int) $post->ID, $field, true), true]; } public function aicobr_write_source_body($post_id, $field, $new_body) { if ($field === '') { // AICOBR_WP_SLASH_2026_08 — wp_update_post() unslashes post_content // before the DB write (wp-includes/post.php), so it expects slashed // input. Without wp_slash, a single backslash in the body is stripped: // \d -> d, C:\Users -> C:Users. Invisible on plain prose, silent // corruption on code/regex/Windows paths. The meta branch below already // slashes; a correctness rule that lives in only one of two sibling // paths is not a rule. $r = wp_update_post(['ID' => (int) $post_id, 'post_content' => wp_slash($new_body)], true); return is_wp_error($r) ? $r : true; } $current = (string) get_post_meta((int) $post_id, $field, true); if ($current === $new_body) return true; $ok = update_post_meta((int) $post_id, $field, wp_slash($new_body)); return $ok !== false ? true : new WP_Error('meta_write_failed', 'Could not update the field.'); } public function aicobr_acf_content_fields() { $map = get_option('aicobr_settings', [])['acf_content_fields'] ?? []; return is_array($map) ? $map : []; } /** * Text held in a post's configured ACF fields, joined, for a given post type. * * Read straight from postmeta rather than via get_field(), so this works * whether or not ACF is active: the value is the same, and ACB should not * hard-depend on a third-party plugin being loaded to read its own data. * Non-scalar values (repeaters, groups: arrays) are flattened to their * scalar leaves; an array cannot be matched as text and would otherwise * throw. Returns '' when nothing is configured or found. */ public function aicobr_acf_field_text($post_id, $post_type) { $fields = $this->aicobr_acf_content_fields()[$post_type] ?? []; if (!$fields) return ''; $parts = []; foreach ($fields as $field) { $val = get_post_meta((int) $post_id, $field, true); $this->aicobr_collect_scalars($val, $parts); } return trim(implode(' ', $parts)); } /** Depth-first flatten of scalar leaves out of a meta value. */ private function aicobr_collect_scalars($val, array &$out) { if (is_string($val) || is_numeric($val)) { $s = trim((string) $val); if ($s !== '') $out[] = $s; return; } if (is_array($val)) { foreach ($val as $v) $this->aicobr_collect_scalars($v, $out); } } /** * Live preview for the settings screen: for each configured type, the most * recent published post and the text its fields yield, so the user confirms * they named the right fields instead of trusting a blind configuration. */ private function aicobr_acf_fields_preview($map) { if (!is_array($map) || !$map) return []; $out = []; foreach ($map as $pt => $fields) { if (!post_type_exists($pt)) continue; // AICOBR_ACF_TYPE_DETECT_2026_08 — the detected type per field, so the // settings screen can SHOW what ACF reports instead of asking the user // to re-enter it. Empty means ACF could not be asked (inactive, or a // non-ACF meta key), and the UI then offers the manual fallback. $detected = []; foreach ((array) $fields as $f) { $detected[(string) $f] = $this->aicobr_acf_detect_field_type($pt, (string) $f); } $recent = get_posts([ 'post_type' => $pt, 'post_status' => 'publish', 'posts_per_page' => 1, 'fields' => 'ids', 'orderby' => 'date', 'order' => 'DESC', 'suppress_filters' => true, ]); if (!$recent) { $out[$pt] = ['title' => '', 'text' => '', 'found' => false]; continue; } $pid = (int) $recent[0]; $text = $this->aicobr_acf_field_text($pid, $pt); $out[$pt] = [ 'title' => (string) get_the_title($pid), 'text' => $this->aicobr_str_cap($text, 300), // AICOBR_MB_GUARD_2026_08 'found' => $text !== '', 'detected_types' => $detected, ]; } return $out; } private function aicobr_inbound_post_types() { $settings = get_option('aicobr_settings', []); $chosen = $settings['inbound_post_types'] ?? null; if (!is_array($chosen) || !$chosen) { $chosen = ['post']; } // Only types that exist and are public: a stored type from a plugin the // user has since removed would otherwise silently return nothing. $valid = []; foreach ($chosen as $t) { $t = sanitize_key((string) $t); if ($t && post_type_exists($t) && is_post_type_viewable($t)) $valid[] = $t; } if (!$valid) $valid = ['post']; return apply_filters('aicobr_inbound_link_post_types', array_values(array_unique($valid))); } /** * POST /aicobr/v1/cluster-stats * * AICOBR_CLUSTER_STATS_2026_08 * * Execution state for one cluster: what has been built, what is stalled, * and what the pillar would gain if the plan were finished. Read-only. * * Body: pillar_post_id. Optional: no_pillar (bool) for briefs with none. * * Reports LINKS ONLY. It says nothing about rankings, positions or traffic, * because ACB cannot observe those and a number here that hinted at them * would be a claim it could not support. */ public function cluster_stats($request) { $data = $request->get_json_params() ?: []; $pillar_id = absint($data['pillar_post_id'] ?? 0); if (!$pillar_id) { return new WP_Error('missing_pillar', 'pillar_post_id is required.', ['status' => 400]); } $entries = []; // AICOBR_PILLAR_URL_FORMS_2026_08 — every address the cluster's up-links // were actually written with. Collected from the entries themselves // because that is the URL the generation path used; get_permalink() can // report a different form today. $alt_urls = []; foreach ((array) get_option('aicobr_entries', []) as $e) { if ((int) ($e['cluster_pillar']['post_id'] ?? 0) !== $pillar_id) continue; $stored = (string) ($e['cluster_pillar']['url'] ?? ''); if ($stored !== '') $alt_urls[$stored] = true; $pid = absint($e['post_id'] ?? 0); $entries[] = [ 'id' => (string) ($e['id'] ?? ''), 'title' => (string) ($e['title'] ?? ''), 'post_id' => $pid, 'status' => (string) ($e['status'] ?? ''), 'wp_status' => $pid ? (string) get_post_status($pid) : '', 'post_url' => $pid ? (string) get_permalink($pid) : '', 'primary_keyword' => (string) ($e['primary_keyword'] ?? ''), ]; } $stats = AICOBR_Cluster_Stats::summarise([ 'post_id' => $pillar_id, 'title' => (string) get_the_title($pillar_id), 'url' => (string) get_permalink($pillar_id), 'alt_urls' => array_keys($alt_urls), ], $entries, ['post_types' => $this->aicobr_inbound_post_types()]); // AICOBR_CLUSTER_REPORT_2026_08 // Resolve activity titles from the target post id. The ledger records // target_post_id and target_url but NOT target_title (see the insert // route), so reading a title straight off the record yields nothing and // every activity line reads "a post". Titles are looked up live rather // than backfilled into the meta, so a renamed post reads correctly here // instead of showing whatever it was called on the day. foreach ($stats['ledger']['recent'] as &$r) { $tid = (int) ($r['target_post_id'] ?? 0); if ($tid && $r['target_title'] === '') { $r['target_title'] = (string) get_the_title($tid); } } unset($r); // AICOBR_FRONT_PAGE_PILLAR_2026_08 // Is this pillar the site's front page? It changes what the inbound // count MEANS. Every theme links home from its header, footer and logo, // so a front-page pillar counts site chrome rather than editorial link // building, and a number in the hundreds is normal and says nothing // about the work the user has done. The figure is true either way; the // report has to say which kind of true it is. $stats['pillar']['is_front_page'] = ((int) get_option('page_on_front') === $pillar_id) || untrailingslashit((string) get_permalink($pillar_id)) === untrailingslashit((string) home_url('/')); $stats['success'] = true; // Returned so the dashboard can deep-link a brief without re-deriving // which entries belong to this cluster. $stats['entries'] = $entries; return rest_ensure_response($stats); } private function aicobr_bump_inbound_gen() { update_option('aicobr_inb_gen', ((int) get_option('aicobr_inb_gen', 0)) + 1, false); } /** * POST /aicobr/v1/insert-inbound-link * * AICOBR_RESCAN_CONTEXT_2026_08 * * Insert ONE approved inbound link into a source post. Correct-or-nothing: * the byte offset from the scan is never trusted — the reviewed occurrence is * re-located against the source post's CURRENT content, and the link is only * written where the reviewed sentence still is. If it has moved, changed, or * gone, nothing is written and the caller is told to re-scan. * * Every insert is recorded in the source post's _aicobr_inbound_links meta * (with the exact stored HTML) so it can be undone surgically later. */ public function insert_inbound_link($request) { $data = $request->get_json_params() ?: []; $source_id = absint($data['source_post_id'] ?? 0); $phrase = trim((string) ($data['phrase'] ?? '')); $target_url = esc_url_raw((string) ($data['target_url'] ?? '')); $target_pid = absint($data['target_post_id'] ?? 0); $title_attr = sanitize_text_field($data['target_title'] ?? ''); $sugg_id = sanitize_text_field($data['suggestion_id'] ?? ''); // AICOBR_ACF_WRITE_2026_08 — which body to write into. Empty = post_content. $field = trim((string) ($data['field'] ?? '')); if ($field !== '' && !preg_match('/^[A-Za-z0-9_\-]+$/', $field)) { return new WP_Error('bad_field', 'Invalid field name.', ['status' => 400]); } $context = [ 'before' => (string) ($data['context']['before'] ?? ''), 'after' => (string) ($data['context']['after'] ?? ''), ]; if (!$source_id || !$phrase || $target_url === '') { return new WP_Error('bad_request', 'source_post_id, phrase and target_url are required.', ['status' => 400]); } // Write path: re-check the SPECIFIC source post, not just the general cap. if (!current_user_can('edit_post', $source_id)) { return new WP_Error('forbidden', 'You cannot edit that source post.', ['status' => 403]); } $post = get_post($source_id); if (!$post) { return new WP_Error('no_source', 'Source post not found.', ['status' => 404]); } // AICOBR_ACF_WRITE_2026_08 — writing into a field is allowed ONLY when // the user declared it WYSIWYG. A link written into a plain-text field // would render as literal angle brackets, so refuse rather than corrupt // the page. Reading that field to FIND matches is unaffected; this gate // is only about writing. if (!$this->aicobr_acf_field_is_writable($post->post_type, $field)) { return rest_ensure_response([ 'success' => false, 'reason' => 'field_not_writable', 'message' => 'That field is not a WYSIWYG field, so a link inserted into it would not render. Add the link in the page editor instead.', ]); } list($content, $is_meta) = $this->aicobr_source_body($post, $field); // Already links to the destination -> nothing to do (idempotent, no double link). if (AICOBR_Link_Scanner::links_to($content, $target_url)) { return rest_ensure_response([ 'success' => false, 'reason' => 'already_links', 'message' => 'That post already links to the destination.', ]); } // The guard: re-find the reviewed occurrence in CURRENT content. // // AICOBR_NAME_GUARD_2026_08 — rebuilt here rather than trusted from the // client, and excluding the destination itself, so the write path enforces // exactly what the scan enforced. A suggestion approved before a longer // product was published is refused now, which is the correct answer: the // sentence means something different than it did at review time. $extenders = AICOBR_Inbound_Links::extenders( $phrase, AICOBR_Inbound_Links::catalogue_names([$phrase], [$target_pid]) ); $hit = AICOBR_Link_Scanner::find_reviewed_occurrence($content, $phrase, $context, 48, $extenders); if (!$hit) { $split = $extenders && AICOBR_Link_Scanner::find_reviewed_occurrence($content, $phrase, $context) !== null; return rest_ensure_response([ 'success' => false, 'reason' => $split ? 'name_collision' : 'spot_changed', 'message' => $split ? 'That phrase now runs into a longer product name on this site, so linking it would split the name. Nothing was written.' : 'The spot you approved has changed since the scan. Re-scan this post to see current options.', ]); } // Manual splice so the stored HTML is provably identical to what is written. $anchor_text = substr($content, $hit['start'], $hit['end'] - $hit['start']); $stored_html = AICOBR_Link_Scanner::build_anchor($target_url, $anchor_text, $title_attr); $new_content = substr($content, 0, $hit['start']) . $stored_html . substr($content, $hit['end']); $updated = $this->aicobr_write_source_body($source_id, $field, $new_content); if (is_wp_error($updated)) { return new WP_Error('write_failed', 'Could not update the source: ' . $updated->get_error_message(), ['status' => 500]); } // Record it so undo can strip exactly this link later. $log = get_post_meta($source_id, '_aicobr_inbound_links', true); if (!is_array($log)) $log = []; $log[] = [ 'suggestion_id' => $sugg_id, 'target_post_id'=> $target_pid, 'target_url' => $target_url, 'anchor_text' => $anchor_text, 'stored_html' => $stored_html, // AICOBR_ACF_WRITE_2026_08 — WHERE the link was written, so undo // strips it from the same place. Absent/empty means post_content, so // links recorded before this change keep working unchanged. 'field' => $field, 'inserted_at' => current_time('mysql'), 'inserted_by' => get_current_user_id(), ]; update_post_meta($source_id, '_aicobr_inbound_links', $log); // The source post's content just moved — every cached scan that referenced // it now holds offsets and context taken from the previous revision. $this->aicobr_bump_inbound_gen(); return rest_ensure_response([ 'success' => true, 'source_post_id' => $source_id, 'target_post_id' => $target_pid, 'target_url' => $target_url, 'anchor' => $anchor_text, 'stored_html' => $stored_html, 'suggestion_id' => $sugg_id, 'message' => 'Link inserted.', ]); } /** * POST /aicobr/v1/remove-inbound-link * * AICOBR_RESCAN_CONTEXT_2026_08 * * Undo one previously-inserted inbound link. Surgical: it strips ONLY ACB's * own stored (matched on the exact HTML written at insert time) and * leaves the anchor text behind. If the source post has been edited so that * exact HTML is gone, it refuses rather than guessing — the same * correct-or-nothing posture as insertion. * * Identify the link by suggestion_id (looked up from the source post's * _aicobr_inbound_links meta) or by passing stored_html directly. */ public function remove_inbound_link($request) { $data = $request->get_json_params() ?: []; $source_id = absint($data['source_post_id'] ?? 0); $sugg_id = sanitize_text_field($data['suggestion_id'] ?? ''); $stored = (string) ($data['stored_html'] ?? ''); if (!$source_id) { return new WP_Error('bad_request', 'source_post_id is required.', ['status' => 400]); } if (!current_user_can('edit_post', $source_id)) { return new WP_Error('forbidden', 'You cannot edit that source post.', ['status' => 403]); } $post = get_post($source_id); if (!$post) { return new WP_Error('no_source', 'Source post not found.', ['status' => 404]); } $log = get_post_meta($source_id, '_aicobr_inbound_links', true); if (!is_array($log)) $log = []; // Resolve the stored HTML from the log by suggestion_id when not given. // AICOBR_ACF_WRITE_2026_08 — also recover WHICH body it was written into, // so undo strips from the same place. Older records have no 'field' key, // which correctly resolves to post_content. $log_index = null; $field = ''; if ($stored === '' && $sugg_id !== '') { foreach ($log as $i => $row) { if (($row['suggestion_id'] ?? '') === $sugg_id) { $stored = (string) ($row['stored_html'] ?? ''); $field = (string) ($row['field'] ?? ''); $log_index = $i; break; } } } elseif ($stored !== '') { foreach ($log as $i => $row) { if (($row['stored_html'] ?? '') === $stored) { $field = (string) ($row['field'] ?? ''); $log_index = $i; break; } } } if ($stored === '') { return new WP_Error('not_recorded', 'No recorded ACB link matched — nothing to remove.', ['status' => 404]); } if ($field !== '' && !preg_match('/^[A-Za-z0-9_\-]+$/', $field)) { return new WP_Error('bad_field', 'Recorded field name is invalid.', ['status' => 500]); } list($body, $is_meta) = $this->aicobr_source_body($post, $field); $new_content = AICOBR_Link_Scanner::remove_link($body, $stored); if ($new_content === false) { return rest_ensure_response([ 'success' => false, 'reason' => 'not_found', 'message' => 'The link was not found in the post — it may have been edited since. Remove it manually to be safe.', ]); } $updated = $this->aicobr_write_source_body($source_id, $field, $new_content); if (is_wp_error($updated)) { return new WP_Error('write_failed', 'Could not update the source: ' . $updated->get_error_message(), ['status' => 500]); } // Drop it from the log. if ($log_index !== null) { unset($log[$log_index]); update_post_meta($source_id, '_aicobr_inbound_links', array_values($log)); } // Undo rewrites the source too — same staleness, same invalidation. $this->aicobr_bump_inbound_gen(); return rest_ensure_response([ 'success' => true, 'source_post_id' => $source_id, 'suggestion_id' => $sugg_id, 'message' => 'Link removed.', ]); } /** * Reset a stuck entry (manual reset after >12 mins stuck) * Supports optional credit refund request */ public function reset_entry($request) { $data = $request->get_json_params(); $entry_id = absint($data['entry_id'] ?? 0); $refund_credits = !empty($data['refund_credits']); $reason = sanitize_text_field($data['reason'] ?? ''); $locked = $this->aicobr_lock_entries(); try { $entries = get_option('aicobr_entries', []); $found = false; foreach ($entries as &$entry) { if ($entry['id'] == $entry_id) { $entry['status'] = 'pending'; $entry['generationStartTime'] = null; $entry['updated_at'] = current_time('mysql'); $found = true; break; } } if (!$found) { return new WP_Error('not_found', 'Entry not found', ['status' => 404]); } update_option('aicobr_entries', $entries); } finally { if ($locked) $this->aicobr_unlock_entries(); } $refund_success = false; // Attempt a real credit refund via the license server if ($refund_credits) { $settings = get_option('aicobr_settings', []); $license_key = $settings['license_key'] ?? ''; $license_server = self::get_server_url(); if (!empty($license_key)) { $refund_response = wp_remote_post("{$license_server}/api/refund-credits", [ 'timeout' => 15, 'headers' => [ 'Content-Type' => 'application/json', 'x-generate-secret' => self::get_secret(), ], 'body' => wp_json_encode([ 'license_key' => $license_key, 'credits' => 1, // refund 1 credit — conservative default 'reason' => $reason, 'entry_id' => $entry_id, ]), ]); if (!is_wp_error($refund_response)) { $refund_body = json_decode(wp_remote_retrieve_body($refund_response), true); $refund_success = !empty($refund_body['success']); } } // Always log the refund request regardless of API outcome $logs = get_option('aicobr_refund_requests', []); $logs[] = [ 'created_at' => current_time('mysql'), 'entry_id' => $entry_id, 'reason' => $reason, 'api_refunded' => $refund_success, 'status' => $refund_success ? 'refunded' : 'pending_manual', ]; update_option('aicobr_refund_requests', $logs); } return rest_ensure_response([ 'success' => true, 'refund_requested' => $refund_credits, 'refund_success' => $refund_success, ]); } /** * Sets any image from the image_history as the active WordPress featured image. * Called when the user clicks "Set as Featured" in the image history lightbox. */ public function set_featured_image($request) { $data = $request->get_json_params(); $entry_id = absint($data['entry_id'] ?? 0); $attachment_id = absint($data['attachment_id'] ?? 0); if (!$entry_id || !$attachment_id) { return new WP_Error('missing_params', 'entry_id and attachment_id are required', ['status' => 400]); } $locked = $this->aicobr_lock_entries(); try { $entries = get_option('aicobr_entries', []); $found = false; foreach ($entries as &$entry) { if ($entry['id'] != $entry_id) continue; $post_id = absint($entry['post_id'] ?? 0); if (!$post_id) { return new WP_Error('no_post', 'Entry has no associated post', ['status' => 400]); } // Ensure the caller can edit THIS post (avoids IDOR on posts they don't own). if (!current_user_can('edit_post', $post_id)) { return new WP_Error('forbidden', 'You cannot edit this post.', ['status' => 403]); } // Set as WordPress featured image set_post_thumbnail($post_id, $attachment_id); // Update the active featured_image URL on the entry $new_url = wp_get_attachment_url($attachment_id); $entry['featured_image'] = $new_url; $entry['featured_image_id'] = $attachment_id; // Mark which image is active in image_history if (!empty($entry['image_history']) && is_array($entry['image_history'])) { foreach ($entry['image_history'] as &$img) { $img['is_active'] = ($img['attachment_id'] === $attachment_id); } } $found = true; break; } if (!$found) { return new WP_Error('not_found', 'Entry not found', ['status' => 404]); } update_option('aicobr_entries', $entries); return rest_ensure_response(['success' => true]); } finally { if ($locked) $this->aicobr_unlock_entries(); } } /** * Append a freshly-generated image to an entry's image_history (image-only * regeneration). Called SERVER-TO-SERVER by the licence server's * /api/regenerate-image pipeline after it has generated a new image and * pushed it through /upload-image. Unlike set_featured_image (which only * re-flags an image that's already in history), this ADDS a new one. * * Secured by the shared generate secret (the route's verify_secret * permission_callback) — there is no logged-in user on a server-to-server * call, so, exactly like /publish and /upload-image, it trusts the secret * and the fact the entry_id originated from the authenticated trigger. * * This runs under the SAME entries lock as the publish path, so an image * append and a status write can never clobber each other (the non-destructive * fix from 2026-07-06 relies on this coexistence). */ public function append_image($request) { $data = $request->get_json_params(); $entry_id = absint($data['entry_id'] ?? 0); $attachment_id = absint($data['attachment_id'] ?? 0); // Default to making the new image active — auto-active with easy revert // via the existing image-history picker (see the image-regen scope). $set_active = array_key_exists('set_active', (array) $data) ? (bool) $data['set_active'] : true; // AICOBR_INBODY_IMAGES_2026_08 // An in-body image is NOT a featured image. It must never set the post // thumbnail and must never overwrite featured_image / featured_image_id, // or generating an illustration for section three would silently replace // the article's lead image. The role is sent explicitly by the licence // server rather than inferred, and set_active is forced off for it. $img_role = (($data['role'] ?? 'featured') === 'body') ? 'body' : 'featured'; if ($img_role === 'body') $set_active = false; $img_layout = sanitize_text_field($data['layout'] ?? ''); if (!in_array($img_layout, ['band', 'inline-left', 'inline-right'], true)) $img_layout = 'band'; $img_alt = $this->aicobr_str_cap(sanitize_text_field($data['alt_text'] ?? ''), 250); $img_caption = $this->aicobr_str_cap(sanitize_text_field($data['caption'] ?? ''), 200); $img_sug_id = sanitize_text_field($data['suggestion_id'] ?? ''); $img_placement = []; if (!empty($data['placement']) && is_array($data['placement'])) { $img_placement = [ 'section_heading' => $this->aicobr_str_cap(sanitize_text_field($data['placement']['section_heading'] ?? ''), 200), 'position' => sanitize_key($data['placement']['position'] ?? 'after-heading'), ]; } if (!$entry_id || !$attachment_id) { return new WP_Error('missing_params', 'entry_id and attachment_id are required', ['status' => 400]); } // Resolve the new image URL up front; a missing attachment is a hard error // (the licence server just uploaded it, so it should exist). $new_url = wp_get_attachment_url($attachment_id); if (!$new_url) { return new WP_Error('bad_attachment', 'Attachment not found', ['status' => 400]); } $locked = $this->aicobr_lock_entries(); try { $entries = get_option('aicobr_entries', []); $found = false; foreach ($entries as &$entry) { if ($entry['id'] != $entry_id) continue; // ── Append to image_history — the EXACT block the publish path uses // (ai-content-bridge.php publish handler ~2421-2437). Never // overwrite previous images; mark the new one active. ── if (!isset($entry['image_history']) || !is_array($entry['image_history'])) { $entry['image_history'] = []; } // Only a FEATURED image deactivates the previous featured image. // An in-body image is an addition, not a replacement, so it must not // clear the active flag on the post's lead image. if ($img_role !== 'body') { foreach ($entry['image_history'] as &$img) { $img['is_active'] = false; } unset($img); } $entry['image_history'][] = array_merge([ 'attachment_id' => $attachment_id, 'url' => $new_url, 'generated_at' => current_time('mysql'), 'is_active' => ($img_role !== 'body'), // AICOBR_INBODY_IMAGES_2026_08 — a row with no role is a legacy // featured image, so absence reads correctly on existing data. 'role' => $img_role, ], $img_role === 'body' ? [ 'layout' => $img_layout, 'placement' => $img_placement, 'alt_text' => $img_alt, 'caption' => $img_caption, 'suggestion_id' => $img_sug_id, 'inserted' => false, ] : []); // ── Optionally make it the featured image — same write as // set_featured_image. Auto-active by default; the user can revert // to any earlier image from the picker (which flips is_active back). if ($set_active) { $post_id = absint($entry['post_id'] ?? 0); if ($post_id) { set_post_thumbnail($post_id, $attachment_id); } $entry['featured_image'] = $new_url; $entry['featured_image_id'] = $attachment_id; } $found = true; break; } unset($entry); if (!$found) { return new WP_Error('not_found', 'Entry not found', ['status' => 404]); } update_option('aicobr_entries', $entries); return rest_ensure_response([ 'success' => true, 'attachment_id' => $attachment_id, 'url' => $new_url, ]); } finally { if ($locked) $this->aicobr_unlock_entries(); } } /** * Returns ACF text/textarea/wysiwyg fields registered to a given post type. * Returns an empty array if ACF is not active — no errors. */ /** * POST /aicobr/v1/image-suggestions * * AICOBR_INBODY_IMAGES_2026_08 * * Retroactive in-body image suggestions for a post that already exists — * the half of the feature that works on everything a user generated before * it shipped. * * The article body is read HERE with get_post(), not accepted from the * browser: /entries deliberately does not persist post content, and the * client has no business asserting what a post says. The licence server * validates every returned heading against this same text, so a suggestion * it cannot anchor is dropped rather than producing a placement the * insertion step could never find. * * Free: no credit is deducted here or on the licence server. The spend * happens when the user generates one of the suggestions. */ public function get_image_suggestions($request) { $data = $request->get_json_params() ?: []; $entry_id = isset($data['entry_id']) && $data['entry_id'] !== '' ? (string) $data['entry_id'] : ''; if ($entry_id === '') { return new WP_Error('missing_entry', 'entry_id is required.', ['status' => 400]); } $settings = get_option('aicobr_settings', []); $license_key = $settings['license_key'] ?? ''; if (empty($license_key)) { return new WP_Error('no_license', 'No license key configured. Please visit Settings.', ['status' => 403]); } // Resolve the entry -> post. $entries = get_option('aicobr_entries', []); $entry = null; foreach ((array) $entries as $e) { if (isset($e['id']) && (string) $e['id'] === $entry_id) { $entry = $e; break; } } if (!$entry) { return new WP_Error('not_found', 'Entry not found.', ['status' => 404]); } $post_id = absint($entry['post_id'] ?? 0); $post = $post_id ? get_post($post_id) : null; if (!$post) { return new WP_Error('no_post', 'This brief has no generated post yet.', ['status' => 400]); } // Per-post capability, not just edit_posts — the same rule the insert // route uses, so reading a post's body can't be used to sidestep it. if (!current_user_can('edit_post', $post_id)) { return new WP_Error('forbidden', 'You cannot edit this post.', ['status' => 403]); } $payload = [ 'title' => (string) get_the_title($post), 'content' => (string) $post->post_content, 'content_type' => sanitize_text_field($entry['content_type'] ?? 'blog_post'), 'count' => max(0, min(3, intval($data['count'] ?? 3))), // Cost attribution only — the licence server never gates on these. 'license_key' => (string) $license_key, 'domain' => wp_parse_url(home_url(), PHP_URL_HOST), ]; $response = wp_remote_post(self::get_server_url() . '/api/image-suggestions', [ 'timeout' => 120, // one small Claude call; the user is waiting in the modal 'headers' => [ 'Content-Type' => 'application/json', 'x-generate-secret' => self::get_secret(), ], 'body' => wp_json_encode($payload), ]); if (is_wp_error($response)) { aicobr_dbg('[ACB] get_image_suggestions: wp_remote_post failed: ' . $response->get_error_message()); return new WP_Error('server_unreachable', 'Could not reach the suggestion server. Please try again.', ['status' => 502]); } $code = wp_remote_retrieve_response_code($response); $body = json_decode(wp_remote_retrieve_body($response), true); if ($code >= 400 || empty($body['success'])) { $err = $body['error'] ?? 'Could not generate image suggestions.'; return new WP_Error($body['code'] ?? 'suggest_failed', $err, ['status' => $code >= 400 ? $code : 500]); } // Sanitise through the SAME function the publish path uses, so a // retroactive suggestion and a generation-time one are stored identically // and the modal cannot tell them apart. $suggestions = $this->aicobr_sanitise_image_suggestions($body['suggestions'] ?? []); // Persist onto the entry under the entries lock, preserving any existing // suggestion the user has already used — a fresh run must not silently // un-tick work they have done. $locked = $this->aicobr_lock_entries(); $all = get_option('aicobr_entries', []); foreach ($all as &$en) { if ((string) ($en['id'] ?? '') !== $entry_id) continue; $used = []; foreach ((array) ($en['image_suggestions'] ?? []) as $prev) { if (!empty($prev['used'])) $used[] = $prev; } $usedIds = wp_list_pluck($used, 'id'); $fresh = array_values(array_filter($suggestions, function ($sg) use ($usedIds) { return !in_array($sg['id'], $usedIds, true); })); $en['image_suggestions'] = array_merge($used, $fresh); $suggestions = $en['image_suggestions']; break; } unset($en); update_option('aicobr_entries', $all); if ($locked) $this->aicobr_unlock_entries(); return rest_ensure_response([ 'success' => true, 'suggestions' => $suggestions, ]); } /** * POST /aicobr/v1/insert-youtube * * AICOBR_YT_INSERT_FIX_2026_08 * * Place a YouTube URL into the body of an ACB post so WordPress auto-embeds * it. Replaces insertYouTubeIntoPost() in ContentDiary.js, which spliced the * content from the browser and carried two faults: * * 1. It called /wp/v2/posts/{id} with the post type hard-coded. Since * 2.8.5 the Landing Page, Service Page, About Us and FAQ Page types * publish as `page`, and events as `tribe_events`, so the route was * simply wrong for all of them and the insert failed outright. * * 2. It read `post.content.raw || post.content.rendered`. apiFetch sends * no context=edit, so the response carries no `content.raw` and the * expression fell through to `rendered` — then wrote rendered HTML back * as the raw content. Shortcodes arrive expanded, so a * [content_cluster] block would be frozen into static HTML and * [content_featured_image] into a fixed that stops tracking the * featured image. Silent, and only visible much later. * * Doing it in PHP avoids both by construction: post_content is read raw, and * the post type is whatever the post actually is. */ public function insert_youtube_into_post($request) { $data = $request->get_json_params() ?: []; $entry_id = isset($data['entry_id']) && $data['entry_id'] !== '' ? (string) $data['entry_id'] : ''; $url = trim((string) ($data['url'] ?? '')); $position = sanitize_key($data['position'] ?? 'top'); if ($entry_id === '' || $url === '') { return new WP_Error('missing_params', 'entry_id and url are required.', ['status' => 400]); } // Only YouTube. The URL is written into post content and relied on to // auto-embed, so an arbitrary URL here would be an injection surface. $host = strtolower((string) wp_parse_url($url, PHP_URL_HOST)); $host = preg_replace('/^www\./', '', $host); if (!in_array($host, ['youtube.com', 'm.youtube.com', 'youtu.be', 'youtube-nocookie.com'], true)) { return new WP_Error('bad_url', 'That does not look like a YouTube URL.', ['status' => 400]); } $url = esc_url_raw($url); if ($url === '') { return new WP_Error('bad_url', 'That URL could not be read.', ['status' => 400]); } $entries = get_option('aicobr_entries', []); $entry = null; foreach ((array) $entries as $e) { if (isset($e['id']) && (string) $e['id'] === $entry_id) { $entry = $e; break; } } if (!$entry) { return new WP_Error('not_found', 'Entry not found.', ['status' => 404]); } $post_id = absint($entry['post_id'] ?? 0); if (!$post_id || !get_post($post_id)) { return new WP_Error('no_post', 'This brief has no generated post yet.', ['status' => 400]); } if (!current_user_can('edit_post', $post_id)) { return new WP_Error('forbidden', 'You cannot edit this post.', ['status' => 403]); } if (!self::aicobr_is_generated_post($post_id) && !$this->aicobr_post_in_diary($post_id)) { return new WP_Error('not_ours', 'This post was not created by AI Content Bridge.', ['status' => 403]); } // A page builder owns the layout — same hard refusal as image insertion. if (self::aicobr_is_page_builder_post($post_id)) { return rest_ensure_response([ 'success' => false, 'reason' => 'page_builder', 'message' => 'This post is built with a page builder, so ACB will not edit its content. Paste the video URL on its own line where you want it instead.', 'url' => $url, ]); } $post = get_post($post_id); $content = (string) $post->post_content; $stored = (string) get_post_meta($post_id, '_aicobr_content_hash', true); $edited = ($stored === '' || $stored !== self::aicobr_content_hash($content)); $updated = self::aicobr_insert_embed_url($content, $url, $position); if (is_wp_error($updated)) { return rest_ensure_response([ 'success' => false, 'reason' => $updated->get_error_code(), 'message' => $updated->get_error_message(), 'url' => $url, ]); } // AICOBR_WP_SLASH_2026_08 — $content came from get_post()->post_content // (unslashed) and $updated is that body with an embed spliced in. // wp_update_post unslashes before writing, so slash here or lose backslashes. $res = wp_update_post(['ID' => $post_id, 'post_content' => wp_slash($updated)], true); if (is_wp_error($res)) { return new WP_Error('save_failed', $res->get_error_message(), ['status' => 500]); } $saved = get_post($post_id); update_post_meta($post_id, '_aicobr_content_hash', self::aicobr_content_hash($saved->post_content)); aicobr_dbg("[ACB] youtube embed inserted into post {$post_id} ({$position})"); return rest_ensure_response([ 'success' => true, 'post_id' => $post_id, 'edited' => $edited, 'position' => $position, ]); } /** * AICOBR_YT_INSERT_FIX_2026_08 * * Insert a bare URL into content so WordPress auto-embeds it. * * Auto-embed only fires when the URL is alone in its own paragraph, so it is * written on its own line with blank lines either side. * * Placement stays INSIDE the .acb-content wrapper when one is present. The * old browser-side version prepended to the whole string, which put the * embed above the opening div and outside every scoped style — and appending * put it after the closing div. Both looked like a stray element rather than * part of the article. */ private static function aicobr_insert_embed_url($html, $url, $position) { $html = (string) $html; $block = "\n\n" . $url . "\n\n"; // Bounds of the article body, inside the wrapper when there is one. // // AICOBR_YT_WRAPPER_SEARCH_2026_08 // Located by SEARCHING for the wrapper, not by anchoring to the start of // the string. The anchored version (/^\s*'); if ($close !== false && $close > $start) $end = $close; } if ($position === 'top') { $at = $start; } elseif ($position === 'after-first') { // After the first block of prose. NOT by hunting for '

' — the old // version did that, and classic-editor content stores paragraphs as // bare text with wpautop creating the

at render time, so on a // real ACB landing page there is no '

' to find and the option // silently appended to the end instead. $at = self::aicobr_first_block_end($html, $start, $end); if ($at === false) $at = $end; } else { $at = $end; } return substr($html, 0, $at) . $block . substr($html, $at); } /** * End offset of the first paragraph-ish block of prose within a range, or * false. Works on both markup shapes: a closing

when one exists, and * otherwise the blank line that separates bare-text paragraphs. */ private static function aicobr_first_block_end($html, $from, $to) { $seg = substr($html, $from, $to - $from); // Skip a leading heading, rule or component so the video does not land // between a heading and the text it introduces. $offset = self::aicobr_prose_anchor_offset($html, $from, $to) - $from; if ($offset < 0) $offset = 0; $p = stripos($seg, '

', $offset); if ($p !== false) return $from + $p + 4; // Bare-text paragraph: ends at the next blank line. if (preg_match('/\R[ \t]*\R/', $seg, $m, PREG_OFFSET_CAPTURE, $offset)) { return $from + $m[0][1] + strlen($m[0][0]); } return false; } /** * POST /aicobr/v1/insert-image * * AICOBR_INBODY_INSERT_2026_08 * * Place an already-generated image into the body of an ACB-written post. * Nothing is generated here and no credit is spent — the image exists in the * media library already; this is purely the content edit. * * This is the only place ACB writes into a post after creating it, so the * guards are layered and deliberately fail CLOSED: * * 1. The caller must be able to edit THIS post (not merely edit_posts). * 2. The post must be one ACB created (_aicobr_generated, with the * content-diary fallback the publish handler already uses). * 3. No page builder may own the layout. Elementor and friends render from * their own postmeta; a post_content edit is ignored or desynchronises * them. This is a hard block, not a warning. * 4. The target section must still exist. ACB never guesses a position. * * The content fingerprint is a SOFT signal by design. A fresh ACB draft that * the user opened in Gutenberg and saved without changing a word is exactly * the moment they will first try this feature, and refusing there would make * the feature look broken. So a mismatch still inserts, and returns * edited=true so the UI can say "this post has been edited since ACB wrote * it — check the placement". A page builder is the case that actually * cannot work, and that one refuses. * * Every refusal returns a `shortcode` the user can paste where they want it, * so the feature degrades visibly instead of silently doing nothing. */ public function insert_image_into_post($request) { $data = $request->get_json_params() ?: []; $entry_id = isset($data['entry_id']) && $data['entry_id'] !== '' ? (string) $data['entry_id'] : ''; $attachment_id = absint($data['attachment_id'] ?? 0); $layout = sanitize_text_field($data['layout'] ?? 'band'); $position = sanitize_key($data['position'] ?? 'after-heading'); $heading = sanitize_text_field($data['section_heading'] ?? ''); $alt = sanitize_text_field($data['alt_text'] ?? ''); $caption = sanitize_text_field($data['caption'] ?? ''); if ($entry_id === '' || !$attachment_id) { return new WP_Error('missing_params', 'entry_id and attachment_id are required.', ['status' => 400]); } if (!wp_get_attachment_url($attachment_id)) { return new WP_Error('bad_attachment', 'That image no longer exists in the media library.', ['status' => 400]); } // Resolve the entry -> post. $entries = get_option('aicobr_entries', []); $entry = null; foreach ((array) $entries as $e) { if (isset($e['id']) && (string) $e['id'] === $entry_id) { $entry = $e; break; } } if (!$entry) { return new WP_Error('not_found', 'Entry not found.', ['status' => 404]); } $post_id = absint($entry['post_id'] ?? 0); if (!$post_id || !get_post($post_id)) { return new WP_Error('no_post', 'This brief has no generated post yet.', ['status' => 400]); } // Guard 1 — per-post capability, not just edit_posts (avoids IDOR). if (!current_user_can('edit_post', $post_id)) { return new WP_Error('forbidden', 'You cannot edit this post.', ['status' => 403]); } // Guard 2 — ACB ownership, same rule as the publish handler. if (!self::aicobr_is_generated_post($post_id) && !$this->aicobr_post_in_diary($post_id)) { return new WP_Error('not_ours', 'This post was not created by AI Content Bridge.', ['status' => 403]); } $figure = self::aicobr_build_figure($attachment_id, $alt, $caption, $layout); $shortcode = '[content_image id="' . $attachment_id . '" layout="' . esc_attr($layout) . '"' . ($caption !== '' ? ' caption="' . esc_attr($caption) . '"' : '') . ($alt !== '' ? ' alt="' . esc_attr($alt) . '"' : '') . ']'; // Guard 3 — a page builder owns the layout. Hard refusal. if (self::aicobr_is_page_builder_post($post_id)) { return rest_ensure_response([ 'success' => false, 'reason' => 'page_builder', 'message' => 'This post is built with a page builder, so ACB will not edit its content. Paste this shortcode where you want the image instead.', 'shortcode' => $shortcode, ]); } $post = get_post($post_id); $content = (string) $post->post_content; // Soft signal — has the post changed since ACB wrote it? $stored = (string) get_post_meta($post_id, '_aicobr_content_hash', true); $edited = ($stored === '' || $stored !== self::aicobr_content_hash($content)); // Guard 4 — the anchor must still exist. Never guess. $updated = self::aicobr_insert_figure($content, $figure, $heading, $position); if (is_wp_error($updated)) { return rest_ensure_response([ 'success' => false, 'reason' => $updated->get_error_code(), 'message' => $updated->get_error_message() . ' Paste this shortcode where you want the image instead.', 'shortcode' => $shortcode, ]); } // AICOBR_WP_SLASH_2026_08 — same contract as the embed path above: // $updated is the post body with a figure spliced in; wp_update_post // unslashes before the DB write, so slash to preserve backslashes. $res = wp_update_post(['ID' => $post_id, 'post_content' => wp_slash($updated)], true); if (is_wp_error($res)) { return new WP_Error('save_failed', $res->get_error_message(), ['status' => 500]); } // Re-fingerprint so a SECOND insertion on the same post is not told the // user edited it — ACB's own write is not a user edit. $saved = get_post($post_id); update_post_meta($post_id, '_aicobr_content_hash', self::aicobr_content_hash($saved->post_content)); // Mark the image as placed and its suggestion as used, so the modal can // flip the button and grey the chip. Both writes happen under the SAME // entries lock every other read-modify-write of this option uses — an // unlocked write here would race the diary's ~2.5s POST-back exactly as // the image_history append once did. $suggestion_id = sanitize_text_field($data['suggestion_id'] ?? ''); $locked = $this->aicobr_lock_entries(); $all = get_option('aicobr_entries', []); foreach ($all as &$en) { if ((string) ($en['id'] ?? '') !== $entry_id) continue; if (!empty($en['image_history']) && is_array($en['image_history'])) { foreach ($en['image_history'] as &$hist) { if (absint($hist['attachment_id'] ?? 0) === $attachment_id) { $hist['inserted'] = true; break; } } unset($hist); } if ($suggestion_id !== '' && !empty($en['image_suggestions']) && is_array($en['image_suggestions'])) { foreach ($en['image_suggestions'] as &$sug) { if (($sug['id'] ?? '') === $suggestion_id) { $sug['used'] = true; break; } } unset($sug); } break; } unset($en); update_option('aicobr_entries', $all); if ($locked) $this->aicobr_unlock_entries(); aicobr_dbg("[ACB] in-body image {$attachment_id} inserted into post {$post_id} ({$layout}/{$position})"); return rest_ensure_response([ 'success' => true, 'post_id' => $post_id, 'edited' => $edited, 'layout' => $layout, 'position' => $position, ]); } public function get_acf_fields($request) { $post_type = sanitize_text_field($request->get_param('post_type') ?? 'post'); // ACF not installed — return empty gracefully if (!function_exists('acf_get_field_groups')) { return rest_ensure_response([]); } $allowed_types = ['text', 'textarea', 'wysiwyg']; $result = []; // Get all field groups assigned to this post type $groups = acf_get_field_groups(['post_type' => $post_type]); foreach ($groups as $group) { $fields = acf_get_fields($group['key']); if (!$fields) continue; foreach ($fields as $field) { if (!in_array($field['type'], $allowed_types)) continue; $result[] = [ 'key' => $field['key'], 'name' => $field['name'], 'label' => $field['label'], 'type' => $field['type'], 'group' => $group['title'], ]; } } return rest_ensure_response($result); } /** * Returns all publicly usable post types registered on the site, * filtered to exclude WordPress internals. */ public function get_post_types() { $excluded = [ 'attachment', 'revision', 'nav_menu_item', 'custom_css', 'customize_changeset', 'oembed_cache', 'user_request', 'wp_block', 'wp_template', 'wp_template_part', 'wp_navigation', 'wp_font_face', 'wp_font_family', 'wp_global_styles' ]; $post_types = get_post_types(['public' => true], 'objects'); $result = []; foreach ($post_types as $slug => $obj) { if (in_array($slug, $excluded)) continue; $result[] = [ 'slug' => $slug, 'label' => $obj->labels->singular_name ?: $obj->label, 'plural_label' => $obj->label, 'supports_thumbnail' => post_type_supports($slug, 'thumbnail'), 'supports_excerpt' => post_type_supports($slug, 'excerpt'), 'is_woocommerce' => ($slug === 'product'), ]; } return rest_ensure_response($result); } /** * AICOBR_CLUSTER_PILLAR_PICKER_2026_08 * * Search the site's own published pages and posts so the Content Strategist * can offer them as the "pillar" a planned cluster will support. Returns the * live permalink because that URL is snapshotted onto each planned brief and * handed to the generation prompt — the spoke links up to a page that already * exists, which is what makes v1 possible without ever editing that page. * * Read-only. Requires edit_posts, same as /post-types and /acf-fields. * * `excerpt` carries a trimmed slice of the page's own copy; the licence * server feeds it into the planning prompt so the spokes it plans genuinely * support THIS page rather than being guessed from the title alone. */ /** * AICOBR_CLUSTER_PILLAR_CPT_2026_08 * Singular label for a post type, for the pillar picker. "Page" stays blank * because a page is the unmarked default and labelling it adds noise. */ private function aicobr_post_type_label($post_type) { if ($post_type === 'page') return ''; $obj = get_post_type_object($post_type); if (!$obj) return ''; $label = $obj->labels->singular_name ?? $obj->label ?? $post_type; return (string) $label; } public function get_linkable_pages($request) { $search = sanitize_text_field((string) $request->get_param('s')); $per_page = absint($request->get_param('per_page')) ?: 20; $per_page = max(1, min(50, $per_page)); // AICOBR_CLUSTER_PILLAR_CPT_2026_08 // The pillar picker searched ['page','post'] only, so a site whose real // landing pages are a custom post type could not choose them as pillars // at all. On a vehicle site the intended pillars are exactly that: a // "Makes" CPT holding "Sell my Ford van", a "Models" CPT holding "Sell // my Ford Transit Custom". These are the pages the owner wants to rank // and build clusters around, and ACB was hiding them. // // Every PUBLIC post type is now eligible. Public is the right test: a // pillar has to be a real, indexable URL a reader can land on, which is // exactly what public post types are and what internal ones (revisions, // menu items, ACF field groups) are not. A site can still narrow or // widen the set with the filter, which also carries the honest caveat // for builder-managed types: see the note on the response. $types = get_post_types(['public' => true], 'names'); unset($types['attachment']); $types = array_values($types); /** * Filter which post types can be chosen as a cluster pillar. * * @param string[] $types Public post type names. * @param string $context 'pillar_picker' */ $types = apply_filters('aicobr_pillar_post_types', $types, 'pillar_picker'); $types = array_values(array_filter((array) $types, 'post_type_exists')); if (!$types) $types = ['page', 'post']; $args = [ 'post_type' => $types, 'post_status' => 'publish', 'posts_per_page' => $per_page, 'orderby' => 'title', 'order' => 'ASC', 'suppress_filters' => false, // Skip the password-protected: their content can't be relied on as a // public landing target for an internal link. 'has_password' => false, ]; if ($search !== '') { $args['s'] = $search; // Relevance beats alphabetical once the user is actually searching. $args['orderby'] = 'relevance'; } $posts = get_posts($args); $result = []; foreach ($posts as $p) { // AICOBR_CLUSTER_PILLAR_PICKER_2026_08 // ACB-generated posts ARE offered. An earlier revision skipped them // on the theory that a cluster under an ACB-written page was // circular — that was wrong. Service Page is one of ACB's own // content types, so generating a service page and then building its // supporting cluster is the intended workflow, not an edge case. // It is also how a user creates a pillar they do not already have. // The only exclusion that matters is self-linking, which is handled // at render time in aicobr_cluster_spokes(). $excerpt = has_excerpt($p) ? $p->post_excerpt : $p->post_content; $excerpt = wp_strip_all_tags(strip_shortcodes((string) $excerpt), true); $excerpt = trim(preg_replace('/\s+/', ' ', $excerpt)); $result[] = [ 'id' => (int) $p->ID, 'title' => html_entity_decode(get_the_title($p), ENT_QUOTES, 'UTF-8'), 'url' => get_permalink($p), 'type' => $p->post_type, // AICOBR_CLUSTER_PILLAR_CPT_2026_08 — a readable label so the // picker can show "Make" or "Model" beside a result, since with // CPTs in the list a bare title no longer tells the user what // kind of thing they are choosing. 'type_label' => $this->aicobr_post_type_label($p->post_type), 'excerpt' => $this->aicobr_str_cap($excerpt, 2000), // AICOBR_MB_GUARD_2026_08 ]; } return rest_ensure_response($result); } /** * POST /ai-content/v1/create-checkout * * Proxies a Stripe Checkout session request to the license server. * Returns the Stripe Checkout URL for the browser to redirect to. */ public function create_checkout($request) { $settings = get_option('aicobr_settings', []); $license_key = $settings['license_key'] ?? ''; if (empty($license_key)) { return new WP_Error('no_license', 'No license key configured.', ['status' => 403]); } $data = $request->get_json_params(); $bundle_id = sanitize_text_field($data['bundle_id'] ?? ''); $plan = sanitize_text_field($data['plan'] ?? ''); $interval = sanitize_text_field($data['interval'] ?? 'month'); if (empty($bundle_id) && empty($plan)) { return new WP_Error('no_item', 'bundle_id or plan is required.', ['status' => 400]); } $admin_url = admin_url('admin.php?page=ai-content-bridge'); $license_url = self::get_server_url(); $payload = [ 'license_key' => $license_key, 'success_url' => $admin_url, 'cancel_url' => $admin_url, ]; if (!empty($plan)) { $payload['plan'] = $plan; $payload['interval'] = ($interval === 'year') ? 'year' : 'month'; } else { $payload['bundle_id'] = $bundle_id; } $response = wp_remote_post("{$license_url}/api/create-checkout-session", [ 'timeout' => 20, 'headers' => ['Content-Type' => 'application/json'], 'body' => wp_json_encode($payload), ]); if (is_wp_error($response)) { return new WP_Error('server_error', 'Could not reach license server.', ['status' => 502]); } $body = json_decode(wp_remote_retrieve_body($response), true); $code = wp_remote_retrieve_response_code($response); if ($code !== 200 || empty($body['success'])) { return new WP_Error('checkout_failed', $body['error'] ?? 'Checkout failed.', ['status' => 500]); } return rest_ensure_response([ 'success' => true, 'checkout_url' => $body['checkout_url'], ]); } /** * POST /ai-content/v1/upload-image * * Accepts a base64-encoded image from the license server, * uploads it to the WordPress Media Library, and returns * the attachment ID and URL. * * Secured by the same generate_secret used for /trigger-generate. * No user credentials needed — runs inside WordPress context. */ public function upload_image($request) { // Verify the shared secret (constant-time) so only our licence server can call this if (!self::verify_secret($request)) { return new WP_Error('unauthorized', 'Unauthorized', ['status' => 401]); } $data = $request->get_json_params(); $b64 = $data['image_b64'] ?? ''; $title = sanitize_text_field($data['title'] ?? 'featured-image'); $post_id = absint($data['post_id'] ?? 0); if (empty($b64)) { return new WP_Error('no_image', 'No image data provided', ['status' => 400]); } $image_data = base64_decode($b64); if (strlen($image_data) < 100) { return new WP_Error('invalid_image', 'Image data invalid or too small', ['status' => 400]); } // Provides wp_generate_attachment_metadata(), used below. require_once(ABSPATH . 'wp-admin/includes/image.php'); $filename = 'acb-' . sanitize_file_name($title) . '-' . time() . '.png'; $upload = wp_upload_bits($filename, null, $image_data); if ($upload['error']) { aicobr_dbg('[ACB] upload_image: wp_upload_bits error: ' . $upload['error']); return new WP_Error('upload_failed', $upload['error'], ['status' => 500]); } $attachment_id = wp_insert_attachment([ 'post_mime_type' => 'image/png', 'post_title' => sanitize_file_name($title), 'post_status' => 'inherit', ], $upload['file'], $post_id ?: 0); if (is_wp_error($attachment_id)) { aicobr_dbg('[ACB] upload_image: wp_insert_attachment error: ' . $attachment_id->get_error_message()); return new WP_Error('attachment_failed', $attachment_id->get_error_message(), ['status' => 500]); } $meta = wp_generate_attachment_metadata($attachment_id, $upload['file']); wp_update_attachment_metadata($attachment_id, $meta); $attachment_url = wp_get_attachment_url($attachment_id); aicobr_dbg("[ACB] upload_image: OK — attachment_id={$attachment_id}, url={$attachment_url}"); return rest_ensure_response([ 'success' => true, 'attachment_id' => $attachment_id, 'url' => $attachment_url, ]); } /** * POST /ai-content/v1/trigger-generate * * Called by the React dashboard when a user clicks Generate. * Reads the license key and secret from wp_options (server-side only — * these never travel through the browser), then forwards the generation * request to the license server via a server-to-server HTTP call. * * The license server responds immediately (credits deducted, job queued), * and posts back to /publish when generation is complete. */ /** * Proxy to the licence server's /api/outline. Browser → WP REST → server, * so the shared secret never travels through the browser. Returns the * structured outline JSON for the review step. */ public function get_outline($request) { $data = $request->get_json_params(); $generate_secret = self::get_secret(); // license_key + domain: cost attribution only (the licence server logs // the outline's real Anthropic/Serper cost against this licence — see // add-cost-columns.sql). Read server-side from the stored option; the // key never travels through the browser. The server never gates the // outline on these — an older payload without them still works. $settings = get_option('aicobr_settings', []); $payload = [ 'keyword' => sanitize_text_field($data['keyword'] ?? ''), 'title' => sanitize_text_field($data['title'] ?? ''), 'serp_gl' => sanitize_text_field($data['serp_gl'] ?? ''), 'serp_hl' => sanitize_text_field($data['serp_hl'] ?? ''), 'license_key' => (string) ($settings['license_key'] ?? ''), 'domain' => wp_parse_url(home_url(), PHP_URL_HOST), ]; $license_server_url = self::get_server_url(); $response = wp_remote_post("{$license_server_url}/api/outline", [ 'timeout' => 60, 'headers' => [ 'Content-Type' => 'application/json', 'x-generate-secret' => $generate_secret, ], 'body' => wp_json_encode($payload), ]); if (is_wp_error($response)) { aicobr_dbg('[ACB] get_outline: wp_remote_post failed: ' . $response->get_error_message()); return new WP_Error('server_unreachable', 'Could not reach the outline server. Please try again.', ['status' => 502]); } $code = wp_remote_retrieve_response_code($response); $body = json_decode(wp_remote_retrieve_body($response), true); if ($code >= 400) { return new WP_Error('outline_failed', $body['error'] ?? 'Outline generation failed.', ['status' => $code]); } return rest_ensure_response($body); } public function trigger_generate($request) { $data = $request->get_json_params(); // Read credentials from saved settings — never from the request $settings = get_option('aicobr_settings', []); $license_key = $settings['license_key'] ?? ''; $generate_secret = self::get_secret(); if (empty($license_key)) { return new WP_Error('no_license', 'No license key configured. Please visit Settings.', ['status' => 403]); } // Build the payload for the license server $payload = [ 'license_key' => $license_key, 'domain' => !empty($settings['domain']) ? $settings['domain'] : wp_parse_url(home_url(), PHP_URL_HOST), 'title' => sanitize_text_field($data['title'] ?? ''), 'primary_keyword' => sanitize_text_field($data['primary_keyword'] ?? ''), 'target_word_count' => intval($data['target_word_count'] ?? 1500), 'special_instructions'=> sanitize_textarea_field($data['special_instructions'] ?? ''), 'image_prompt' => sanitize_text_field($data['image_prompt'] ?? ''), 'image_style' => sanitize_text_field($data['image_style'] ?? 'professional'), 'include_youtube' => !empty($data['include_youtube']), 'include_image' => isset($data['include_image']) ? (bool)$data['include_image'] : true, 'publish_date' => sanitize_text_field($data['publish_date'] ?? ''), 'brand_voice' => $data['brand_voice'] ?? ($settings['brand_voice'] ?? ''), 'tone' => sanitize_text_field($data['tone'] ?? ($settings['tone'] ?? 'professional')), 'post_id' => !empty($data['post_id']) ? intval($data['post_id']) : null, 'post_type' => sanitize_text_field($data['post_type'] ?? 'post'), 'acf_field_key' => sanitize_text_field($data['acf_field_key'] ?? ''), 'content_type' => sanitize_text_field($data['content_type'] ?? 'blog_post'), 'content_type_meta' => $data['content_type_meta'] ?? [], // AICOBR_INBODY_IMAGES_2026_08 — opt-in for in-body image // suggestions. Costs no extra credit: the model returns descriptions // alongside the SEO block on the call we are already making. The // licence server clamps the count to the content type's ceiling. 'suggest_images' => !empty($data['suggest_images']), 'image_suggestion_count' => max(0, min(3, intval($data['image_suggestion_count'] ?? 3))), // SEO outline-review flow — forwarded to the licence server's generateContent 'approved_outline' => (isset($data['approved_outline']) && is_array($data['approved_outline'])) ? $data['approved_outline'] : null, 'serp_gl' => sanitize_text_field($data['serp_gl'] ?? ''), // Round-tripped so /publish can match the exact diary entry (not by // title, which can collide when the same title is generated twice). 'entry_id' => isset($data['entry_id']) && $data['entry_id'] !== '' ? (string) $data['entry_id'] : null, // LMS integration 'mailpoet_list_id' => intval($data['mailpoet_list_id'] ?? 0), 'tnp_list_id' => intval($data['tnp_list_id'] ?? 0), 'tec_start_date' => sanitize_text_field($data['tec_start_date'] ?? ''), 'tec_end_date' => sanitize_text_field($data['tec_end_date'] ?? ''), 'tec_cost' => sanitize_text_field($data['tec_cost'] ?? ''), 'tec_url' => esc_url_raw($data['tec_url'] ?? ''), 'tec_venue' => sanitize_text_field($data['tec_venue'] ?? ''), 'tec_organiser' => sanitize_text_field($data['tec_organiser'] ?? ''), 'lms_type' => sanitize_text_field($data['lms_type'] ?? ''), 'lms_course_id' => intval($data['lms_course_id'] ?? 0), 'lms_lesson_id' => intval($data['lms_lesson_id'] ?? 0), 'lms_section_id' => intval($data['lms_section_id'] ?? 0), 'ld_post_type' => sanitize_text_field($data['ld_post_type'] ?? ''), ]; // AICOBR_CLUSTER_PILLAR_2026_08 // Resolve the cluster pillar from the STORED diary entry, never from the // request body. The browser has no business asserting which page a post // links to, and the entries write-back doesn't carry these fields // (they're server-owned). Re-resolve the permalink live so a slug change // between planning and generation produces a correct link rather than // relying on a WP redirect from the snapshotted URL. $cluster_pillar = $this->aicobr_resolve_cluster_pillar($payload['entry_id']); if ($cluster_pillar) { $payload['cluster_pillar'] = $cluster_pillar; aicobr_dbg('[ACB] cluster: linking up to pillar #' . $cluster_pillar['post_id'] . ' — ' . $cluster_pillar['url']); } // Inject active writing style profile if one is set $active_id = $settings['active_style_profile_id'] ?? ''; $profiles = $settings['writing_style_profiles'] ?? []; aicobr_dbg('[ACB] active_style_profile_id: ' . $active_id); aicobr_dbg('[ACB] writing_style_profiles count: ' . count($profiles)); aicobr_dbg('[ACB] profiles raw: ' . print_r($profiles, true)); if ( !empty($active_id) && !empty($profiles) ) { foreach ( $profiles as $profile ) { aicobr_dbg('[ACB] checking profile id: ' . ($profile['id'] ?? 'NO_ID')); if ( ($profile['id'] ?? '') === $active_id && !empty($profile['extracted']) ) { $payload['style_profile'] = [ 'name' => $profile['name'] ?? '', 'profile' => $profile['extracted'], ]; aicobr_dbg('[ACB] Style profile injected: ' . ($profile['name'] ?? 'unnamed')); break; } } } $license_server_url = self::get_server_url(); // Server-to-server POST — no CORS, no browser involvement $response = wp_remote_post("{$license_server_url}/api/generate", [ 'timeout' => 30, // wait up to 30s for the server to accept the job 'headers' => [ 'Content-Type' => 'application/json', 'x-generate-secret' => $generate_secret, ], 'body' => wp_json_encode($payload), ]); if (is_wp_error($response)) { aicobr_dbg('[ACB] trigger_generate: wp_remote_post failed: ' . $response->get_error_message()); return new WP_Error( 'server_unreachable', 'Could not reach the generation server. Please try again.', ['status' => 502] ); } $status_code = wp_remote_retrieve_response_code($response); $body = json_decode(wp_remote_retrieve_body($response), true); if ($status_code === 402) { return new WP_Error('no_credits', $body['error'] ?? 'Insufficient credits.', [ 'status' => 402, 'credits_remaining' => $body['credits_remaining'] ?? 0, 'plan_tier' => $settings['plan_tier'] ?? 'free', ]); } if ($status_code === 403) { $code = $body['code'] ?? 'invalid_license'; return new WP_Error($code, $body['error'] ?? 'Access denied.', [ 'status' => 403, 'plan_tier' => $settings['plan_tier'] ?? 'free', ]); } if ($status_code !== 200 || empty($body['success'])) { $msg = $body['error'] ?? 'Generation server returned an unexpected error.'; aicobr_dbg("[ACB] trigger_generate: server error {$status_code}: {$msg}"); return new WP_Error('generate_failed', $msg, ['status' => 500]); } // Job accepted — mark the diary entry as generating with an authoritative // server-side start time. This (not browser localStorage) is the source of // truth the polling UI and the read-time timeout both rely on. $is_regen = !empty($payload['post_id']); $this->aicobr_apply_entry_fields( [ 'entry_id' => $data['entry_id'] ?? null, 'title' => $payload['title'], 'post_id' => $payload['post_id'], ], [ 'status' => $is_regen ? 'regenerating' : 'generating', 'stage' => 'queued', 'started_ts' => time(), 'error_message' => '', ] ); // Job accepted — return success to React so it can update the UI return rest_ensure_response([ 'success' => true, 'message' => 'Generation started.', 'credits_deducted' => $body['credits_deducted'] ?? null, ]); } /** * POST /ai-content/v1/trigger-regenerate-image * * Kicks off an IMAGE-ONLY regeneration for an existing entry. Mirrors * trigger_generate: the browser calls this WP route, and WP forwards to the * licence server's /api/regenerate-image server-to-server, so the shared * secret and licence key never travel through the browser. Resolves the * entry's title + post_id from stored state (the client only sends entry_id * plus the optional "describe the new image" prompt/style), so the licence * server has what it needs to generate and append the image. */ public function trigger_regenerate_image($request) { $data = $request->get_json_params(); $entry_id = isset($data['entry_id']) && $data['entry_id'] !== '' ? (string) $data['entry_id'] : ''; if ($entry_id === '') { return new WP_Error('missing_entry', 'entry_id is required.', ['status' => 400]); } // Credentials read server-side only — never from the request/browser. $settings = get_option('aicobr_settings', []); $license_key = $settings['license_key'] ?? ''; $generate_secret = self::get_secret(); if (empty($license_key)) { return new WP_Error('no_license', 'No license key configured. Please visit Settings.', ['status' => 403]); } // Resolve title + post_id from the stored entry so the licence server can // generate an appropriately-titled image and append it to the right post. $entries = get_option('aicobr_entries', []); $entry = null; foreach ($entries as $e) { if (isset($e['id']) && (string) $e['id'] === $entry_id) { $entry = $e; break; } } if (!$entry) { return new WP_Error('not_found', 'Entry not found.', ['status' => 404]); } $payload = [ 'license_key' => $license_key, 'entry_id' => $entry_id, 'domain' => !empty($settings['domain']) ? $settings['domain'] : wp_parse_url(home_url(), PHP_URL_HOST), 'title' => sanitize_text_field($entry['title'] ?? ''), 'post_id' => !empty($entry['post_id']) ? intval($entry['post_id']) : 0, // The "describe the new image" box (optional). Falls back to the // entry's stored image_style so a silent regen still matches the look. 'image_prompt' => sanitize_text_field($data['image_prompt'] ?? ''), 'image_style' => sanitize_text_field($data['image_style'] ?? ($entry['image_style'] ?? 'professional')), 'serp_gl' => sanitize_text_field($settings['serp_gl'] ?? ''), // Auto-active with easy revert via the picker (see image-regen scope). 'set_active' => true, ]; // AICOBR_INBODY_IMAGES_2026_08 // In-body request. Everything below is additive: with no role the // payload is byte-identical to the previous build and the licence server // performs a featured-image regeneration exactly as before. if (($data['role'] ?? '') === 'body') { $payload['role'] = 'body'; $payload['layout'] = sanitize_text_field($data['layout'] ?? 'band'); $payload['position'] = sanitize_key($data['position'] ?? 'after-heading'); $payload['section_heading'] = sanitize_text_field($data['section_heading'] ?? ''); $payload['alt_text'] = sanitize_text_field($data['alt_text'] ?? ''); $payload['caption'] = sanitize_text_field($data['caption'] ?? ''); $payload['suggestion_id'] = sanitize_text_field($data['suggestion_id'] ?? ''); // Style anchor: the brief's own featured-image prompt, so a set of // in-body images reads as one commission rather than four unrelated // stock photos. Read from the STORED entry, not the request. $payload['style_anchor'] = sanitize_text_field($entry['image_prompt'] ?? ''); $payload['set_active'] = false; } $license_server_url = self::get_server_url(); $response = wp_remote_post("{$license_server_url}/api/regenerate-image", [ 'timeout' => 30, // wait up to 30s for the server to ACCEPT the job (image runs async after) 'headers' => [ 'Content-Type' => 'application/json', 'x-generate-secret' => $generate_secret, ], 'body' => wp_json_encode($payload), ]); if (is_wp_error($response)) { aicobr_dbg('[ACB] trigger_regenerate_image: wp_remote_post failed: ' . $response->get_error_message()); return new WP_Error('server_unreachable', 'Could not reach the image server. Please try again.', ['status' => 502]); } $status_code = wp_remote_retrieve_response_code($response); $body = json_decode(wp_remote_retrieve_body($response), true); // Map the licence server's status codes to the SAME error codes the // client's generate handler already understands (no_credits → upgrade // modal, etc.), plus no_image_plan for a no-image tier. if ($status_code === 402) { return new WP_Error('no_credits', $body['error'] ?? 'Insufficient credits.', [ 'status' => 402, 'credits_remaining' => $body['credits_remaining'] ?? 0, 'plan_tier' => $settings['plan_tier'] ?? 'free', ]); } if ($status_code === 400 && ($body['code'] ?? '') === 'no_image_plan') { return new WP_Error('no_image_plan', $body['error'] ?? "Images aren't included on your plan.", ['status' => 400]); } if ($status_code === 403) { $code = $body['code'] ?? 'invalid_license'; return new WP_Error($code, $body['error'] ?? 'Access denied.', [ 'status' => 403, 'plan_tier' => $settings['plan_tier'] ?? 'free', ]); } if ($status_code !== 200 || empty($body['success'])) { $msg = $body['error'] ?? 'Image server returned an unexpected error.'; aicobr_dbg("[ACB] trigger_regenerate_image: server error {$status_code}: {$msg}"); return new WP_Error('regenerate_failed', $msg, ['status' => 500]); } // Job accepted — the licence server deducted the credit and will append // the new image to image_history when the async pipeline finishes. The // client's polling loop picks up the new image from /entries. return rest_ensure_response([ 'success' => true, 'message' => 'Image generation started.', 'credits_deducted' => $body['credits_deducted'] ?? 1, ]); } public function handle_publish($request) { aicobr_dbg('=== AI-CONTENT PUBLISH v3.5 (Stable Base + Variable Credits) ==='); $data = $request->get_json_params(); if (empty($data)) $data = $request->get_body_params(); $title = sanitize_text_field($data['title'] ?? ''); $content = $data['content'] ?? ''; $status = sanitize_text_field($data['status'] ?? 'draft'); $post_id = absint($data['post_id'] ?? 0); // ── Failure callback from the licence server ─────────────────────────── // generate.js posts { status: 'failed', error } when its pipeline throws. // Mark the matched entry failed authoritatively and stop — no post to create. if ($status === 'failed') { $this->aicobr_apply_entry_fields( ['title' => $title, 'post_id' => $post_id], [ 'status' => 'failed', 'stage' => 'failed', 'error_message' => sanitize_text_field($data['error'] ?? 'Generation failed. Please retry.'), 'started_ts' => null, ] ); return rest_ensure_response(['success' => true, 'failed' => true]); } $ai_model = sanitize_text_field($data['ai_model'] ?? ''); $tokens_used = intval($data['tokens_used'] ?? 0); $image_cost = floatval($data['image_cost'] ?? 0); $execution_time = intval($data['execution_time'] ?? 0); $include_youtube = !empty($data['include_youtube']); $image_style = sanitize_text_field($data['image_style'] ?? 'professional'); $youtube_video = sanitize_text_field($data['youtube_video'] ?? ''); $youtube_video_2 = sanitize_text_field($data['youtube_video_2'] ?? ''); $youtube_video_3 = sanitize_text_field($data['youtube_video_3'] ?? ''); $post_type = sanitize_text_field($data['post_type'] ?? 'post'); $acf_field_key = sanitize_text_field($data['acf_field_key'] ?? ''); // Log what we received to help diagnose issues aicobr_dbg("=== PUBLISH: title={$title}, content_len=" . strlen($content) . ", has_b64=" . (!empty($data['featured_image_b64']) ? 'yes' : 'no') . ", has_bytearray=" . (!empty($data['featured_image']) && is_array($data['featured_image']) ? 'yes' : 'no') . ", status_in={$status} ==="); aicobr_dbg("[ACB] LMS debug: lms_type=" . ($data['lms_type'] ?? 'MISSING') . " | ld_post_type=" . ($data['ld_post_type'] ?? 'MISSING') . " | lms_course_id=" . ($data['lms_course_id'] ?? 'MISSING') . " | has_quiz_data=" . (!empty($data['quiz_data']) ? 'yes(' . count($data['quiz_data']['questions'] ?? []) . ' questions)' : 'no')); // SEO fields from the licence server $seo_title = sanitize_text_field($data['seo_title'] ?? ''); $seo_description = sanitize_text_field($data['seo_description'] ?? ''); $seo_focus_keyword = sanitize_text_field($data['seo_focus_keyword'] ?? ''); $seo_og_title = sanitize_text_field($data['seo_og_title'] ?? $seo_title); $seo_og_description = sanitize_text_field($data['seo_og_description'] ?? $seo_description); // SEO score + checklist from the licence server's deterministic scorer. $seo_score = (isset($data['seo_score']) && $data['seo_score'] !== null) ? intval($data['seo_score']) : null; $seo_report = (isset($data['seo_report']) && is_array($data['seo_report'])) ? $data['seo_report'] : null; // AICOBR_INBODY_IMAGES_2026_08 — in-body image suggestions (descriptions // only; nothing generated, no credit spent). Sanitised and bounded before it // touches the diary option. $image_suggestions = $this->aicobr_sanitise_image_suggestions($data['image_suggestions'] ?? null); // === STRIP SEO BLOCK FROM CONTENT === // Claude appends ---SEO_DATA_START--- ... ---SEO_DATA_END--- to the article. // We parse those values here on the PHP side (so server-side parsing is optional), // then strip the block entirely so it never appears in the saved post content. if (strpos($content, '---SEO_DATA_START---') !== false) { // Extract the SEO block preg_match('/---SEO_DATA_START---(.*?)---SEO_DATA_END---/s', $content, $seo_block_match); if (!empty($seo_block_match[1])) { $seo_block = $seo_block_match[1]; // Only use parsed values if the fields weren't already sent as discrete payload fields if (empty($seo_title)) { preg_match('/SEO_TITLE:\s*(.+)/m', $seo_block, $m); $seo_title = sanitize_text_field(trim($m[1] ?? '')); } if (empty($seo_description)) { preg_match('/SEO_DESCRIPTION:\s*(.+)/m', $seo_block, $m); $seo_description = sanitize_text_field(trim($m[1] ?? '')); } if (empty($seo_focus_keyword)) { preg_match('/SEO_FOCUS_KEYWORD:\s*(.+)/m', $seo_block, $m); $seo_focus_keyword = sanitize_text_field(trim($m[1] ?? '')); } if (empty($seo_og_title)) { preg_match('/SEO_OG_TITLE:\s*(.+)/m', $seo_block, $m); $seo_og_title = sanitize_text_field(trim($m[1] ?? $seo_title)); } if (empty($seo_og_description)) { preg_match('/SEO_OG_DESCRIPTION:\s*(.+)/m', $seo_block, $m); $seo_og_description = sanitize_text_field(trim($m[1] ?? $seo_description)); } } // Strip the entire SEO block (and any surrounding horizontal rules) from content $content = preg_replace('/\n*-{3,}\s*SEO_DATA_START-{3,}.*?-{3,}\s*SEO_DATA_END-{3,}\n*/s', '', $content); $content = trim($content); } // === END STRIP SEO BLOCK === // === STRIP IMAGE-SUGGESTIONS BLOCK === // AICOBR_INBODY_IMAGES_2026_08 // routes/generate.js already strips this server-side, so in the normal case // there is nothing here to remove. This exists for the mismatched-version // case the three-deployment-path rule makes possible: a plugin updated ahead // of the licence server (or a stray emission by the model on a route that // did not ask for suggestions) must never publish a post with a raw JSON // block visible at the bottom. Matches an UNTERMINATED block too, so a // truncated response cannot leak half an array into the page. if (strpos($content, 'IMAGE_SUGGESTIONS_START') !== false) { $content = preg_replace( '/\n*-{3,}\s*IMAGE_SUGGESTIONS_START-{3,}.*?(?:-{3,}\s*IMAGE_SUGGESTIONS_END-{3,}|$)\n*/s', '', $content ); $content = trim((string) $content); aicobr_dbg('[ACB] stripped a stray IMAGE_SUGGESTIONS block from content'); } // === END STRIP IMAGE-SUGGESTIONS BLOCK === // If LMS integration is active use the correct post type upfront $early_ld_post_type = sanitize_text_field($data['ld_post_type'] ?? ''); // ACB_LMS_TYPE_DERIVE_2026_07_22: belt-and-braces — an L&D content type must // NEVER land as a plain blog post when an LMS is installed. If ld_post_type // arrives empty (lost anywhere along the round trip), derive it here from // content_type + whichever LMS is active, instead of falling through to 'post'. if ($early_ld_post_type === '') { $aicobr_lms_ct_map = [ 'course_overview' => 'course', 'training_module' => 'lesson', 'case_study_ld' => 'lesson', 'explainer_guide' => 'lesson', 'quiz_assessment' => 'llms_quiz', 'workshop_guide' => 'lesson', 'sop' => 'lesson', ]; $aicobr_ct_early = sanitize_text_field($data['content_type'] ?? ''); if (isset($aicobr_lms_ct_map[$aicobr_ct_early])) { $aicobr_llms_on = class_exists('LLMS') || class_exists('LifterLMS') || function_exists('llms') || defined('LLMS_PLUGIN_FILE'); $aicobr_lp_on = class_exists('LearnPress') || defined('LP_PLUGIN_FILE') || post_type_exists('lp_course'); $aicobr_lms_engine = sanitize_text_field($data['lms_type'] ?? ''); if ($aicobr_lms_engine === '') { $aicobr_lms_engine = $aicobr_llms_on ? 'lifterlms' : ($aicobr_lp_on ? 'learnpress' : ''); } if ($aicobr_lms_engine === 'lifterlms') { $early_ld_post_type = $aicobr_lms_ct_map[$aicobr_ct_early]; $data['lms_type'] = 'lifterlms'; } elseif ($aicobr_lms_engine === 'learnpress') { $aicobr_lp_map = ['course' => 'lp_course', 'lesson' => 'lp_lesson', 'llms_quiz' => 'lp_quiz']; $early_ld_post_type = $aicobr_lp_map[$aicobr_lms_ct_map[$aicobr_ct_early]]; $data['lms_type'] = 'learnpress'; } if ($early_ld_post_type !== '') { $data['ld_post_type'] = $early_ld_post_type; aicobr_dbg('[ACB] LMS derive: ld_post_type was empty for ' . $aicobr_ct_early . ' — derived ' . $early_ld_post_type); } } } // Events Calendar: force tribe_events post type for event_description $tec_content_type = sanitize_text_field($data['content_type'] ?? ''); $tec_meta_input = []; if ($tec_content_type === 'event_description' && post_type_exists('tribe_events')) { $post_type = 'tribe_events'; // Compute the event's date meta NOW, before the post is created, so it can // be passed into wp_insert_post() via meta_input. TEC 6's custom-tables // layer hooks save_post and validates the event the moment the post is // created — if the date meta isn't already present it throws // "start_date_utc requires a value" and never builds the occurrence row. // Writing the dates at insert time makes that first sync succeed. $e_start = sanitize_text_field($data['tec_start_date'] ?? ''); $e_end = sanitize_text_field($data['tec_end_date'] ?? ''); if ($e_start !== '') { $e_fmt_start = str_replace('T', ' ', $e_start) . ':00'; $e_fmt_end = $e_end !== '' ? str_replace('T', ' ', $e_end) . ':00' : $e_fmt_start; $e_tz_string = function_exists('wp_timezone_string') ? wp_timezone_string() : (get_option('timezone_string') ?: 'UTC'); $e_duration = 0; $e_start_utc = ''; $e_end_utc = ''; try { $e_tz = new DateTimeZone($e_tz_string ?: 'UTC'); $e_utc = new DateTimeZone('UTC'); $e_so = new DateTime($e_fmt_start, $e_tz); $e_eo = new DateTime($e_fmt_end, $e_tz); $e_duration = max(0, $e_eo->getTimestamp() - $e_so->getTimestamp()); $e_start_utc = (clone $e_so)->setTimezone($e_utc)->format('Y-m-d H:i:s'); $e_end_utc = (clone $e_eo)->setTimezone($e_utc)->format('Y-m-d H:i:s'); } catch (Exception $e) { aicobr_dbg('[ACB] TEC early date parse failed: ' . $e->getMessage()); } $tec_meta_input = [ '_EventStartDate' => $e_fmt_start, '_EventEndDate' => $e_fmt_end, '_EventStartDateUTC' => $e_start_utc, '_EventEndDateUTC' => $e_end_utc, '_EventDuration' => $e_duration, '_EventTimezone' => $e_tz_string ?: 'UTC', ]; } } $early_lms_course_id = intval($data['lms_course_id'] ?? 0); $early_lms_type = sanitize_text_field($data['lms_type'] ?? ''); // Special case: Course Overview + existing course selected // → update that course's content rather than creating a new lp_course post $is_course_overview_update = ( $early_ld_post_type === 'lp_course' && $early_lms_course_id > 0 && $early_lms_type === 'learnpress' ); if ($is_course_overview_update) { // Override post_id so publish handler updates the existing course $data['post_id'] = $early_lms_course_id; aicobr_dbg('[ACB] Course Overview: updating existing course ' . $early_lms_course_id); } elseif (!empty($early_ld_post_type)) { // Trust known LMS post types even if post_type_exists() returns false — // LLMS and LearnPress register their types on 'init' but WP may not have // fired that hook yet in the REST context, so the check gives a false // negative. The known list covers every LMS type ACB can target. $known_lms_types = ['llms_quiz', 'llms_question', 'lesson', 'course', 'lp_quiz', 'lp_lesson', 'lp_course']; if (in_array($early_ld_post_type, $known_lms_types, true) || post_type_exists($early_ld_post_type)) { $post_type = $early_ld_post_type; } } // Validate post_type — allow known LMS types through even if not yet registered $known_lms_types_fallback = ['llms_quiz', 'lesson', 'course', 'lp_quiz', 'lp_lesson', 'lp_course']; if (!post_type_exists($post_type) && !in_array($post_type, $known_lms_types_fallback, true)) { $post_type = 'post'; } // Process content aicobr_dbg('=== CONTENT: length before processing=' . strlen($content) . ' ==='); $aicobr_ct = sanitize_text_field($data['content_type'] ?? 'blog_post'); $content = $this->processGeneratedContent($content, $include_youtube, $aicobr_ct, $post_type); aicobr_dbg('=== CONTENT: length after processing=' . strlen($content) . ' ==='); // === HERO IMAGE === // AICOBR_HERO_IMAGE_2026_08 // Whether a featured image appears above the article is a THEME decision, // and plenty of themes never render the thumbnail — so a post that paid a // credit for an image could publish looking like it had none. Prepending the // shortcode puts it in the content, where it always shows. // // A shortcode rather than an for two reasons: the user can delete one // line if their theme already displays the thumbnail (otherwise they get it // twice), and it reads the CURRENT thumbnail at render time, so regenerating // the featured image updates the hero with nothing to reconcile. // // Prepended OUTSIDE the .acb-content wrapper, which is correct — the hero is // not part of the article body, and .acb-fig is styled unscoped for exactly // this case (the same reason [content_cluster] is not scoped). // // Gated on a setting so a user whose theme already shows the thumbnail can // turn it off once instead of deleting a line on every post. Only prepended // when an image is actually expected: the shortcode renders nothing without // a thumbnail, but adding a dead line to every image-less post is noise. $aicobr_settings_pub = get_option('aicobr_settings', []); $aicobr_hero_on = !array_key_exists('auto_hero_image', $aicobr_settings_pub) || !empty($aicobr_settings_pub['auto_hero_image']); $aicobr_expect_image = !empty($data['featured_image_attachment_id']) || !empty($data['featured_image_b64']) || ($post_id && has_post_thumbnail($post_id)); if ($aicobr_hero_on && $aicobr_expect_image && !has_shortcode($content, 'content_featured_image') && !has_shortcode($content, 'aicobr_featured_image')) { $content = '[content_featured_image ratio="16:9"]' . "\n\n" . $content; aicobr_dbg('=== HERO: featured-image shortcode prepended ==='); } // === END HERO IMAGE === // AICOBR_WP_SLASH_2026_08 — wp_insert_post/wp_update_post unslash the array // before the DB write, so the free-text fields must be slashed here or they // lose a backslash level. $content is the generated body (from REST // get_json_params, which is unslashed) and $title is the generated title. // Only the free-text fields need it; post_status/post_type/dates carry no // backslashes. meta_input, if set below, is only TEC date/timezone strings — // no backslashes there either, so it is left as-is. $post_data = [ 'post_title' => wp_slash($title), 'post_content' => wp_slash($content), 'post_status' => $status, 'post_type' => $post_type, ]; // === SCHEDULED PUBLICATION === // AICOBR_SCHEDULE_PUBLISH_2026_08 // A brief carries a publish_date and the diary calls it "scheduled", but the // post itself was always created as a plain draft dated now — so the date // the user planned around existed only inside ACB. Opening the post in // WordPress showed "Draft" with no date anywhere, and the only way to find // out when it was meant to go live was to navigate back to the diary. // // The post is now genuinely scheduled: post_status 'future' plus the date, // so WordPress itself shows "Scheduled for ..." and publishes it on the day. // // Guards, in order: // - only when the licence server asked for a draft. A 'failed' callback // returns long before this, and an explicit 'publish' is honoured. // - only when the entry's date resolves to a FUTURE local datetime. // - on an UPDATE, only when the existing post is still a draft-ish or // already-scheduled post. If the user has published it (or trashed it), // regenerating must never drag it back out of the index. $aicobr_schedule_at = null; if ($status === 'draft') { $aicobr_schedule_at = $this->aicobr_resolve_schedule_datetime($data['entry_id'] ?? null); } if ($aicobr_schedule_at !== null) { $aicobr_existing_status = ($post_id && get_post($post_id)) ? get_post_status($post_id) : ''; $aicobr_may_schedule = ($aicobr_existing_status === '') || in_array($aicobr_existing_status, ['draft', 'auto-draft', 'pending', 'future'], true); if ($aicobr_may_schedule) { $post_data['post_status'] = 'future'; $post_data['post_date'] = $aicobr_schedule_at; $post_data['post_date_gmt'] = get_gmt_from_date($aicobr_schedule_at); // wp_update_post ignores post_date on an existing post unless this // is set — without it a regeneration would silently keep the old // date while flipping the status to 'future'. $post_data['edit_date'] = true; aicobr_dbg("=== SCHEDULE: post set to publish at {$aicobr_schedule_at} (site time) ==="); } else { $aicobr_schedule_at = null; aicobr_dbg("=== SCHEDULE: skipped — existing post status is '{$aicobr_existing_status}' ==="); } } // === END SCHEDULED PUBLICATION === // For TEC events, seed the date meta at creation so TEC's custom-tables sync // (which fires on save_post during wp_insert_post) validates and builds the // occurrence row on the first save instead of erroring on a missing date. if (!empty($tec_meta_input)) { $post_data['meta_input'] = $tec_meta_input; } if ($post_id && get_post($post_id)) { // Only update posts AI Content Bridge created — prevents overwriting // arbitrary posts (e.g. the home page) even if the shared secret leaks. if (!self::aicobr_is_generated_post($post_id)) { // Legacy posts created before the _aicobr_generated meta existed won't // carry it. Fall back to the content diary: if this post_id is // recorded on an ACB entry, the post is genuinely ours — backfill the // meta and proceed (this also lets the styled front-end CSS load on it). if ($this->aicobr_post_in_diary($post_id)) { update_post_meta($post_id, '_aicobr_generated', 1); } else { return new WP_Error('forbidden', 'Target post was not created by AI Content Bridge.', ['status' => 403]); } } $post_data['ID'] = $post_id; $updated_id = wp_update_post($post_data, true); } else { $updated_id = wp_insert_post($post_data, true); if (!is_wp_error($updated_id)) { update_post_meta($updated_id, '_aicobr_generated', 1); } } if (is_wp_error($updated_id)) { aicobr_dbg('Post save failed: ' . $updated_id->get_error_message()); return new WP_Error('save_failed', $updated_id->get_error_message(), ['status' => 500]); } // === SEO META === $seo_data = [ 'title' => $seo_title, 'description' => $seo_description, 'focus_keyword' => $seo_focus_keyword, 'og_title' => $seo_og_title, 'og_description' => $seo_og_description, ]; $seo_plugin = $this->apply_seo_meta($updated_id, $seo_data); aicobr_dbg("=== SEO META: plugin=$seo_plugin ==="); // === END SEO META === // === SEO SCORE === // Store the checklist as JSON post meta so the Content Diary can render the // score ring + fix list, and so the score is queryable per post. if ($seo_report !== null) { update_post_meta($updated_id, '_acb_seo_report', wp_json_encode($seo_report)); update_post_meta($updated_id, '_acb_seo_score', $seo_score); aicobr_dbg("=== SEO SCORE: {$seo_score}/100 ==="); } // === END SEO SCORE === // === CONTENT FINGERPRINT === // AICOBR_INBODY_INSERT_2026_08 // Record a hash of exactly what ACB wrote. In-body image insertion compares // against this before editing the body: a match means the post is still // ACB's prose and real markup can be inserted; a mismatch means the user has // been in there, so ACB offers a shortcode instead of rearranging their // edits. Written here because $content is the post-processed HTML actually // saved — the same value the comparison will read back. update_post_meta($updated_id, '_aicobr_content_hash', self::aicobr_content_hash($content)); // === END CONTENT FINGERPRINT === // === CLUSTER INTERNAL LINK === // AICOBR_CLUSTER_PILLAR_2026_08 // Record whether the generated article really did link up to its pillar. We // COUNT the link in the finished content rather than assuming the model // complied — the Monthly Report figure has to be a fact, not an intention. // Stored as one meta key, which also serves as the idempotency marker if a // re-generation path is ever added. $cluster_pillar_pub = $this->aicobr_resolve_cluster_pillar($data['entry_id'] ?? null); if ($cluster_pillar_pub) { $pillar_url = $cluster_pillar_pub['url']; // Match the href regardless of trailing-slash or entity-encoded output. $needle = rtrim($pillar_url, '/'); $haystack = html_entity_decode((string) $content, ENT_QUOTES, 'UTF-8'); $link_count = substr_count($haystack, 'href="' . $needle) + substr_count($haystack, "href='" . $needle); update_post_meta($updated_id, '_aicobr_cluster_links', wp_json_encode([ 'cluster_id' => sanitize_text_field($cluster_pillar_pub['cluster_id'] ?? ''), 'pillar_id' => (int) $cluster_pillar_pub['post_id'], 'pillar_url' => esc_url_raw($pillar_url), 'count' => (int) $link_count, 'linked_at' => current_time('mysql'), ])); aicobr_dbg("=== CLUSTER LINK: {$link_count} link(s) to pillar #{$cluster_pillar_pub['post_id']} ==="); // AICOBR_CLUSTER_SHORTCODE_2026_08 — this spoke has just gone live, so // the pillar's [content_cluster] block is now stale. Clear it immediately // rather than waiting out the transient: the whole point of the block is // that new spokes appear in it as they publish. $this->aicobr_bust_cluster_cache($cluster_pillar_pub['post_id']); } // === END CLUSTER INTERNAL LINK === // AICOBR_LINK_FORECAST_2026_08 // A new published post changes the SOURCE POOL, which is what every cached // scan and every cached forecast was computed against. Until now the salt // only moved when ACB wrote INTO a source (insert and undo), because that // was the only thing that could stale a scan. A forecast depends on what is // published, so publishing has to move it too. // // Without this, the compounding lift is exactly what breaks: publish a spoke // and its siblings keep reporting the pre-publication number for fifteen // minutes, so the one moment the feature exists to show appears not to work. // Same shape as the two bugs that shipped the name guard inert: the cache // encodes a world state, and something changed the world without telling it. $this->aicobr_bump_inbound_gen(); // === ACF FIELD === // If the diary entry had an ACF field selected, write the generated content into it. // We also look it up from the saved entry in case it wasn't sent in the payload. if (empty($acf_field_key)) { $all_entries_tmp = get_option('aicobr_entries', []); foreach ($all_entries_tmp as $e) { $title_match = strtolower(trim($e['title'] ?? '')) === strtolower(trim($title)); $id_match = ($post_id && isset($e['post_id']) && intval($e['post_id']) === $post_id); if (($title_match || $id_match) && !empty($e['acf_field_key'])) { $acf_field_key = sanitize_text_field($e['acf_field_key']); break; } } } if (!empty($acf_field_key) && function_exists('update_field')) { update_field($acf_field_key, $content, $updated_id); aicobr_dbg("=== ACF FIELD WRITTEN: key=$acf_field_key, post=$updated_id ==="); } // === END ACF FIELD === // === LMS INTEGRATION === // Extract LMS fields once — used both to set correct post_type upfront // (handled above before wp_insert_post) and to set associations after. $quiz_data = $data['quiz_data'] ?? null; $lms_type = sanitize_text_field($data['lms_type'] ?? ''); $lms_course = intval($data['lms_course_id'] ?? 0); $lms_lesson = intval($data['lms_lesson_id'] ?? 0); $lms_section = intval($data['lms_section_id'] ?? 0); $ld_post_type = sanitize_text_field($data['ld_post_type'] ?? ''); // ── LifterLMS ────────────────────────────────────────────────────────── $lifterlms_active = class_exists('LLMS') || class_exists('LifterLMS') || defined('LLMS_PLUGIN_FILE'); if ($lms_type === 'lifterlms' && $lifterlms_active && $updated_id && !is_wp_error($updated_id)) { if ($ld_post_type === 'lesson' && $lms_course) { wp_update_post(['ID' => $updated_id, 'post_type' => 'lesson']); update_post_meta($updated_id, '_llms_parent_course', $lms_course); } elseif ($ld_post_type === 'course' && $lms_course) { // Update the selected course directly — do not create a duplicate // AICOBR_WP_SLASH_2026_08 — wp_update_post unslashes before writing; // $content is the generated body, and the excerpt is derived from it, // so both free-text fields are slashed to preserve backslashes. wp_update_post([ 'ID' => $lms_course, 'post_content' => wp_slash($content), 'post_excerpt' => wp_slash(wp_trim_words(wp_strip_all_tags($content), 40, '…')), ]); if ($updated_id && $updated_id !== $lms_course) { wp_delete_post($updated_id, true); } $updated_id = $lms_course; } elseif ($ld_post_type === 'llms_quiz' && $lms_lesson) { // LLMS v6+: scalar _llms_quiz meta on lesson + quiz_enabled flag // post_parent must be the lesson for the builder to find the quiz wp_update_post([ 'ID' => $updated_id, 'post_type' => 'llms_quiz', 'post_status' => 'publish', 'post_parent' => $lms_lesson, ]); update_post_meta($updated_id, '_llms_parent_course', $lms_course); update_post_meta($updated_id, '_llms_lesson_id', $lms_lesson); // quiz→lesson back-ref (required by the builder; without it the quiz is an orphan and questions fail to load) update_post_meta($lms_lesson, '_llms_quiz', $updated_id); update_post_meta($lms_lesson, '_llms_quiz_enabled', 'yes'); } elseif ($lms_course) { wp_update_post([ 'ID' => $updated_id, 'post_parent' => $lms_course, 'post_type' => $ld_post_type ?: 'lesson', ]); update_post_meta($updated_id, '_llms_parent_course', $lms_course); } aicobr_dbg("[ACB] LifterLMS: post $updated_id ($ld_post_type) → course $lms_course" . ($lms_section ? ", section $lms_section" : "") . ($lms_lesson ? ", lesson $lms_lesson" : "")); } // ── LearnPress ──────────────────────────────────────────────────────── $lp_active = class_exists('LearnPress') || defined('LP_PLUGIN_FILE') || post_type_exists('lp_course'); if ($lms_type === 'learnpress' && $lp_active && $updated_id && !is_wp_error($updated_id)) { global $wpdb; $tbl_sections = $wpdb->prefix . 'learnpress_sections'; $tbl_section_items = $wpdb->prefix . 'learnpress_section_items'; if ($ld_post_type === 'lp_lesson' || $ld_post_type === 'lp_quiz') { // Find or create a section for this course $section_id = (int) $wpdb->get_var($wpdb->prepare( "SELECT section_id FROM $tbl_sections WHERE section_course_id = %d ORDER BY section_order ASC LIMIT 1", $lms_course )); if (!$section_id && $lms_course) { $wpdb->insert($tbl_sections, [ 'section_name' => 'Generated Content', 'section_course_id' => $lms_course, 'section_order' => 1, 'section_description' => '', ], ['%s', '%d', '%d', '%s']); $section_id = (int) $wpdb->insert_id; } if ($section_id) { // Check not already added $exists = $wpdb->get_var($wpdb->prepare( "SELECT section_item_id FROM $tbl_section_items WHERE section_id = %d AND item_id = %d", $section_id, $updated_id )); if (!$exists) { $next_order = (int) $wpdb->get_var($wpdb->prepare( "SELECT COALESCE(MAX(item_order), 0) + 1 FROM $tbl_section_items WHERE section_id = %d", $section_id )); $wpdb->insert($tbl_section_items, [ 'section_id' => $section_id, 'item_id' => $updated_id, 'item_order' => $next_order, 'item_type' => $ld_post_type, ], ['%d', '%d', '%d', '%s']); } } // Clear LP4 caches wp_cache_delete($lms_course, 'lp_course'); wp_cache_delete($updated_id, $ld_post_type); if (function_exists('learn_press_reset_auto_draft')) learn_press_reset_auto_draft($lms_course); delete_post_meta($lms_course, '_lp_course_count_items'); } elseif ($ld_post_type === 'lp_course') { update_post_meta($updated_id, '_lp_associated_course', $lms_course); } aicobr_dbg("[ACB] LearnPress: post $updated_id ($ld_post_type) → course $lms_course, section $section_id"); } // === QUIZ POPULATION === $is_quiz_type = in_array($ld_post_type, ['lp_quiz', 'llms_quiz']); $is_lesson_type = in_array($ld_post_type, ['lesson', 'lp_lesson']); $content_type = sanitize_text_field($data['content_type'] ?? ''); $is_training_mod = ($content_type === 'training_module'); if (!empty($quiz_data) && $updated_id && !is_wp_error($updated_id)) { if ($is_quiz_type) { // Explicit quiz content type — populate the quiz post directly aicobr_dbg('[ACB] Quiz: ' . count($quiz_data['questions'] ?? []) . ' questions for ' . $updated_id); if ($ld_post_type === 'lp_quiz') { $this->populate_learnpress_quiz($updated_id, $quiz_data, $lms_course); } elseif ($ld_post_type === 'llms_quiz') { $this->populate_lifterlms_quiz($updated_id, $quiz_data, $lms_course); } } elseif ($is_lesson_type && $is_training_mod && $lms_course) { // Training Module with knowledge check data — auto-create an attached quiz. // The lesson ($updated_id) is already saved. Now create a sibling quiz post // and link it to the lesson so students get an auto-graded knowledge check. aicobr_dbg('[ACB] Training Module: auto-creating knowledge check quiz for lesson ' . $updated_id); $quiz_title = get_the_title($updated_id) . ' — Knowledge Check'; $quiz_intro = sanitize_text_field($quiz_data['quiz_intro'] ?? ''); if ($lms_type === 'lifterlms') { // Create the llms_quiz post parented to this lesson $quiz_id = wp_insert_post([ 'post_title' => $quiz_title, 'post_type' => 'llms_quiz', 'post_status' => 'publish', 'post_parent' => $updated_id, // lesson is the parent 'post_excerpt' => $quiz_intro, ]); if (!is_wp_error($quiz_id)) { update_post_meta($quiz_id, '_llms_parent_course', $lms_course); update_post_meta($quiz_id, '_llms_lesson_id', $updated_id); // quiz→lesson back-ref (required by the builder) update_post_meta($updated_id, '_llms_quiz', $quiz_id); update_post_meta($updated_id, '_llms_quiz_enabled', 'yes'); $this->populate_lifterlms_quiz($quiz_id, $quiz_data, $lms_course); aicobr_dbg('[ACB] LLMS knowledge check quiz ' . $quiz_id . ' created and linked to lesson ' . $updated_id); } else { aicobr_dbg('[ACB] LLMS knowledge check quiz creation failed: ' . $quiz_id->get_error_message()); } } elseif ($lms_type === 'learnpress') { // Create the lp_quiz post and slot it into the same course section $quiz_id = wp_insert_post([ 'post_title' => $quiz_title, 'post_type' => 'lp_quiz', 'post_status' => 'publish', 'post_excerpt' => $quiz_intro, ]); if (!is_wp_error($quiz_id)) { // Find the section the lesson is already in and add the quiz after it global $wpdb; $tbl_sections = $wpdb->prefix . 'learnpress_sections'; $tbl_section_items = $wpdb->prefix . 'learnpress_section_items'; $section_id = (int) $wpdb->get_var($wpdb->prepare( "SELECT section_id FROM $tbl_section_items WHERE item_id = %d LIMIT 1", $updated_id )); if (!$section_id) { // Fallback: first section of the course $section_id = (int) $wpdb->get_var($wpdb->prepare( "SELECT section_id FROM $tbl_sections WHERE section_course_id = %d ORDER BY section_order ASC LIMIT 1", $lms_course )); } if ($section_id) { $next_order = (int) $wpdb->get_var($wpdb->prepare( "SELECT COALESCE(MAX(item_order), 0) + 1 FROM $tbl_section_items WHERE section_id = %d", $section_id )); $wpdb->insert($tbl_section_items, [ 'section_id' => $section_id, 'item_id' => $quiz_id, 'item_order' => $next_order, 'item_type' => 'lp_quiz', ], ['%d', '%d', '%d', '%s']); } $this->populate_learnpress_quiz($quiz_id, $quiz_data, $lms_course); wp_cache_delete($lms_course, 'lp_course'); aicobr_dbg('[ACB] LP knowledge check quiz ' . $quiz_id . ' created and linked to lesson ' . $updated_id); } else { aicobr_dbg('[ACB] LP knowledge check quiz creation failed: ' . $quiz_id->get_error_message()); } } } } // === MAILPOET INTEGRATION === $mailpoet_list_id = intval($data['mailpoet_list_id'] ?? 0); $mp_ct = $data['content_type'] ?? ''; if ($mailpoet_list_id && $mp_ct === 'email_newsletter' && class_exists('\MailPoet\API\API')) { global $wpdb; $mp_prefix = esc_sql($wpdb->prefix . 'mailpoet_'); // table-name identifier; esc_sql satisfies the DB linter $mp_subject = $title; $mp_preview = ''; $mp_body = $content; if (preg_match('/^SUBJECT LINE:\s*(.+)$/m', $content, $sm)) { $mp_subject = sanitize_text_field(trim($sm[1])); } if (preg_match('/^PREVIEW TEXT:\s*(.+)$/m', $content, $pm)) { $mp_preview = sanitize_text_field(trim($pm[1])); } if (preg_match('/---\s*EMAIL BODY\s*---\s*(.+)/s', $content, $bm)) { $mp_body = trim($bm[1]); } $mp_html = wpautop($mp_body); $mp_newsletter_body = wp_json_encode([ 'content' => [ 'type' => 'container', 'columnLayout' => false, 'orientation' => 'vertical', 'image' => ['src' => null, 'display' => 'scale'], 'styles' => ['block' => ['backgroundColor' => 'transparent']], 'blocks' => [['type' => 'container', 'columnLayout' => false, 'orientation' => 'horizontal', 'image' => ['src' => null, 'display' => 'scale'], 'styles' => ['block' => ['backgroundColor' => 'transparent']], 'blocks' => [['type' => 'container', 'columnLayout' => false, 'orientation' => 'vertical', 'image' => ['src' => null, 'display' => 'scale'], 'styles' => ['block' => ['backgroundColor' => 'transparent']], 'blocks' => [['type' => 'text', 'text' => $mp_html]], ]]]], ], 'globalStyles' => [ 'links' => ['fontColor' => '#0073aa', 'fontUnderline' => 'true'], 'text' => ['fontColor' => '#000000', 'fontFamily' => 'Arial', 'fontSize' => '15px', 'lineHeight' => '1.6'], 'backgroundColor' => '#ffffff', ], ]); // Reuse the draft created for this entry on a previous run (regeneration) // rather than piling up duplicates. Tracked in a WP option keyed by entry_id, // because email newsletters do NOT keep a WP post (it's deleted below), so we // can't rely on post meta. entry_id round-trips on regenerate. $mp_entry_key = (isset($data['entry_id']) && $data['entry_id'] !== '') ? (string) $data['entry_id'] : ''; $mp_map = get_option('aicobr_mailpoet_drafts', []); if (!is_array($mp_map)) { $mp_map = []; } $existing_mp_id = ($mp_entry_key !== '' && !empty($mp_map[$mp_entry_key])) ? intval($mp_map[$mp_entry_key]) : 0; // Backwards-compat: drafts created before this change tracked the id in post meta if (!$existing_mp_id && $updated_id) { $existing_mp_id = intval(get_post_meta($updated_id, '_acb_mailpoet_id', true)); } $mp_exists = $existing_mp_id && $wpdb->get_var($wpdb->prepare( "SELECT id FROM {$mp_prefix}newsletters WHERE id = %d AND deleted_at IS NULL", $existing_mp_id )); if ($mp_exists) { $wpdb->update( $mp_prefix . 'newsletters', [ 'subject' => $mp_subject, 'preheader' => $mp_preview, 'body' => $mp_newsletter_body, 'status' => 'draft', 'updated_at' => current_time('mysql'), ], ['id' => $existing_mp_id], ['%s','%s','%s','%s','%s'], ['%d'] ); // Re-point the list link to the currently selected segment. $wpdb->delete($mp_prefix . 'newsletter_segment', ['newsletter_id' => $existing_mp_id], ['%d']); $wpdb->insert($mp_prefix . 'newsletter_segment', ['newsletter_id' => $existing_mp_id, 'segment_id' => $mailpoet_list_id], ['%d','%d'] ); $newsletter_id = $existing_mp_id; aicobr_dbg('[ACB] MailPoet draft updated in place: id=' . $existing_mp_id . ' subject=' . $mp_subject); } else { $inserted = $wpdb->insert( $mp_prefix . 'newsletters', [ 'type' => 'standard', 'subject' => $mp_subject, 'preheader' => $mp_preview, 'body' => $mp_newsletter_body, 'status' => 'draft', 'sender_name' => get_bloginfo('name'), 'sender_address' => get_option('admin_email'), 'reply_to_name' => get_bloginfo('name'), 'reply_to_address' => get_option('admin_email'), 'created_at' => current_time('mysql'), 'updated_at' => current_time('mysql'), 'deleted_at' => null, ], ['%s','%s','%s','%s','%s','%s','%s','%s','%s','%s','%s','%s'] ); if ($inserted) { $newsletter_id = $wpdb->insert_id; $wpdb->insert($mp_prefix . 'newsletter_segment', ['newsletter_id' => $newsletter_id, 'segment_id' => $mailpoet_list_id], ['%d','%d'] ); aicobr_dbg('[ACB] MailPoet draft created: id=' . $newsletter_id . ' subject=' . $mp_subject); } else { aicobr_dbg('[ACB] MailPoet insert failed: ' . $wpdb->last_error); } } // Persist the entry→draft mapping so a later regeneration updates this same // draft instead of creating a duplicate, then delete the placeholder WP post — // an email newsletter lives in MailPoet, not as a WordPress post. if (!empty($newsletter_id)) { $aicobr_mp_newsletter_id = (int) $newsletter_id; // surfaced onto the diary entry below if ($mp_entry_key !== '') { $mp_map[$mp_entry_key] = (int) $newsletter_id; update_option('aicobr_mailpoet_drafts', $mp_map, false); } if ($updated_id && !is_wp_error($updated_id)) { wp_delete_post($updated_id, true); aicobr_dbg('[ACB] MailPoet: deleted placeholder WP post ' . $updated_id . ' (newsletter lives in MailPoet)'); $updated_id = 0; // entry will store post_id=0; downstream blocks skip } } } // === END MAILPOET INTEGRATION === // === NEWSLETTER PLUGIN INTEGRATION === $tnp_list_id = intval($data['tnp_list_id'] ?? 0); if ($tnp_list_id && $content_type === 'email_newsletter' && (class_exists('Newsletter') || class_exists('TNP') || defined('NEWSLETTER_VERSION'))) { global $wpdb; $tnp_subject = $title; $tnp_preview = ''; $tnp_body = $content; if (preg_match('/^SUBJECT LINE:\s*(.+)$/m', $content, $sm)) { $tnp_subject = sanitize_text_field(trim($sm[1])); } if (preg_match('/^PREVIEW TEXT:\s*(.+)$/m', $content, $pm)) { $tnp_preview = sanitize_text_field(trim($pm[1])); } if (preg_match('/---\s*EMAIL BODY\s*---\s*(.+)/s', $content, $bm)) { $tnp_body = trim($bm[1]); } $tnp_html = wpautop($tnp_body); // Build list targets: comma-separated list IDs as integers $tnp_lists = serialize([$tnp_list_id => 1]); $tnp_table = $wpdb->prefix . 'newsletter_emails'; // Reuse the draft created for this entry on a previous run (regeneration) // rather than piling up duplicates. Tracked in a WP option keyed by entry_id, // because the newsletter does NOT keep a WP post (it's deleted below), so we // can't rely on post meta. entry_id round-trips on regenerate. $tnp_entry_key = (isset($data['entry_id']) && $data['entry_id'] !== '') ? (string) $data['entry_id'] : ''; $tnp_map = get_option('aicobr_tnp_drafts', []); if (!is_array($tnp_map)) { $tnp_map = []; } $existing_tnp_id = ($tnp_entry_key !== '' && !empty($tnp_map[$tnp_entry_key])) ? intval($tnp_map[$tnp_entry_key]) : 0; // Backwards-compat: drafts created before this change tracked the id in post meta if (!$existing_tnp_id && $updated_id) { $existing_tnp_id = intval(get_post_meta($updated_id, '_acb_tnp_id', true)); } $tnp_exists = $existing_tnp_id && $wpdb->get_var($wpdb->prepare( "SELECT id FROM {$tnp_table} WHERE id = %d", $existing_tnp_id )); $tnp_newsletter_id = 0; // resolved id (existing or freshly inserted); stays 0 on failure // The newsletter_emails schema varies across TNP versions: e.g. 9.x has no // 'preheader' column (the preheader is a composer block, not a table field), // which made a hard-coded INSERT fail with "Unknown column 'preheader'". // Read the real columns once and only write keys that actually exist, so the // write survives schema differences instead of erroring and leaving a stray // WP post behind. $tnp_cols = []; foreach ((array) $wpdb->get_col("DESC `{$tnp_table}`", 0) as $c) { $tnp_cols[$c] = true; } $tnp_fmt = [ 'subject' => '%s', 'message' => '%s', 'message_text' => '%s', 'preheader' => '%s', 'subject_line' => '%s', 'status' => '%s', 'type' => '%s', 'lists' => '%s', 'created' => '%s', 'updated' => '%s', 'send_on' => '%d', 'sent' => '%d', 'total' => '%d', 'error' => '%d', 'query' => '%s', 'editor' => '%d', 'track' => '%d', 'options' => '%s', ]; // Build a candidate row, then drop any key the table doesn't have. $filter_row = function (array $row) use ($tnp_cols, $tnp_fmt) { $data = []; $fmt = []; foreach ($row as $k => $v) { if (!empty($tnp_cols[$k])) { $data[$k] = $v; $fmt[] = $tnp_fmt[$k] ?? '%s'; } } return [$data, $fmt]; }; if ($tnp_exists) { list($u_data, $u_fmt) = $filter_row([ 'subject' => $tnp_subject, 'message' => $tnp_html, 'message_text' => wp_strip_all_tags($tnp_body), 'preheader' => $tnp_preview, 'lists' => serialize([$tnp_list_id]), 'updated' => current_time('mysql'), ]); $wpdb->update($tnp_table, $u_data, ['id' => $existing_tnp_id], $u_fmt, ['%d']); $tnp_newsletter_id = $existing_tnp_id; aicobr_dbg('[ACB] Newsletter Plugin draft updated in place: id=' . $existing_tnp_id . ' subject=' . $tnp_subject); } else { list($i_data, $i_fmt) = $filter_row([ 'subject' => $tnp_subject, 'message' => $tnp_html, 'message_text' => wp_strip_all_tags($tnp_body), 'preheader' => $tnp_preview, 'status' => 'new', 'type' => 'message', 'lists' => serialize([$tnp_list_id]), 'created' => current_time('mysql'), 'send_on' => 0, 'sent' => 0, 'total' => 0, 'error' => 0, 'query' => '', ]); $inserted = $wpdb->insert($tnp_table, $i_data, $i_fmt); if ($inserted) { $tnp_newsletter_id = (int) $wpdb->insert_id; aicobr_dbg('[ACB] Newsletter Plugin draft created: id=' . $tnp_newsletter_id . ' subject=' . $tnp_subject . ' cols=' . implode(',', array_keys($i_data))); } else { aicobr_dbg('[ACB] Newsletter Plugin insert failed: ' . $wpdb->last_error); } } // Persist the entry→draft mapping so a later regeneration updates this same // draft instead of creating a duplicate, then delete the placeholder WP post — // a newsletter lives in the Newsletter plugin, not as a WordPress post. if (!empty($tnp_newsletter_id)) { $aicobr_tnp_newsletter_id = (int) $tnp_newsletter_id; // surfaced onto the diary entry below if ($tnp_entry_key !== '') { $tnp_map[$tnp_entry_key] = (int) $tnp_newsletter_id; update_option('aicobr_tnp_drafts', $tnp_map, false); } if ($updated_id && !is_wp_error($updated_id)) { wp_delete_post($updated_id, true); aicobr_dbg('[ACB] Newsletter Plugin: deleted placeholder WP post ' . $updated_id . ' (newsletter lives in the Newsletter plugin)'); $updated_id = 0; // entry will store post_id=0; downstream blocks skip } } } // === END NEWSLETTER PLUGIN INTEGRATION === // === THE EVENTS CALENDAR INTEGRATION === $tec_active = class_exists('Tribe__Events__Main') || post_type_exists('tribe_events'); if ($tec_active && $content_type === 'event_description' && $updated_id && !is_wp_error($updated_id)) { $tec_start = sanitize_text_field($data['tec_start_date'] ?? ''); $tec_end = sanitize_text_field($data['tec_end_date'] ?? ''); $tec_cost = sanitize_text_field($data['tec_cost'] ?? ''); $tec_url = esc_url_raw($data['tec_url'] ?? ''); $tec_venue = sanitize_text_field($data['tec_venue'] ?? ''); $tec_org = sanitize_text_field($data['tec_organiser'] ?? ''); // Convert datetime-local (YYYY-MM-DDTHH:MM) to TEC's local format (YYYY-MM-DD HH:MM:SS). $fmt_start = !empty($tec_start) ? str_replace('T', ' ', $tec_start) . ':00' : ''; $fmt_end = !empty($tec_end) ? str_replace('T', ' ', $tec_end) . ':00' : $fmt_start; // Resolve / create the venue and organiser posts first so we can pass their // IDs to the ORM (and fall back to meta below). $tec_venue_id = 0; if ($tec_venue) { $existing_venue = get_posts(['post_type' => 'tribe_venue', 'title' => $tec_venue, 'posts_per_page' => 1, 'post_status' => 'publish']); if (!empty($existing_venue)) { $tec_venue_id = (int) $existing_venue[0]->ID; } else { $venue_id = wp_insert_post(['post_title' => $tec_venue, 'post_type' => 'tribe_venue', 'post_status' => 'publish']); if (!is_wp_error($venue_id)) $tec_venue_id = (int) $venue_id; } } $tec_org_id = 0; if ($tec_org) { $existing_org = get_posts(['post_type' => 'tribe_organizer', 'title' => $tec_org, 'posts_per_page' => 1, 'post_status' => 'publish']); if (!empty($existing_org)) { $tec_org_id = (int) $existing_org[0]->ID; } else { $org_id = wp_insert_post(['post_title' => $tec_org, 'post_type' => 'tribe_organizer', 'post_status' => 'publish']); if (!is_wp_error($org_id)) $tec_org_id = (int) $org_id; } } // Compute the full date meta set TEC needs. Writing only the local // _EventStartDate/_EventEndDate makes an event show in the admin list but // sort/display wrongly in calendar views, because TEC reads the *UTC* keys // (and _EventDuration / _EventTimezone) for queries. We derive those from // the site timezone here so a meta-only write still renders correctly. $tec_tz_string = function_exists('wp_timezone_string') ? wp_timezone_string() : (get_option('timezone_string') ?: 'UTC'); $tec_duration = 0; $fmt_start_utc = ''; $fmt_end_utc = ''; if ($fmt_start) { try { $tz = new DateTimeZone($tec_tz_string ?: 'UTC'); $utc = new DateTimeZone('UTC'); $startObj = new DateTime($fmt_start, $tz); $endObj = new DateTime($fmt_end ?: $fmt_start, $tz); $tec_duration = max(0, $endObj->getTimestamp() - $startObj->getTimestamp()); $fmt_start_utc = (clone $startObj)->setTimezone($utc)->format('Y-m-d H:i:s'); $fmt_end_utc = (clone $endObj)->setTimezone($utc)->format('Y-m-d H:i:s'); } catch (Exception $e) { aicobr_dbg('[ACB] TEC date parse failed: ' . $e->getMessage()); } } // Preferred path: TEC's ORM (tribe_events()) sets every derived field AND // populates the custom tables (tec_events / tec_occurrences) that TEC 6+ // uses for the calendar. Writing raw meta alone does not create those rows. $tec_orm_ok = false; if ($fmt_start && function_exists('tribe_events')) { try { $orm_args = [ 'start_date' => $fmt_start, 'end_date' => $fmt_end ?: $fmt_start, 'timezone' => $tec_tz_string ?: 'UTC', 'status' => get_post_status($updated_id) ?: 'draft', ]; if ($tec_cost !== '') $orm_args['cost'] = $tec_cost; if ($tec_url !== '') $orm_args['url'] = $tec_url; if ($tec_venue_id) $orm_args['venue'] = $tec_venue_id; if ($tec_org_id) $orm_args['organizer'] = $tec_org_id; $orm_result = tribe_events()->where('id', $updated_id)->set_args($orm_args)->save(); $tec_orm_ok = !empty($orm_result); aicobr_dbg('[ACB] TEC ORM update ' . ($tec_orm_ok ? 'ok' : 'returned empty') . ' for post ' . $updated_id); } catch (Exception $e) { aicobr_dbg('[ACB] TEC ORM update threw: ' . $e->getMessage() . ' — falling back to meta'); } catch (Error $e) { aicobr_dbg('[ACB] TEC ORM update fatal: ' . $e->getMessage() . ' — falling back to meta'); } } // Fallback (and belt-and-braces on TEC 5.x): write the complete meta set // directly. Harmless if the ORM already set these — values are identical. if (!$tec_orm_ok) { if ($fmt_start) update_post_meta($updated_id, '_EventStartDate', $fmt_start); if ($fmt_end) update_post_meta($updated_id, '_EventEndDate', $fmt_end); if ($fmt_start_utc) update_post_meta($updated_id, '_EventStartDateUTC', $fmt_start_utc); if ($fmt_end_utc) update_post_meta($updated_id, '_EventEndDateUTC', $fmt_end_utc); if ($fmt_start) update_post_meta($updated_id, '_EventDuration', $tec_duration); update_post_meta($updated_id, '_EventTimezone', $tec_tz_string ?: 'UTC'); if (class_exists('Tribe__Events__Timezones') && $fmt_start) { update_post_meta($updated_id, '_EventTimezoneAbbr', Tribe__Events__Timezones::wp_timezone_abbr($fmt_start)); } if ($tec_cost !== '') update_post_meta($updated_id, '_EventCost', $tec_cost); if ($tec_url !== '') update_post_meta($updated_id, '_EventURL', $tec_url); if ($tec_venue_id) update_post_meta($updated_id, '_EventVenueID', $tec_venue_id); if ($tec_org_id) update_post_meta($updated_id, '_EventOrganizerID', $tec_org_id); } // These display flags are safe to set regardless of path. update_post_meta($updated_id, '_EventShowMap', 'yes'); update_post_meta($updated_id, '_EventShowMapLink', 'yes'); update_post_meta($updated_id, '_EventOrigin', 'events-calendar'); aicobr_dbg('[ACB] TEC event saved: post ' . $updated_id . ' start=' . $fmt_start . ' utc=' . $fmt_start_utc . ' dur=' . $tec_duration . ' venue_id=' . $tec_venue_id . ' org_id=' . $tec_org_id . ' via=' . ($tec_orm_ok ? 'orm' : 'meta')); // The event post was first created (wp_insert_post) BEFORE its date meta // existed, so TEC's save_post hooks ran against a dateless event and never // built the occurrence row its single-event template / front-end preview // needs — which is why a freshly generated draft event couldn't be // previewed until you manually re-saved it in the editor. Now that all the // meta is in place, fire one more save so TEC reprocesses the event with // its dates present. This is the programmatic equivalent of clicking // "Save draft", and it makes the draft previewable immediately. (ACB does // not hook save_post, so this cannot recurse.) if (!$tec_orm_ok && $fmt_start) { clean_post_cache($updated_id); wp_update_post(['ID' => $updated_id]); aicobr_dbg('[ACB] TEC event re-saved to rebuild occurrence data (post ' . $updated_id . ')'); } } // === END THE EVENTS CALENDAR INTEGRATION === // === END QUIZ POPULATION === // === END LMS INTEGRATION === // === WOOCOMMERCE META === // If this is a product, read saved WooCommerce fields from the diary entry // and apply them as post meta. We do NOT receive these from the licence server — // they were saved on the entry when the user created the content request. if ($post_type === 'product' && class_exists('WooCommerce')) { // Find the diary entry to read saved woo fields $all_entries = get_option('aicobr_entries', []); $woo_entry = null; $clean_title = strtolower(trim($title)); foreach ($all_entries as $e) { $title_match = strtolower(trim($e['title'] ?? '')) === $clean_title; $id_match = ($post_id && isset($e['post_id']) && intval($e['post_id']) === $post_id); if ($title_match || $id_match) { $woo_entry = $e; break; } } $woo_price = isset($woo_entry['woo_price']) ? floatval($woo_entry['woo_price']) : null; $woo_sale_price = isset($woo_entry['woo_sale_price']) ? floatval($woo_entry['woo_sale_price']) : null; $woo_sku = isset($woo_entry['woo_sku']) ? sanitize_text_field($woo_entry['woo_sku']) : ''; if ($woo_price !== null) { update_post_meta($updated_id, '_price', $woo_price); update_post_meta($updated_id, '_regular_price', $woo_price); } if ($woo_sale_price !== null && $woo_sale_price > 0) { update_post_meta($updated_id, '_sale_price', $woo_sale_price); // If sale price is set and lower, use it as the active price if ($woo_price === null || $woo_sale_price < $woo_price) { update_post_meta($updated_id, '_price', $woo_sale_price); } } if (!empty($woo_sku)) { update_post_meta($updated_id, '_sku', $woo_sku); } // Required WooCommerce defaults update_post_meta($updated_id, '_visibility', 'visible'); update_post_meta($updated_id, '_stock_status', 'instock'); update_post_meta($updated_id, '_manage_stock', 'no'); update_post_meta($updated_id, '_virtual', 'no'); update_post_meta($updated_id, '_downloadable', 'no'); aicobr_dbg("=== WOOCOMMERCE META SET for post $updated_id ==="); } // === END WOOCOMMERCE META === // Use SEO description as the diary excerpt — more useful than an auto-trimmed snippet $excerpt = !empty($seo_description) ? $seo_description : wp_trim_words(wp_strip_all_tags($content), 30); $featured_image_id = 0; $featured_image_url = ''; // === FEATURED IMAGE === // The license server now uploads the image directly to the WP Media Library // and sends back just the attachment ID — keeping the /publish payload small. // Legacy base64/byte-array formats are also supported as fallbacks. $attachment_id_from_server = absint($data['featured_image_attachment_id'] ?? 0); if ($attachment_id_from_server > 0 && get_post($attachment_id_from_server)) { // Native path: server uploaded the image, we just set it as thumbnail set_post_thumbnail($updated_id, $attachment_id_from_server); $featured_image_id = $attachment_id_from_server; $featured_image_url = wp_get_attachment_url($attachment_id_from_server); aicobr_dbg("=== IMAGE: set attachment {$attachment_id_from_server} as thumbnail ==="); } else { // Fallback: base64 string (legacy or if WP media upload failed on server) $image_data = ''; if (!empty($data['featured_image_b64'])) { $image_data = base64_decode($data['featured_image_b64']); aicobr_dbg('=== IMAGE: fallback base64 decode, length=' . strlen($image_data) . ' ==='); } elseif (!empty($data['featured_image']) && is_array($data['featured_image'])) { foreach ($data['featured_image'] as $byte) $image_data .= chr($byte); aicobr_dbg('=== IMAGE: fallback byte array, length=' . strlen($image_data) . ' ==='); } if (strlen($image_data) > 500) { $filename = 'featured-' . $updated_id . '-' . time() . '.png'; $upload = wp_upload_bits($filename, null, $image_data); if (!$upload['error']) { // Provides wp_generate_attachment_metadata(), used below. require_once(ABSPATH . 'wp-admin/includes/image.php'); $att_id = wp_insert_attachment([ 'post_mime_type' => 'image/png', 'post_title' => sanitize_file_name($title), 'post_status' => 'inherit', ], $upload['file'], $updated_id); if (!is_wp_error($att_id)) { wp_update_attachment_metadata($att_id, wp_generate_attachment_metadata($att_id, $upload['file'])); set_post_thumbnail($updated_id, $att_id); $featured_image_id = $att_id; $featured_image_url = wp_get_attachment_url($att_id); aicobr_dbg("=== IMAGE: fallback upload OK, attachment_id={$att_id} ==="); } } else { aicobr_dbg('=== IMAGE: fallback upload error: ' . $upload['error'] . ' ==='); } } else { // No image data at all — use existing thumbnail if post already has one $featured_image_url = has_post_thumbnail($updated_id) ? get_the_post_thumbnail_url($updated_id, 'medium') : ''; aicobr_dbg('=== IMAGE: no image data received ==='); } } // === END FEATURED IMAGE === // ============================================ // STATUS UPDATE - EXACT STABLE LOGIC FROM 30 APRIL // ============================================ // Serialise the entries read-modify-write so two near-simultaneous publishes // can't clobber each other's status write (the lost-update that left a card // stuck on "regenerating"). The re-read below runs inside the lock for freshness. $aicobr_pub_lock = $this->aicobr_lock_entries(); $entries = get_option('aicobr_entries', []); $entryUpdated = false; $cleanTitle = strtolower(trim(preg_replace('/\s+/', ' ', $title))); // Resolve which diary entry this result belongs to, by PRIORITY: // exact entry_id (round-tripped through the licence server) > post_id > // normalised title. This stops a duplicate title from attaching the result to // an older same-titled entry and orphaning the one that actually ran. $publish_entry_id = isset($data['entry_id']) && $data['entry_id'] !== '' ? (string) $data['entry_id'] : null; $matchId = null; if ($publish_entry_id !== null) { foreach ($entries as $e) { if (isset($e['id']) && (string) $e['id'] === $publish_entry_id) { $matchId = (string) $e['id']; break; } } } if ($matchId === null && $post_id) { foreach ($entries as $e) { if (isset($e['post_id']) && intval($e['post_id']) === $post_id) { $matchId = (string) $e['id']; break; } } } if ($matchId === null) { foreach ($entries as $e) { if (strtolower(trim(preg_replace('/\s+/', ' ', $e['title'] ?? ''))) === $cleanTitle) { $matchId = (string) $e['id']; break; } } } foreach ($entries as &$entry) { if ($matchId !== null && (string) ($entry['id'] ?? '') === $matchId) { $entry['post_id'] = $updated_id; $entry['status'] = 'generated'; $entry['stage'] = 'generated'; $entry['started_ts'] = null; $entry['updated_at'] = current_time('mysql'); $entry['generationStartTime'] = null; $entry['excerpt'] = $excerpt; $entry['post_type'] = $post_type; // Email newsletters live in MailPoet (no WP post) — surface the draft id // so the diary card can show a "View in MailPoet" button. if (!empty($aicobr_mp_newsletter_id)) { $entry['mailpoet_newsletter_id'] = (int) $aicobr_mp_newsletter_id; } // Same for the Newsletter (TNP) plugin — surface the draft id so the // diary card can show a "View in Newsletter" button. if (!empty($aicobr_tnp_newsletter_id)) { $entry['tnp_newsletter_id'] = (int) $aicobr_tnp_newsletter_id; } if ($featured_image_id) { $new_image_url = wp_get_attachment_url($featured_image_id); // Always update the active featured image to the latest one $entry['featured_image_id'] = $featured_image_id; $entry['featured_image'] = $new_image_url; // Accumulate into image_history — never overwrite previous images if (!isset($entry['image_history']) || !is_array($entry['image_history'])) { $entry['image_history'] = []; } // Mark all previous images as not active foreach ($entry['image_history'] as &$img) { $img['is_active'] = false; } unset($img); // Append the new image as the active one $entry['image_history'][] = [ 'attachment_id' => $featured_image_id, 'url' => $new_image_url, 'generated_at' => current_time('mysql'), 'is_active' => true, ]; } elseif (empty($entry['featured_image']) && !empty($featured_image_url)) { // This publish carried no NEW attachment id. Only fill the card // image if the entry has none AND we resolved a url (e.g. from an // existing thumbnail). Never blank an image the entry already has: // the featured-image set and the status publish are separate // server requests, and on long generations (tutorials especially) // a status-only write can land last — that must not wipe the // image the other write already recorded. (Was: unconditional // overwrite, which cleared the card while WP kept the thumbnail.) $entry['featured_image'] = $featured_image_url; } // Always write youtube_video so the field is never absent (even if empty string) if (!empty($youtube_video)) { $entry['youtube_video'] = $youtube_video; } elseif (!isset($entry['youtube_video'])) { $entry['youtube_video'] = ''; } // Additional video results (optional — only written if present) if (!empty($youtube_video_2)) { $entry['youtube_video_2'] = $youtube_video_2; } elseif (!isset($entry['youtube_video_2'])) { $entry['youtube_video_2'] = ''; } if (!empty($youtube_video_3)) { $entry['youtube_video_3'] = $youtube_video_3; } elseif (!isset($entry['youtube_video_3'])) { $entry['youtube_video_3'] = ''; } $entryUpdated = true; aicobr_dbg("=== STATUS UPDATED SUCCESSFULLY (entry " . $matchId . ", entry_id=" . ($publish_entry_id ?? 'n/a') . ") ==="); // Save SEO values onto the entry for reference if (!empty($seo_title)) $entry['seo_title'] = $seo_title; if (!empty($seo_description)) $entry['seo_description'] = $seo_description; if (!empty($seo_focus_keyword)) $entry['seo_focus_keyword'] = $seo_focus_keyword; if (!empty($seo_og_title)) $entry['seo_og_title'] = $seo_og_title; if (!empty($seo_og_description))$entry['seo_og_description'] = $seo_og_description; if (!empty($seo_plugin)) $entry['seo_plugin'] = $seo_plugin; if ($seo_score !== null) $entry['seo_score'] = $seo_score; if ($seo_report !== null) $entry['seo_report'] = $seo_report; if (!empty($acf_field_key)) $entry['acf_field_key'] = $acf_field_key; // AICOBR_INBODY_IMAGES_2026_08 — only overwrite when this publish // actually carried suggestions. A regeneration that did not ask for // them must not wipe the ones the user has not used yet. if (!empty($image_suggestions)) $entry['image_suggestions'] = $image_suggestions; break; } } // Safety fallback (prevents stuck items) if (!$entryUpdated) { aicobr_dbg("WARNING: No match found — creating fallback entry"); $entries[] = [ 'id' => time(), 'title' => $title, 'primary_keyword' => '', 'publish_date' => current_time('mysql'), 'status' => 'generated', 'post_id' => $updated_id, 'generationStartTime' => null, 'updated_at' => current_time('mysql'), 'excerpt' => $excerpt, 'featured_image' => $featured_image_id ? wp_get_attachment_url($featured_image_id) : $featured_image_url, 'featured_image_id' => $featured_image_id, 'image_history' => $featured_image_id ? [[ 'attachment_id' => $featured_image_id, 'url' => wp_get_attachment_url($featured_image_id), 'generated_at' => current_time('mysql'), 'is_active' => true, ]] : [], 'youtube_video' => $youtube_video, 'youtube_video_2' => $youtube_video_2, 'youtube_video_3' => $youtube_video_3, 'target_word_count' => intval($data['target_word_count'] ?? 1500), 'seo_title' => $seo_title, 'seo_description' => $seo_description, 'seo_focus_keyword' => $seo_focus_keyword, 'seo_og_title' => $seo_og_title, 'seo_og_description'=> $seo_og_description, 'seo_plugin' => $seo_plugin ?? '', 'seo_score' => $seo_score, 'seo_report' => $seo_report, 'image_suggestions' => $image_suggestions, ]; } update_option('aicobr_entries', $entries); if ($aicobr_pub_lock) $this->aicobr_unlock_entries(); // ============================================ // Logging $logs = get_option('aicobr_logs', []); $logs[] = [ 'created_at' => current_time('mysql'), 'title' => $title, 'status' => 'success', 'ai_model' => $ai_model, 'image_model' => sanitize_text_field($data['image_model'] ?? 'gpt-image-1.5'), 'credits_used' => intval($data['credits_used'] ?? 0), 'execution_time' => $execution_time, 'post_id' => $updated_id, 'featured_image' => $featured_image_id ? wp_get_attachment_url($featured_image_id) : $featured_image_url, 'excerpt' => $excerpt, 'image_style' => $image_style, 'include_youtube'=> $include_youtube, 'youtube_video' => $youtube_video, 'seo_plugin' => $seo_plugin ?? '', 'seo_title' => $seo_title, ]; $logs = array_slice($logs, -100); update_option('aicobr_logs', $logs); aicobr_dbg("=== PUBLISH COMPLETE - Post ID: $updated_id ==="); // NOTE: Credits are deducted by generate.js on the license server BEFORE // generation starts. Do NOT deduct again here — that caused double billing. return rest_ensure_response([ 'success' => true, 'post_id' => $updated_id, 'status' => 'success' ]); } /** * Auto-detects the active SEO plugin and writes all SEO meta fields. * Supports Yoast SEO, Rank Math, and SEOPress. * Returns the name of the detected plugin, or 'none' if none found. */ private function apply_seo_meta($post_id, $seo) { $title = $seo['title'] ?? ''; $desc = $seo['description'] ?? ''; $keyword = $seo['focus_keyword'] ?? ''; $og_title = $seo['og_title'] ?? $title; $og_desc = $seo['og_description'] ?? $desc; // Nothing to write — skip entirely if (empty($title) && empty($desc) && empty($keyword)) { return 'none'; } // === YOAST SEO === if (class_exists('WPSEO_Options')) { if (!empty($title)) update_post_meta($post_id, '_yoast_wpseo_title', $title); if (!empty($desc)) update_post_meta($post_id, '_yoast_wpseo_metadesc', $desc); if (!empty($keyword)) update_post_meta($post_id, '_yoast_wpseo_focuskw', $keyword); if (!empty($og_title)) update_post_meta($post_id, '_yoast_wpseo_opengraph-title', $og_title); if (!empty($og_desc)) update_post_meta($post_id, '_yoast_wpseo_opengraph-description',$og_desc); return 'yoast'; } // === RANK MATH === if (class_exists('RankMath')) { if (!empty($title)) update_post_meta($post_id, 'rank_math_title', $title); if (!empty($desc)) update_post_meta($post_id, 'rank_math_description', $desc); if (!empty($keyword)) update_post_meta($post_id, 'rank_math_focus_keyword', $keyword); if (!empty($og_title)) update_post_meta($post_id, 'rank_math_og_title', $og_title); if (!empty($og_desc)) update_post_meta($post_id, 'rank_math_og_description', $og_desc); return 'rankmath'; } // === SEOPRESS === if (defined('SEOPRESS_VERSION')) { if (!empty($title)) update_post_meta($post_id, '_seopress_titles_title', $title); if (!empty($desc)) update_post_meta($post_id, '_seopress_titles_desc', $desc); if (!empty($keyword)) update_post_meta($post_id, '_seopress_analysis_target_kw', $keyword); if (!empty($og_title)) update_post_meta($post_id, '_seopress_social_fb_title', $og_title); if (!empty($og_desc)) update_post_meta($post_id, '_seopress_social_fb_desc', $og_desc); return 'seopress'; } aicobr_dbg('AI Content Bridge: No supported SEO plugin detected. SEO meta not written.'); return 'none'; } private function processGeneratedContent($content, $includeYoutube = false, $content_type = 'blog_post', $post_type = 'post') { // Bundled, prefixed copy of Parsedown (renamed AICOBR_Parsedown) — cannot // collide with another plugin's or theme's Parsedown at any version. require_once __DIR__ . '/Parsedown.php'; $Parsedown = new AICOBR_Parsedown(); $Parsedown->setSafeMode(true); $Parsedown->setBreaksEnabled(false); // Let Parsedown handle paragraph breaks naturally $html = $Parsedown->text($content); // ── Gutenberg block classes ──────────────────────────────────────── $html = str_replace('', '
', $html); $html = str_replace('
', '
', $html); // ── Improve heading spacing — add margin classes ─────────────────── $html = preg_replace('/

/', '

', $html); $html = preg_replace('/

/', '

', $html); // ── Wrap lists in wp-block-list for Gutenberg compatibility ──────── $html = preg_replace('/
    /', '
      ', $html); $html = preg_replace('/
        /', '
          ', $html); // ── Clean up excessive blank lines ───────────────────────────────── $html = preg_replace('/(<\/p>)\s*(

          )/', "$1\n\n$2", $html); $html = preg_replace('/\n{3,}/', "\n\n", $html); // ── YouTube shortcode replacement ────────────────────────────────── $html = preg_replace_callback( '/\{%\s*youtube\s+(https?:\/\/(?:www\.)?youtube\.com\/watch\?v=[\w-]+|https?:\/\/youtu\.be\/[\w-]+)\s*%\}/i', function($matches) { $url = $matches[1]; $videoId = strpos($url, 'youtu.be') !== false ? basename(wp_parse_url($url, PHP_URL_PATH)) : (parse_str(wp_parse_url($url, PHP_URL_QUERY), $params) ? ($params['v'] ?? '') : ''); if (!$videoId) return $matches[0]; return '

          '; }, $html ); // ── ACB styled-content enhancement ───────────────────────────────── // Wrap in .acb-content and upgrade FAQ/callouts/takeaways/tables per tier. // Runs before wp_kses so the sanitiser (which now allows
          ) cleans // the final markup. Email/quiz tiers are returned untouched by the formatter. if (!class_exists('AICOBR_Content_Formatter')) { require_once __DIR__ . '/AIContentFormatter.php'; } $html = AICOBR_Content_Formatter::enhance($html, $content_type, $post_type); // Defence-in-depth: Parsedown safe mode is not a full sanitiser, so run the // output through wp_kses (allowing the YouTube embed iframe) before it is // stored or rendered. $allowed = wp_kses_allowed_html('post'); $allowed['iframe'] = [ 'src' => true, 'width' => true, 'height' => true, 'frameborder' => true, 'allowfullscreen' => true, 'allow' => true, 'title' => true, ]; $html = wp_kses($html, $allowed); return $html; } /** * POST /ai-content/v1/register-free * Proxies to the license server to register a free licence for this site. * Keeps the licence-server URL server-side, so there's no hardcoded URL in * the browser and no cross-origin call from the customer's WordPress install. */ public function register_free($request) { $data = $request->get_json_params(); $email = sanitize_email($data['email'] ?? ''); if (empty($email) || !is_email($email)) { return new WP_Error('invalid_email', 'Please enter a valid email address.', ['status' => 400]); } $license_server = self::get_server_url(); $response = wp_remote_post("{$license_server}/api/register-free", [ 'timeout' => 15, 'headers' => ['Content-Type' => 'application/json'], 'body' => wp_json_encode([ 'email' => $email, 'domain' => wp_parse_url(home_url(), PHP_URL_HOST), ]), ]); if (is_wp_error($response)) { return new WP_Error('server_unreachable', 'Could not reach the license server. Please try again.', ['status' => 502]); } $code = wp_remote_retrieve_response_code($response); $body = json_decode(wp_remote_retrieve_body($response), true); if ($code < 200 || $code >= 300 || empty($body['success'])) { return new WP_Error( 'registration_failed', $body['error'] ?? 'Registration failed. Please try again.', ['status' => ($code >= 400 && $code < 600) ? $code : 502] ); } self::maybe_store_generate_secret($body); return rest_ensure_response($body); } /** * GET /aicobr/v1/license-status?license_key=ACB-... * AICOBR_WIZARD_POLL_2026_07_29 — proxies the licence server's polling * endpoint so the onboarding wizard can detect email verification live * and auto-complete without any manual key copy/paste. */ public function license_status($request) { $license_key = sanitize_text_field($request->get_param('license_key') ?? ''); if (empty($license_key)) { return new WP_Error('no_key', 'License key is required.', ['status' => 400]); } $license_server = self::get_server_url(); $response = wp_remote_get( "{$license_server}/api/license-status?license_key=" . rawurlencode($license_key), ['timeout' => 10] ); if (is_wp_error($response)) { return new WP_Error('server_unreachable', 'Could not reach the license server.', ['status' => 502]); } $code = wp_remote_retrieve_response_code($response); $body = json_decode(wp_remote_retrieve_body($response), true); if ($code < 200 || $code >= 300 || empty($body['success'])) { return new WP_Error( 'status_failed', $body['error'] ?? 'Could not check licence status.', ['status' => ($code >= 400 && $code < 600) ? $code : 502] ); } return rest_ensure_response($body); } /** * POST /aicobr/v1/wizard-event * Fire-and-forget onboarding funnel telemetry (opened / inbox_reached / * completed …). Best-effort: failures are swallowed — telemetry must never * affect the user's setup flow. */ public function wizard_event($request) { $data = $request->get_json_params(); $event = sanitize_key($data['event'] ?? ''); if (!empty($event)) { $license_server = self::get_server_url(); wp_remote_post("{$license_server}/api/wizard-event", [ 'timeout' => 5, 'blocking' => false, 'headers' => ['Content-Type' => 'application/json'], 'body' => wp_json_encode([ 'event' => $event, 'domain' => wp_parse_url(home_url(), PHP_URL_HOST), ]), ]); } return rest_ensure_response(['ok' => true]); } /** * POST /ai-content/v1/verify-license * Proxies to the license server to validate the key. * On success, persists the plan tier and returns credits. */ public function verify_license($request) { $data = $request->get_json_params(); $license_key = sanitize_text_field($data['license_key'] ?? ''); $email = sanitize_email($data['email'] ?? ''); if (empty($license_key)) { return new WP_Error('no_key', 'License key is required.', ['status' => 400]); } $license_server = self::get_server_url(); $response = wp_remote_post("{$license_server}/api/verify-license", [ 'timeout' => 15, 'headers' => ['Content-Type' => 'application/json'], 'body' => wp_json_encode([ 'license_key' => $license_key, 'email' => $email, 'domain' => wp_parse_url(home_url(), PHP_URL_HOST), ]), ]); if (is_wp_error($response)) { return new WP_Error('server_unreachable', 'Could not reach the license server. Please try again.', ['status' => 502]); } $code = wp_remote_retrieve_response_code($response); $body = json_decode(wp_remote_retrieve_body($response), true); if ($code !== 200 || empty($body['success'])) { // AICOBR_DEACTIVATE_ON_REJECT_2026_07_29: the server definitively // rejected this key (this branch is NOT reached for network // failures — those return server_unreachable above). Persist the // deactivation: previously email_verified/plan_tier could only be // SET here and never cleared, so a site that saved a bad key kept // showing "Email verified — licence active" forever, surviving // reloads. Now stored state reflects the rejection, the Settings // badge flips, and the Dashboard setup wall returns. $settings = get_option('aicobr_settings', []); $settings['license_key'] = $license_key; // the rejected key, so the hint shows what was tried $settings['email_verified'] = false; $settings['plan_tier'] = 'free'; update_option('aicobr_settings', $settings); return new WP_Error( 'invalid_license', $body['error'] ?? 'License verification failed. Please check your key and try again.', ['status' => 403] ); } self::maybe_store_generate_secret($body); // Persist verified key + plan into settings. // AICOBR_EMAIL_AUTHORITATIVE_2026_07_29: the licence server returns the // account's real email; prefer it over whatever was typed in the form. // The Settings email field previously accepted any value unchecked and // stored it as if meaningful — now the server is the source of truth. $settings = get_option('aicobr_settings', []); $settings['license_key'] = $license_key; $settings['email'] = sanitize_email($body['email'] ?? '') ?: $email; // Normalise tier — strip anything like "(beta)" and map to known values $raw_tier = strtolower(sanitize_text_field($body['plan'] ?? 'free')); if (strpos($raw_tier, 'agency') !== false) $raw_tier = 'agency'; elseif (strpos($raw_tier, 'pro') !== false) $raw_tier = 'pro'; elseif (strpos($raw_tier, 'starter') !== false) $raw_tier = 'starter'; else $raw_tier = 'free'; $settings['plan_tier'] = $raw_tier; $settings['email_verified'] = true; update_option('aicobr_settings', $settings); return rest_ensure_response([ 'success' => true, 'plan' => $body['plan'] ?? 'free', 'credits_remaining' => $body['credits_remaining'] ?? null, 'expires_at' => $body['expires_at'] ?? null, ]); } /** * POST /ai-content/v1/extract-style * Sends a writing sample to the license server (which calls Claude) * and returns a structured style profile object. */ /** * GET /ai-content/v1/lms-data * Returns detected LMS plugins and their course/lesson structure. * Used by the ContentDiary LearnDash picker UI. */ public function get_lms_data() { $data = [ 'lifterlms' => false, 'learnpress' => false, 'courses' => [], 'debug' => [ 'llms_class' => class_exists('LLMS'), 'llms_constant' => defined('LLMS_PLUGIN_FILE'), 'lp_class' => class_exists('LearnPress'), 'lp_constant' => defined('LP_PLUGIN_FILE'), ], ]; // ── LifterLMS ───────────────────────────────────────────────────── // LifterLMS uses different class names across versions: // v3.x: 'LifterLMS', v4.x+: 'LLMS', function-based: 'llms' $lifterlms_active = class_exists('LLMS') || class_exists('LifterLMS') || function_exists('llms') || defined('LLMS_PLUGIN_FILE'); if ($lifterlms_active) { $data['lifterlms'] = true; $courses = get_posts([ 'post_type' => 'course', 'posts_per_page' => 100, 'post_status' => 'publish', 'orderby' => 'title', 'order' => 'ASC', ]); foreach ($courses as $course) { // LifterLMS: llms_section is not a real post type — fetch lessons // directly via _llms_parent_course meta $lessons = get_posts([ 'post_type' => 'lesson', 'posts_per_page' => 100, 'post_status' => 'publish', 'orderby' => 'menu_order', 'order' => 'ASC', 'meta_query' => [[ 'key' => '_llms_parent_course', 'value' => $course->ID, ]], ]); $lessons_data = []; foreach ($lessons as $lesson) { $lessons_data[] = [ 'id' => $lesson->ID, 'title' => $lesson->post_title, ]; } $data['courses'][] = [ 'id' => $course->ID, 'title' => $course->post_title, 'lms' => 'lifterlms', 'lessons' => $lessons_data, ]; } } // ── LearnPress ─────────────────────────────────────────────────────── $lp_active = class_exists('LearnPress') || defined('LP_PLUGIN_FILE') || post_type_exists('lp_course'); if ($lp_active) { $data['learnpress'] = true; $courses = get_posts([ 'post_type' => 'lp_course', 'posts_per_page' => 100, 'post_status' => 'publish', 'orderby' => 'title', 'order' => 'ASC', ]); foreach ($courses as $course) { // LearnPress stores curriculum as JSON meta on the course // Parse it to get sections with their lesson/quiz items $curriculum_json = get_post_meta($course->ID, '_lp_course_json', true); $sections_data = []; if (!empty($curriculum_json)) { $curriculum = json_decode($curriculum_json, true); if (!empty($curriculum['sections'])) { foreach ($curriculum['sections'] as $section) { $section_lessons = []; foreach ($section['items'] ?? [] as $item) { $post = get_post($item['id']); if ($post) { $section_lessons[] = [ 'id' => $post->ID, 'title' => $post->post_title, 'type' => $item['type'], ]; } } $sections_data[] = [ 'id' => $section['id'], 'title' => $section['title'] ?? 'Section', 'lessons' => $section_lessons, ]; } } } // Flatten to lessons list for the picker (section name as prefix) $lessons_data = []; foreach ($sections_data as $section) { foreach ($section['lessons'] as $lesson) { $lessons_data[] = [ 'id' => $lesson['id'], 'title' => $lesson['title'], 'section' => $section['title'], 'section_id' => $section['id'], 'type' => $lesson['type'], ]; } } // Also get standalone lessons attached to this course // (lessons linked via _lp_course meta but not yet in curriculum) $standalone = get_posts([ 'post_type' => 'lp_lesson', 'posts_per_page' => 100, 'post_status' => 'publish', 'meta_query' => [[ 'key' => '_lp_course', 'value' => $course->ID, ]], ]); $existing_ids = array_column($lessons_data, 'id'); foreach ($standalone as $lesson) { if (!in_array($lesson->ID, $existing_ids)) { $lessons_data[] = [ 'id' => $lesson->ID, 'title' => $lesson->post_title, ]; } } $data['courses'][] = [ 'id' => $course->ID, 'title' => $course->post_title, 'lms' => 'learnpress', 'lessons' => $lessons_data, 'sections' => $sections_data, ]; } } return rest_ensure_response($data); } /** * Populate a LearnPress 4.x quiz with questions and answers. * LP4 stores everything in custom tables — not post meta or post_parent. * * Tables used: * learnpress_quiz_questions — links question posts to quiz post * learnpress_question_answers — stores answer options per question * learnpress_section_items — links quiz to course section * learnpress_sections — course sections */ public function populate_learnpress_quiz($quiz_id, $quiz_data, $course_id = 0) { global $wpdb; if (empty($quiz_data['questions'])) return; // Set quiz intro as excerpt if (!empty($quiz_data['quiz_intro'])) { wp_update_post(['ID' => $quiz_id, 'post_excerpt' => sanitize_text_field($quiz_data['quiz_intro'])]); } // Default quiz meta settings $quiz_meta = [ '_lp_duration' => '0 minute', '_lp_instant_check' => 'no', '_lp_negative_marking' => 'no', '_lp_review' => 'yes', '_lp_show_correct_review' => 'yes', '_lp_passing_grade' => '80', '_lp_retake_count' => '0', '_lp_pagination' => '1', ]; foreach ($quiz_meta as $key => $val) { update_post_meta($quiz_id, $key, $val); } $tbl_quiz_q = $wpdb->prefix . 'learnpress_quiz_questions'; $tbl_answers = $wpdb->prefix . 'learnpress_question_answers'; $q_order = 0; foreach ($quiz_data['questions'] as $q) { $q_order++; $q_type = $q['type'] ?? 'multiple_choice'; // Map to LearnPress question type slugs if ($q_type === 'true_false') { $lp_type = 'true_or_false'; } elseif ($q_type === 'open') { $lp_type = 'fill_in_blanks'; } else { $lp_type = 'multi_choice'; } // Create the question as an lp_question post $q_id = wp_insert_post([ 'post_title' => sanitize_text_field($q['text']), 'post_content' => '', 'post_type' => 'lp_question', 'post_status' => 'publish', 'post_parent' => 0, // LP4 uses its own tables, not WP post_parent 'menu_order' => 0, ]); if (is_wp_error($q_id) || !$q_id) { aicobr_dbg('[ACB] LP question create failed: ' . (is_wp_error($q_id) ? $q_id->get_error_message() : 'unknown')); continue; } // Set question type and explanation as post meta update_post_meta($q_id, '_lp_type', $lp_type); update_post_meta($q_id, '_lp_explanation', sanitize_text_field($q['explanation'] ?? '')); // Link question to quiz via learnpress_quiz_questions table $wpdb->insert($tbl_quiz_q, [ 'quiz_id' => $quiz_id, 'question_id' => $q_id, 'question_order' => $q_order, ], ['%d', '%d', '%d']); // Insert answer options into learnpress_question_answers if ($lp_type === 'multi_choice' || $lp_type === 'true_or_false') { $a_order = 0; foreach ($q['answers'] ?? [] as $ans) { $a_order++; // value must be unique varchar(32) — use a short hash $value = substr(md5($q_id . '_' . $a_order), 0, 10); $wpdb->insert($tbl_answers, [ 'question_id' => $q_id, 'title' => sanitize_text_field($ans['text']), 'value' => $value, 'order' => $a_order, 'is_true' => !empty($ans['correct']) ? 'yes' : '', ], ['%d', '%s', '%s', '%d', '%s']); } } elseif ($lp_type === 'fill_in_blanks') { // Open question — store model answer in meta update_post_meta($q_id, '_lp_hint', sanitize_text_field($q['model_answer'] ?? '')); } aicobr_dbg("[ACB] LP question $q_order created: post $q_id ($lp_type) linked to quiz $quiz_id"); } // Link quiz to course section if course_id provided if ($course_id) { $tbl_sections = $wpdb->prefix . 'learnpress_sections'; $tbl_section_items = $wpdb->prefix . 'learnpress_section_items'; // Find existing section for this course, or create one $section_id = $wpdb->get_var($wpdb->prepare( "SELECT section_id FROM $tbl_sections WHERE section_course_id = %d ORDER BY section_order ASC LIMIT 1", $course_id )); if (!$section_id) { $wpdb->insert($tbl_sections, [ 'section_name' => 'Generated Content', 'section_course_id' => $course_id, 'section_order' => 1, 'section_description' => '', ], ['%s', '%d', '%d', '%s']); $section_id = $wpdb->insert_id; } if ($section_id) { // Get next item order in this section $next_order = (int) $wpdb->get_var($wpdb->prepare( "SELECT COALESCE(MAX(item_order), 0) + 1 FROM $tbl_section_items WHERE section_id = %d", $section_id )); $wpdb->insert($tbl_section_items, [ 'section_id' => $section_id, 'item_id' => $quiz_id, 'item_order' => $next_order, 'item_type' => 'lp_quiz', ], ['%d', '%d', '%d', '%s']); aicobr_dbg("[ACB] LP quiz $quiz_id added to section $section_id (course $course_id)"); } } // Clear LP caches wp_cache_delete($quiz_id, 'lp_quiz'); if ($course_id) wp_cache_delete($course_id, 'lp_course'); if (function_exists('learn_press_reset_auto_draft')) learn_press_reset_auto_draft($course_id); aicobr_dbg('[ACB] LP quiz ' . $quiz_id . ': ' . count($quiz_data['questions']) . ' questions populated successfully'); } public function populate_lifterlms_quiz($quiz_id, $quiz_data, $course_id = 0) { if (empty($quiz_data['questions'])) return; if (!empty($quiz_data['quiz_intro'])) { wp_update_post(['ID' => $quiz_id, 'post_excerpt' => sanitize_text_field($quiz_data['quiz_intro'])]); } $q_list = []; $order = 0; foreach ($quiz_data['questions'] as $q) { $order++; $q_type = $q['type'] ?? 'multiple_choice'; $llms_type = ($q_type === 'true_false') ? 'true_false' : (($q_type === 'open') ? 'short_answer' : 'choice'); $q_id = wp_insert_post([ 'post_title' => sanitize_text_field($q['text']), 'post_type' => 'llms_question', 'post_status' => 'publish', 'post_parent' => $quiz_id, 'menu_order' => $order, ]); if (is_wp_error($q_id)) { aicobr_dbg('[ACB] LLMS question insert failed: ' . $q_id->get_error_message()); continue; } update_post_meta($q_id, '_llms_question_type', $llms_type); update_post_meta($q_id, '_llms_quiz', $quiz_id); update_post_meta($q_id, '_llms_question_explanation', sanitize_text_field($q['explanation'] ?? '')); if ($course_id) update_post_meta($q_id, '_llms_parent_course', $course_id); // Write each choice as its own postmeta row: _llms_choice_{id} => serialised array. // This is the internal format LLMS_Question_Choice::save() produces and // LLMS_Question::get_choices() reads (meta_key LIKE '_llms_choice_%'). // We replicate it directly because LLMS_Question_Choice may not be loaded // in the REST context even when LLMS is active (post types register on init // but class autoloading can lag behind). foreach ($q['answers'] ?? [] as $idx => $ans) { $choice_id = uniqid(); $choice_data = [ 'id' => $choice_id, 'choice' => sanitize_text_field($ans['text']), 'choice_type' => 'text', 'correct' => !empty($ans['correct']), 'marker' => chr(65 + $idx), // A, B, C, D… ]; update_post_meta($q_id, '_llms_choice_' . $choice_id, $choice_data); } // _llms_questions on the quiz is an array of {id, points} maps $q_list[] = ['id' => $q_id, 'points' => 1]; } // Rebuild the question list from the questions that ACTUALLY exist in the DB, // parented to this quiz. Building it from $q_id values returned by // wp_insert_post can include a phantom ID if an insert reserved an ID but the // post didn't persist (e.g. an auto-draft collision) — and a single bad ID in // _llms_questions makes the Course Builder's question loader throw // "An error occurred while trying to load the questions". Querying the real // posts guarantees every listed ID resolves to a genuine llms_question. $real_q_ids = get_posts([ 'post_type' => 'llms_question', 'post_parent' => $quiz_id, 'post_status' => 'publish', 'posts_per_page' => -1, 'fields' => 'ids', 'orderby' => 'menu_order', 'order' => 'ASC', ]); $q_list = array_map(function ($id) { return ['id' => (int) $id, 'points' => 1]; }, $real_q_ids); // Register all questions on the quiz so LLMS_Quiz::get_questions() finds them update_post_meta($quiz_id, '_llms_questions', $q_list); wp_cache_delete($quiz_id, 'llms_quiz'); aicobr_dbg('[ACB] LLMS quiz ' . $quiz_id . ': ' . count($q_list) . ' questions registered (from DB)'); } public function get_tec_status() { $active = class_exists('Tribe__Events__Main') || post_type_exists('tribe_events'); return rest_ensure_response(['active' => $active]); } public function get_mailpoet_lists() { if (!class_exists('\MailPoet\API\API')) { return rest_ensure_response(['active' => false, 'lists' => []]); } try { $api = \MailPoet\API\API::MP('v1'); $lists = $api->getLists(); $out = []; foreach ($lists as $l) { $out[] = ['id' => $l['id'], 'name' => $l['name'], 'subscribers' => $l['subscribers_count'] ?? 0]; } return rest_ensure_response(['active' => true, 'lists' => $out]); } catch (\Exception $e) { return rest_ensure_response(['active' => false, 'lists' => [], 'error' => $e->getMessage()]); } } public function get_newsletter_lists() { // Newsletter plugin by The Newsletter Team (TNP). $tnp_active = class_exists('Newsletter') || class_exists('TNP') || defined('NEWSLETTER_VERSION'); if (!$tnp_active) { return rest_ensure_response(['active' => false, 'lists' => []]); } global $wpdb; $lists = []; // Subscriber-count helper: list membership is the list_N column on the // {$wpdb->prefix}newsletter table; 'C' = confirmed. $count_for = function ($i) use ($wpdb) { $col = 'list_' . (int) $i; $table = $wpdb->prefix . 'newsletter'; // Guard against the column not existing on minimal installs. $exists = $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = %s AND column_name = %s", $table, $col )); if (!$exists) return 0; return (int) $wpdb->get_var( $wpdb->prepare("SELECT COUNT(*) FROM `{$table}` WHERE `{$col}` = 1 AND status = %s", 'C') ); }; // Preferred: ask TNP itself. Newsletter::instance()->get_list_options() // returns the configured lists keyed by id regardless of how the current // version stores them internally (9.x keeps them in the main settings // option, not in separate newsletter_list_N options like older versions). if (class_exists('Newsletter') && method_exists('Newsletter', 'instance')) { $tnp = Newsletter::instance(); // Newer cores expose get_lists() returning objects with ->id/->name. if (method_exists($tnp, 'get_lists')) { foreach ((array) $tnp->get_lists() as $l) { $id = is_object($l) ? (isset($l->id) ? (int) $l->id : 0) : (isset($l['id']) ? (int) $l['id'] : 0); $name = is_object($l) ? (isset($l->name) ? $l->name : '') : (isset($l['name']) ? $l['name'] : ''); if ($id && $name !== '') { $lists[] = ['id' => $id, 'name' => $name, 'subscribers' => $count_for($id)]; } } } // Fallback: get_list_options() returns [id => ['name'=>..., ...]]. if (empty($lists) && method_exists($tnp, 'get_list_options')) { foreach ((array) $tnp->get_list_options() as $id => $opt) { $name = is_array($opt) ? ($opt['name'] ?? '') : (string) $opt; if ((int) $id && $name !== '') { $lists[] = ['id' => (int) $id, 'name' => $name, 'subscribers' => $count_for($id)]; } } } } // Last-resort fallbacks for older cores / unusual storage: // (a) the single 'newsletter_main' (or 'newsletter') settings option with // list_N_name keys, then (b) the legacy per-list newsletter_list_N // options. Note the correct constant is NEWSLETTER_LISTS_MAX (plural); // the old code read NEWSLETTER_LIST_MAX, which never matched. $max_lists = defined('NEWSLETTER_LISTS_MAX') ? (int) NEWSLETTER_LISTS_MAX : (defined('NEWSLETTER_LIST_MAX') ? (int) NEWSLETTER_LIST_MAX : 40); if (empty($lists)) { $main = get_option('newsletter_main'); if (!is_array($main)) $main = get_option('newsletter'); if (is_array($main)) { for ($i = 1; $i <= $max_lists; $i++) { $name = $main['list_' . $i . '_name'] ?? ($main['list_' . $i] ?? ''); if (is_string($name) && trim($name) !== '') { $lists[] = ['id' => $i, 'name' => $name, 'subscribers' => $count_for($i)]; } } } } if (empty($lists)) { for ($i = 1; $i <= $max_lists; $i++) { $list_option = get_option('newsletter_list_' . $i); if (!empty($list_option['name'])) { $lists[] = ['id' => $i, 'name' => $list_option['name'], 'subscribers' => $count_for($i)]; } } } // 'active' reflects only whether TNP is installed — NOT whether lists were // found. The UI decides what to show; an installed-but-no-lists state must // still surface so the user can be told to create a list, rather than the // integration silently vanishing. return rest_ensure_response(['active' => true, 'lists' => $lists]); } public function extract_style($request) { $data = $request->get_json_params(); $sample = wp_kses_post($data['sample'] ?? ''); if (strlen(trim($sample)) < 100) { return new WP_Error('sample_too_short', 'Writing sample must be at least 100 characters.', ['status' => 400]); } $settings = get_option('aicobr_settings', []); $license_key = $settings['license_key'] ?? ''; if (empty($license_key)) { return new WP_Error('no_license', 'No license key configured. Please complete setup first.', ['status' => 403]); } $license_server = self::get_server_url(); $generate_secret = self::get_secret(); $response = wp_remote_post("{$license_server}/api/extract-style", [ 'timeout' => 30, 'headers' => [ 'Content-Type' => 'application/json', 'x-generate-secret' => $generate_secret, ], 'body' => wp_json_encode([ 'license_key' => $license_key, 'sample' => $sample, ]), ]); if (is_wp_error($response)) { return new WP_Error('server_unreachable', 'Could not reach the style extraction service.', ['status' => 502]); } $code = wp_remote_retrieve_response_code($response); $body = json_decode(wp_remote_retrieve_body($response), true); if ($code !== 200 || empty($body['success'])) { return new WP_Error( 'extraction_failed', $body['error'] ?? 'Style extraction failed. Please try again.', ['status' => 500] ); } return rest_ensure_response([ 'success' => true, 'profile' => $body['profile'], ]); } /* Self-hosted auto-updates removed for WordPress.org distribution. [ACB_UPDATER_REMOVED_2026_07_08] Updates are served by WordPress.org, matched on the plugin slug. Do NOT re-add a custom update channel while this plugin is hosted in the .org directory (it violates the guidelines). */ } new AICOBR_Plugin();