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'; 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.8.7'; /** * 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://acb-license-server-production.up.railway.app'; /** 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); } /** * 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 || !self::aicobr_is_generated_post($post->ID)) 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); } } /** 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')) ]); $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') ]); $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')) ]); $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') ]); $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) { 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. */ 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. $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']; $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]); } public function delete_entry($request) { $id = absint($request->get_param('id')); $locked = $this->aicobr_lock_entries(); $entries = get_option('aicobr_entries', []); $entries = array_filter($entries, fn($entry) => $entry['id'] != $id); update_option('aicobr_entries', array_values($entries)); if ($locked) $this->aicobr_unlock_entries(); return rest_ensure_response(['success' => true]); } 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; } 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 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']), // ── 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), // 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'] ?? '')), ]); 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); } /** * 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; 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'] = []; } // 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' => $attachment_id, 'url' => $new_url, 'generated_at' => current_time('mysql'), 'is_active' => true, ]; // ── 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. */ 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); } /** * 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'] ?? [], // 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'] ?? ''), ]; // 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, ]; $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; // === 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 === // 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) . ' ==='); $post_data = [ 'post_title' => $title, 'post_content' => $content, 'post_status' => $status, 'post_type' => $post_type, ]; // 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 === // === 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 wp_update_post([ 'ID' => $lms_course, 'post_content' => $content, 'post_excerpt' => 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; 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, ]; } 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); } /** * 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'])) { 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 $settings = get_option('aicobr_settings', []); $settings['license_key'] = $license_key; $settings['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();