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.11';
/**
* No shared secret is shipped in source. The live value is fetched from the
* licence server (server-to-server) during verify/registration and stored in
* the `aicobr_generate_secret` option — see get_secret() / maybe_store_generate_secret().
*/
const GENERATE_SECRET = '';
/** Resolve the active shared secret (synced from the server into the aicobr_generate_secret option). */
public static function get_secret() {
$opt = get_option('aicobr_generate_secret');
return (is_string($opt) && $opt !== '') ? $opt : self::GENERATE_SECRET;
}
/**
* Persist the live generate secret the licence server returns on verify/register.
* These calls are server-to-server (the value never reaches the browser), so this
* keeps every install in sync with the server even if the secret is rotated, and
* removes any reliance on the hardcoded fallback.
*/
public static function maybe_store_generate_secret($body) {
if (is_array($body) && !empty($body['generate_secret'])) {
$secret = sanitize_text_field($body['generate_secret']);
if ($secret !== get_option('aicobr_generate_secret')) {
update_option('aicobr_generate_secret', $secret);
}
}
}
/** Constant-time check that a request carries the shared secret header. */
public static function verify_secret($request) {
$incoming = (string) $request->get_header('x-generate-secret');
return $incoming !== '' && hash_equals(self::get_secret(), $incoming);
}
/**
* Fallback licence-server base URL. Override per install via the
* `aicobr_license_server` option (set it to your ACB server domain).
*/
const LICENSE_SERVER = 'https://api.aicontentbridge.com';
/** Resolve the licence-server base URL (stored option preferred), trailing slash stripped. */
public static function get_server_url() {
$opt = get_option('aicobr_license_server');
$url = (is_string($opt) && $opt !== '') ? $opt : self::LICENSE_SERVER;
return rtrim($url, '/');
}
/**
* Refresh the cached plan tier from the licence server using the stored key.
*
* The UI gates paid features (Style Profiles, premium content types, YouTube,
* ACF) on aicobr_settings['plan_tier']. That cache was previously only
* written when the user manually verified their key, so after a Stripe
* upgrade/renewal/downgrade the licence server tier would change while the
* cache stayed stale — leaving features wrongly locked (or unlocked). This
* pulls the authoritative tier from the server and updates the cache.
*
* Throttled to once per TTL via a transient so the Dashboard's frequent
* settings poll can't hammer the licence server. On any failure the existing
* cached tier is left untouched (fail-safe — never downgrade on a blip).
*
* @param bool $force Skip the TTL throttle and refresh immediately
* (used on return from Stripe checkout).
* @return string|null Normalised tier on a successful refresh, else null.
*/
public static function refresh_license_tier($force = false) {
$settings = get_option('aicobr_settings', []);
$license_key = $settings['license_key'] ?? '';
if (empty($license_key)) {
return null; // no key stored — nothing to refresh against
}
$ttl = 60; // seconds
if (!$force && get_transient('aicobr_tier_refreshed_at') !== false) {
return null; // refreshed recently — skip
}
// Mark the attempt up-front so rapid concurrent polls don't all fire,
// and so a persistently unreachable server isn't hit every poll.
set_transient('aicobr_tier_refreshed_at', time(), $ttl);
$response = wp_remote_post(self::get_server_url() . '/api/verify-license', [
'timeout' => 8,
'headers' => ['Content-Type' => 'application/json'],
'body' => wp_json_encode([
'license_key' => $license_key,
'email' => $settings['email'] ?? '',
'domain' => wp_parse_url(home_url(), PHP_URL_HOST),
]),
]);
if (is_wp_error($response)) {
return null; // keep existing cached tier on network error
}
$code = wp_remote_retrieve_response_code($response);
$body = json_decode(wp_remote_retrieve_body($response), true);
if ($code !== 200 || empty($body['success'])) {
return null; // keep existing cached tier on bad/invalid response
}
self::maybe_store_generate_secret($body);
// Normalise tier — identical mapping to verify_license().
$raw = strtolower(sanitize_text_field($body['plan'] ?? 'free'));
if (strpos($raw, 'agency') !== false) $tier = 'agency';
elseif (strpos($raw, 'pro') !== false) $tier = 'pro';
elseif (strpos($raw, 'starter') !== false) $tier = 'starter';
else $tier = 'free';
if (($settings['plan_tier'] ?? null) !== $tier) {
$settings['plan_tier'] = $tier;
update_option('aicobr_settings', $settings);
}
return $tier;
}
public function __construct() {
add_action('plugins_loaded', [self::class, 'aicobr_maybe_migrate_prefixes']);
add_action('rest_api_init', [$this, 'register_endpoints']);
add_action('admin_menu', [$this, 'add_admin_menu']);
add_action('admin_enqueue_scripts', [$this, 'enqueue_assets']);
// ── Front-end styled content (the .acb-content layer) ──
add_action('wp_enqueue_scripts', [$this, 'enqueue_frontend_styles']);
add_filter('wp_kses_allowed_html', [$this, 'aicobr_allow_accordion_tags'], 10, 2);
// AICOBR_CLUSTER_SHORTCODE_2026_08
// Renders the cluster's spoke pages as a links block on the pillar page.
// Registered on init (not plugins_loaded) so post types and permalinks
// are ready. This is the ONLY downward-linking mechanism: ACB never
// writes into the user's pillar page — the user places this shortcode
// and WordPress renders the block from live diary data on each view.
add_action('init', [$this, 'aicobr_register_shortcodes']);
// A spoke can leave 'publish' outside the generation flow — the user
// trashes it, drafts it, or schedules it. Without this the cached block
// would keep listing it (and link to a 404) until the transient expired.
add_action('transition_post_status', [$this, 'aicobr_cluster_status_changed'], 10, 3);
}
/**
* Load the scoped content stylesheet + heading font, but only on singular
* posts that ACB generated — never site-wide.
*/
public function enqueue_frontend_styles() {
if (is_admin() || !is_singular()) return;
$post = get_post();
if (!$post) return;
// AICOBR_CLUSTER_SHORTCODE_2026_08
// The stylesheet normally loads only on ACB-generated posts. A pillar
// page is the user's OWN page — not ACB-generated — so without this the
// [content_cluster] block would render unstyled. Load the sheet when the
// shortcode is present on the page regardless of who wrote it.
$has_cluster_block = has_shortcode((string) $post->post_content, 'content_cluster')
|| has_shortcode((string) $post->post_content, 'aicobr_cluster');
if (!self::aicobr_is_generated_post($post->ID) && !$has_cluster_block) return;
$settings = get_option('aicobr_settings', []);
// Master switch — default ON when never configured, so existing installs
// keep the current styled output until a user opts out.
$styling_on = !array_key_exists('enable_frontend_styling', $settings)
|| !empty($settings['enable_frontend_styling']);
if (!$styling_on) return;
$css_path = plugin_dir_path(__FILE__) . 'ai-content-bridge-frontend.css';
$ver = @filemtime($css_path) ?: '2.7.3';
// Fraunces is self-hosted (fonts/fraunces-var.woff2, SIL OFL — see
// fonts/OFL.txt) via @font-face in the stylesheet below. No third-party
// requests are made from visitors' browsers.
wp_enqueue_style('aicobr-frontend', plugin_dir_url(__FILE__) . 'ai-content-bridge-frontend.css', [], $ver);
// Inline overrides layered on top of the stylesheet.
$inline = '';
// Accent colour: the brand_color setting drives --acb-accent; otherwise
// the stylesheet's ACB-blue default applies.
$accent = !empty($settings['brand_color']) ? sanitize_hex_color($settings['brand_color']) : '';
if ($accent) {
$inline .= ':root{--acb-accent:' . esc_attr($accent) . ';}';
}
// Serif post title — default ON. When disabled, reset just the title
// font to inherit so it follows the theme; component styling and the
// blockquote pull-quote serif are untouched.
$serif_on = !array_key_exists('style_post_title_serif', $settings)
|| !empty($settings['style_post_title_serif']);
if (!$serif_on) {
$inline .= '.elementor-page-title .elementor-heading-title,'
. '.elementor-widget-theme-post-title .elementor-heading-title,'
. '.wp-block-post-title,.entry-title{font-family:inherit !important;}';
}
if ($inline !== '') {
wp_add_inline_style('aicobr-frontend', $inline);
}
}
/**
* AICOBR_CLUSTER_SHORTCODE_2026_08
*
* [content_cluster] — renders the published spoke pages of the cluster whose
* pillar is the CURRENT post. Placed by the user on their own pillar page.
*
* Why a shortcode rather than writing links into the page:
* - ACB never edits a post it did not create. No guard to relax, no risk
* of trampling a hand-edited or page-builder layout.
* - It is always correct. New spokes appear as they publish; deleted or
* unpublished spokes drop out. Nothing to reconcile, nothing to go stale.
* - It renders server-side in PHP, so the links are in the delivered HTML
* and are crawlable exactly as if they had been typed in.
*
* Attributes:
* heading — block heading. Empty string renders no heading. Default set below.
* limit — max links to show. 0 (default) = all.
*
* Renders an empty string when the cluster has no published spokes yet, so a
* pillar page never shows an empty box.
*/
/**
* Any status change on an ACB-generated post invalidates the cached cluster
* block of the pillar it belongs to. Cheap: only touches posts ACB created,
* and only reads the entries option for those.
*/
public function aicobr_cluster_status_changed($new_status, $old_status, $post) {
if ($new_status === $old_status) return;
if (!$post || !isset($post->ID)) return;
if (!self::aicobr_is_generated_post($post->ID)) return;
$entries = get_option('aicobr_entries', []);
if (!is_array($entries)) return;
foreach ($entries as $e) {
if (absint($e['post_id'] ?? 0) !== absint($post->ID)) continue;
if (empty($e['cluster_pillar']['post_id'])) continue;
$this->aicobr_bust_cluster_cache($e['cluster_pillar']['post_id']);
break;
}
}
public function aicobr_register_shortcodes() {
// AICOBR_CLUSTER_SHORTCODE_RENAME_2026_08
// [content_cluster] is the name users are told and the one in the docs.
// [aicobr_cluster] stays registered PERMANENTLY as an alias: anyone who
// placed the original would otherwise lose their block silently on
// update — no error, the section simply vanishes from their page.
add_shortcode('content_cluster', [$this, 'aicobr_cluster_shortcode']);
add_shortcode('aicobr_cluster', [$this, 'aicobr_cluster_shortcode']);
}
/**
* Collect the published spokes whose pillar is $pillar_id.
* Cached in a transient because this runs on a public page that may take
* traffic; busted by aicobr_bust_cluster_cache() whenever a spoke publishes.
*/
private function aicobr_cluster_spokes($pillar_id) {
$pillar_id = absint($pillar_id);
if ($pillar_id <= 0) return [];
$cache_key = 'aicobr_cluster_' . $pillar_id;
$cached = get_transient($cache_key);
if (is_array($cached)) return $cached;
$entries = get_option('aicobr_entries', []);
if (!is_array($entries)) $entries = [];
$spokes = [];
foreach ($entries as $e) {
if (empty($e['cluster_pillar']) || !is_array($e['cluster_pillar'])) continue;
if (absint($e['cluster_pillar']['post_id'] ?? 0) !== $pillar_id) continue;
$sid = absint($e['post_id'] ?? 0);
if ($sid <= 0 || $sid === $pillar_id) continue; // never link a page to itself
$sp = get_post($sid);
if (!$sp || $sp->post_status !== 'publish') continue;
$spokes[$sid] = [ // keyed by id — dedupes if a brief was regenerated
'id' => $sid,
'title' => html_entity_decode(get_the_title($sp), ENT_QUOTES, 'UTF-8'),
'url' => get_permalink($sp),
'excerpt' => html_entity_decode(wp_strip_all_tags(get_the_excerpt($sp)), ENT_QUOTES, 'UTF-8'),
];
}
$spokes = array_values($spokes);
// An EMPTY result is cached briefly, not for 12 hours. ACB generates to
// draft, so the common first-run state is "shortcode placed, nothing
// published yet" — caching that for half a day makes the block look
// broken long after the user publishes. The status-change hook also
// busts it, this is belt-and-braces for edits made outside WP.
set_transient($cache_key, $spokes, empty($spokes) ? 5 * MINUTE_IN_SECONDS : 12 * HOUR_IN_SECONDS);
return $spokes;
}
/**
* Clear the cached cluster block for a pillar. Called on publish so a newly
* generated spoke shows up immediately rather than after the transient
* expires — the "it just appeared" behaviour is the point of the feature.
*/
private function aicobr_bust_cluster_cache($pillar_id) {
$pillar_id = absint($pillar_id);
if ($pillar_id > 0) delete_transient('aicobr_cluster_' . $pillar_id);
}
public function aicobr_cluster_shortcode($atts) {
$atts = shortcode_atts([
'heading' => 'Explore this topic',
'limit' => 0,
], $atts, 'content_cluster');
$pillar_id = get_the_ID();
if (!$pillar_id) return '';
$spokes = $this->aicobr_cluster_spokes($pillar_id);
if (empty($spokes)) {
// Nothing published yet. Visitors see nothing at all — but an editor
// needs to know WHY, because ACB generates to draft and "I added the
// shortcode and nothing happened" is otherwise a dead end. This note
// is only ever rendered for users who can edit posts.
if (current_user_can('edit_posts')) {
$pending = 0;
$entries = get_option('aicobr_entries', []);
if (is_array($entries)) {
foreach ($entries as $e) {
if (absint($e['cluster_pillar']['post_id'] ?? 0) === $pillar_id) $pending++;
}
}
if ($pending > 0) {
return '
AI Content Bridge: '
. esc_html(sprintf(
_n(
'%d post in this cluster is not published yet, so nothing is shown here. Publish it and it will appear. Only you can see this message.',
'%d posts in this cluster are not published yet, so nothing is shown here. Publish them and they will appear. Only you can see this message.',
$pending,
'ai-content-bridge'
),
$pending
)) . '
';
}
}
return ''; // no empty box on a page with no cluster yet
}
$limit = absint($atts['limit']);
if ($limit > 0) $spokes = array_slice($spokes, 0, $limit);
$heading = trim((string) $atts['heading']);
$out = '';
return $out;
}
/** 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')
]);
// AICOBR_CLUSTER_PILLAR_PICKER_2026_08: feeds the Content Strategist's
// "supporting pages for" picker. Read-only search over the site's own
// published pages/posts so the user can choose the pillar their planned
// cluster will link up to. Never writes; the pillar page is never edited.
$this->aicobr_register_route('/pages', [
'methods' => 'GET',
'callback' => [$this, 'get_linkable_pages'],
'permission_callback' => fn() => current_user_can('edit_posts')
]);
$this->aicobr_register_route('/set-featured-image', [
'methods' => 'POST',
'callback' => [$this, 'set_featured_image'],
'permission_callback' => fn() => current_user_can('edit_posts')
]);
// Image-only regeneration — server-to-server: the licence server calls
// this to append a freshly-generated image to an entry's image_history
// (never touches the post text). Secured by the shared generate secret,
// same as /upload-image and /publish.
$this->aicobr_register_route('/append-image', [
'methods' => 'POST',
'callback' => [$this, 'append_image'],
'permission_callback' => [self::class, 'verify_secret']
]);
// Browser → WP → licence server proxy that kicks off an image-only regen
// (mirrors /trigger-generate). The browser can't call the licence server
// directly — the shared secret and licence key never leave the server.
$this->aicobr_register_route('/trigger-regenerate-image', [
'methods' => 'POST',
'callback' => [$this, 'trigger_regenerate_image'],
'permission_callback' => fn() => current_user_can(apply_filters('aicobr_rest_capability', 'manage_options', 'regenerate_image'))
]);
$this->aicobr_register_route('/verify-license', [
'methods' => 'POST',
'callback' => [$this, 'verify_license'],
'permission_callback' => fn() => current_user_can('manage_options')
]);
$this->aicobr_register_route('/register-free', [
'methods' => 'POST',
'callback' => [$this, 'register_free'],
'permission_callback' => fn() => current_user_can('manage_options')
]);
// AICOBR_WIZARD_POLL_2026_07_29 — verification polling + funnel telemetry
// for the onboarding wizard. Both proxy to the licence server so its URL
// stays server-side.
$this->aicobr_register_route('/license-status', [
'methods' => 'GET',
'callback' => [$this, 'license_status'],
'permission_callback' => fn() => current_user_can('manage_options')
]);
$this->aicobr_register_route('/wizard-event', [
'methods' => 'POST',
'callback' => [$this, 'wizard_event'],
'permission_callback' => fn() => current_user_can('manage_options')
]);
$this->aicobr_register_route('/mailpoet-lists', [
'methods' => 'GET',
'callback' => [$this, 'get_mailpoet_lists'],
'permission_callback' => function() { return current_user_can('edit_posts'); },
]);
$this->aicobr_register_route('/tec-status', [
'methods' => 'GET',
'callback' => [$this, 'get_tec_status'],
'permission_callback' => function() { return current_user_can('edit_posts'); },
]);
$this->aicobr_register_route('/newsletter-lists', [
'methods' => 'GET',
'callback' => [$this, 'get_newsletter_lists'],
'permission_callback' => function() { return current_user_can('edit_posts'); },
]);
$this->aicobr_register_route('/lms-data', [
'methods' => 'GET',
'callback' => [$this, 'get_lms_data'],
'permission_callback' => function() { return current_user_can('edit_posts'); },
]);
$this->aicobr_register_route('/extract-style', [
'methods' => 'POST',
'callback' => [$this, 'extract_style'],
'permission_callback' => fn() => current_user_can('edit_posts')
]);
$this->aicobr_register_route('/outline', [
'methods' => 'POST',
'callback' => [$this, 'get_outline'],
'permission_callback' => fn() => current_user_can('edit_posts')
]);
// AI Content Strategist routes (preview / plan / populate).
$this->register_strategist_routes();
}
public function add_admin_menu() {
add_menu_page(
'Content Bridge',
'Content Bridge',
'edit_posts',
'ai-content-bridge',
[$this, 'admin_page'],
'data:image/svg+xml;base64,' . base64_encode('
'),
30
);
}
public function admin_page() {
echo '';
}
public function enqueue_assets($hook) {
if ($hook !== 'toplevel_page_ai-content-bridge') return;
// Keep the cached plan tier in sync with the licence server so paid
// features unlock immediately. Force a refresh when returning from Stripe
// checkout (?upgrade=success) — this runs before the localize below, so
// aicobrConfig.planTier is already correct when the app mounts. Other loads
// refresh at most once per minute (throttled inside the helper).
// Stripe returns the buyer to ?upgrade=success; this is a read-only signal used
// to force a licence-tier refresh. The value is unslashed + sanitized, and a
// third-party payment redirect cannot carry a WordPress nonce.
$upgrade_param = isset($_GET['upgrade']) ? sanitize_text_field(wp_unslash($_GET['upgrade'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only handling of a Stripe return URL (see note above)
$just_upgraded = ($upgrade_param === 'success');
self::refresh_license_tier($just_upgraded);
$asset_file = plugin_dir_path(__FILE__) . 'build/index.asset.php';
if (file_exists($asset_file)) {
$asset = include $asset_file;
$settings = get_option('aicobr_settings', []); // re-read after refresh
wp_enqueue_script('aicobr-react', plugin_dir_url(__FILE__) . 'build/index.js', $asset['dependencies'], $asset['version'], true);
// Self-hosted admin fonts (see fonts/ + aicobr-admin-fonts.css) — no
// third-party font CDN requests from the admin screens.
wp_enqueue_style('aicobr-admin-fonts', plugin_dir_url(__FILE__) . 'aicobr-admin-fonts.css', [], $asset['version']);
wp_enqueue_style('aicobr-style', plugin_dir_url(__FILE__) . 'build/index.css', ['aicobr-admin-fonts'], $asset['version']);
wp_localize_script('aicobr-react', 'aicobrConfig', [
'wizardDismissed' => !empty($settings['_wizard_dismissed']),
'planTier' => $settings['plan_tier'] ?? 'free',
// Real plugin URL (trailing slash) so the React app can resolve
// bundled assets (e.g. the logo) without hardcoding the folder
// name — survives the plugin folder being renamed.
'pluginUrl' => plugin_dir_url(__FILE__),
]);
}
}
public function get_entries() {
$locked = $this->aicobr_lock_entries();
$entries = get_option('aicobr_entries', []);
// Server-authoritative timeout: any entry left 'generating' past the
// threshold is flipped to 'failed' at read time, so a crashed/lost
// pipeline can never leave a card stuck forever — independent of any
// browser being open.
$timeout = 600; // seconds (10 minutes)
$now = time();
$changed = false;
foreach ($entries as &$e) {
$st = $e['status'] ?? '';
if ($st === 'generating' || $st === 'regenerating') {
$started = isset($e['started_ts']) ? intval($e['started_ts']) : 0;
if ($started > 0 && ($now - $started) > $timeout) {
$e['status'] = 'failed';
$e['stage'] = 'failed';
$e['error_message'] = 'Generation timed out. Please retry.';
$e['started_ts'] = null;
$changed = true;
}
}
}
unset($e);
if ($changed) update_option('aicobr_entries', $entries);
if ($locked) $this->aicobr_unlock_entries();
// Attach a correct front-end URL for each entry that has a post — computed
// at read time, NOT persisted, so draft preview links keep a fresh nonce.
// get_permalink()/get_preview_post_link() resolve correctly for ANY post
// type and status, unlike the old hardcoded "/?p={id}" (post-type 'post',
// published-only) the UI used to build.
foreach ($entries as &$e2) {
$pid = isset($e2['post_id']) ? intval($e2['post_id']) : 0;
if ($pid > 0) {
$st = get_post_status($pid);
if ($st) {
if (in_array($st, ['publish', 'private'], true)) {
$e2['view_url'] = get_permalink($pid);
} else {
// Draft/pending preview. get_preview_post_link() can return an
// empty string in REST context, which made the UI fall back to a
// bare "/?p={id}" link that 404s for custom post types like
// tribe_events. Build the canonical preview URL ourselves so it
// always resolves: "/?post_type={type}&p={id}&preview=true".
$preview = get_preview_post_link($pid);
if (empty($preview)) {
$ptype = get_post_type($pid) ?: 'post';
$preview = add_query_arg(
['post_type' => $ptype, 'p' => $pid, 'preview' => 'true'],
home_url('/')
);
}
$e2['view_url'] = $preview;
}
}
}
}
unset($e2);
return rest_ensure_response(['entries' => $entries]);
}
/**
* True if $post_id is referenced by any content-diary entry. Used as a
* fallback ownership check in /publish for posts created before the
* _aicobr_generated meta was stamped: if ACB has the post on a diary entry it is
* genuinely ours, so regeneration is allowed and the meta is backfilled.
*/
/**
* AICOBR_CLUSTER_PILLAR_2026_08
*
* Resolve the pillar page a diary entry's cluster hangs off, for the
* generation prompt. Returns null unless the entry is a spoke AND the
* pillar post still exists AND it is still published — a link to a trashed
* or drafted page is worse than no link, so we silently omit rather than
* emit a dead one.
*
* The permalink is re-resolved live (not read from the stored snapshot) so
* a slug change between planning and generation still produces a correct
* URL. The stored url is kept only as a display value / fallback.
*/
private function aicobr_resolve_cluster_pillar($entry_id) {
if ($entry_id === null || $entry_id === '') return null;
$entries = get_option('aicobr_entries', []);
if (!is_array($entries)) return null;
$entry = null;
foreach ($entries as $e) {
if (isset($e['id']) && (string) $e['id'] === (string) $entry_id) { $entry = $e; break; }
}
if (!$entry || empty($entry['cluster_pillar']) || !is_array($entry['cluster_pillar'])) return null;
$pillar_id = absint($entry['cluster_pillar']['post_id'] ?? 0);
if ($pillar_id <= 0) return null;
$pillar_post = get_post($pillar_id);
if (!$pillar_post || $pillar_post->post_status !== 'publish') {
aicobr_dbg('[ACB] cluster: pillar #' . $pillar_id . ' missing or unpublished — skipping internal link.');
return null;
}
return [
'post_id' => $pillar_id,
'cluster_id' => (string) ($entry['cluster_id'] ?? ''),
'title' => html_entity_decode(get_the_title($pillar_post), ENT_QUOTES, 'UTF-8'),
'url' => get_permalink($pillar_post),
];
}
private function aicobr_post_in_diary($post_id) {
$post_id = intval($post_id);
if ($post_id <= 0) return false;
$entries = get_option('aicobr_entries', []);
if (!is_array($entries)) return false;
foreach ($entries as $e) {
if (isset($e['post_id']) && intval($e['post_id']) === $post_id) {
return true;
}
}
return false;
}
/**
* Cross-request lock for read-modify-write of the aicobr_entries option.
* The option is a single serialised array; concurrent /publish (and start-of-
* generation) requests that each get_option -> modify one entry -> update_option
* will clobber each other's writes (lost update), which is what left a
* regenerated card stuck on "regenerating" when two items finished within ~1s.
* A MySQL named lock serialises those critical sections. GET_LOCK is connection
* scoped and auto-released when the request ends, so a leaked lock self-heals.
* On failure to acquire, the caller proceeds unlocked rather than block a publish.
*/
private function aicobr_entries_lock_name() {
// MySQL lock names max 64 chars; scope per site for multisite safety.
return 'aicobr_entries_' . get_current_blog_id();
}
private function aicobr_lock_entries($timeout = 10) {
global $wpdb;
$got = $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, %d)', $this->aicobr_entries_lock_name(), $timeout));
if ((string) $got !== '1') {
aicobr_dbg('[ACB] entries lock not acquired (got=' . var_export($got, true) . ') — proceeding unlocked');
return false;
}
return true;
}
private function aicobr_unlock_entries() {
global $wpdb;
$wpdb->query($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $this->aicobr_entries_lock_name()));
}
/**
* Find a diary entry by id (preferred), else post_id, else normalised title,
* apply $fields, and persist. If $require_status is given, the entry is only
* updated when its current status is in that list. Returns true if updated.
* The read-modify-write runs under the entries lock with a fresh re-read so a
* concurrent writer cannot clobber the status it sets.
*/
private function aicobr_apply_entry_fields($match, $fields, $require_status = null) {
$locked = $this->aicobr_lock_entries();
try {
$entries = get_option('aicobr_entries', []);
$entry_id = isset($match['entry_id']) && $match['entry_id'] !== '' ? (string) $match['entry_id'] : null;
$post_id = !empty($match['post_id']) ? intval($match['post_id']) : 0;
$title = isset($match['title']) && $match['title'] !== ''
? strtolower(trim(preg_replace('/\s+/', ' ', $match['title']))) : null;
// Resolve the target by PRIORITY, not by "first entry matching anything".
// A title can collide across entries (same title generated twice), so an
// exact entry-id match must always win over a title match — otherwise an
// older same-titled entry hijacks the update and the real one is orphaned.
$targetIdx = -1;
if ($entry_id !== null) {
foreach ($entries as $idx => $e) {
if (isset($e['id']) && (string) $e['id'] === $entry_id) { $targetIdx = $idx; break; }
}
}
if ($targetIdx === -1 && $post_id) {
foreach ($entries as $idx => $e) {
if (isset($e['post_id']) && intval($e['post_id']) === $post_id) { $targetIdx = $idx; break; }
}
}
if ($targetIdx === -1 && $title !== null) {
foreach ($entries as $idx => $e) {
if (strtolower(trim(preg_replace('/\s+/', ' ', $e['title'] ?? ''))) === $title) { $targetIdx = $idx; break; }
}
}
if ($targetIdx === -1) return false;
if (is_array($require_status) && !in_array(($entries[$targetIdx]['status'] ?? ''), $require_status, true)) {
return false; // matched, but not in an updatable state
}
foreach ($fields as $k => $v) { $entries[$targetIdx][$k] = $v; }
update_option('aicobr_entries', $entries);
return true;
} finally {
if ($locked) $this->aicobr_unlock_entries();
}
}
public function save_entries($request) {
$data = $request->get_json_params() ?: $request->get_body_params();
// Handle manual reset of stuck item
if (isset($data['action']) && $data['action'] === 'reset_stuck' && !empty($data['entry_id'])) {
$entryId = absint($data['entry_id']);
$locked = $this->aicobr_lock_entries();
$entries = get_option('aicobr_entries', []);
foreach ($entries as &$entry) {
if ($entry['id'] == $entryId) {
$entry['status'] = 'pending';
$entry['generationStartTime'] = null;
break;
}
}
update_option('aicobr_entries', $entries);
if ($locked) $this->aicobr_unlock_entries();
return rest_ensure_response(['success' => true, 'message' => 'Item reset']);
}
// Normal full entries update — MERGE so the client can never overwrite
// server-owned fields (status, stage, post_id, etc.). The browser owns the
// editable content fields; the server owns the generation lifecycle.
if (isset($data['entries'])) {
$locked = $this->aicobr_lock_entries();
$existing = get_option('aicobr_entries', []);
$byId = [];
foreach ($existing as $e) { if (isset($e['id'])) $byId[(string) $e['id']] = $e; }
// Fields the server stamps during publish/finalise and the client must
// never overwrite with stale local state. The browser owns editable
// content fields; the server owns the generation lifecycle AND the
// record of what was produced. mailpoet_newsletter_id/tnp_newsletter_id
// were missing here, so the entries-write-back useEffect could race the
// publish and strip a freshly-stamped newsletter id (dropping the
// "View in Newsletter/MailPoet" button).
//
// image_history / featured_image / featured_image_id are ALSO
// server-owned: they're written only by the publish path,
// set_featured_image, and append_image (image-only regeneration). The
// client merely mirrors them optimistically. Without protecting them,
// the ~2.5s entries POST-back races an image regen and overwrites the
// freshly-appended image with the client's stale copy — the new image
// vanishes from the diary card and the regen's completion (which keys
// off image_history growing) never fires. Same last-writer-wins class
// as the 2026-07-06 thumbnail fix; this closes it for the append path.
// AICOBR_CLUSTER_PILLAR_2026_08: cluster_id / cluster_role /
// cluster_pillar are written by strategist_populate and read by the
// publish path to build the internal link. They are structural, not
// editable content, so the browser must never write them back — the
// entries POST-back would otherwise strip the cluster from every
// entry the moment the diary re-renders, silently killing the
// interlinking. Same last-writer-wins class as image_history.
$serverOwned = ['status', 'stage', 'started_ts', 'post_id', 'error_message', 'updated_at', 'generationStartTime', 'mailpoet_newsletter_id', 'tnp_newsletter_id', 'excerpt', 'post_type', 'image_history', 'featured_image', 'featured_image_id', 'seo_report', 'cluster_id', 'cluster_role', 'cluster_pillar'];
$merged = array_map(function ($e) use ($byId, $serverOwned) {
$id = isset($e['id']) ? (string) $e['id'] : null;
if ($id !== null && isset($byId[$id])) {
foreach ($serverOwned as $f) {
if (array_key_exists($f, $byId[$id])) { $e[$f] = $byId[$id][$f]; }
}
}
return $e;
}, $data['entries']);
update_option('aicobr_entries', $merged);
if ($locked) $this->aicobr_unlock_entries();
}
return rest_ensure_response(['success' => true]);
}
/**
* Delete a diary entry. Optionally also move its generated post to TRASH.
*
* AICOBR_DELETE_TRASHES_POST_2026_08
* Previously the post was orphaned: still published, no longer tracked by
* ACB. `trash_post=1` (opt-in per deletion, never a stored setting) moves it
* to the WordPress trash — trash, never wp_delete_post(force), so the user
* can always recover content they didn't mean to unpublish.
*
* Guarded: only posts ACB actually created are trashable, so a brief that
* was pointed at a pre-existing page can never take that page down.
*/
public function delete_entry($request) {
$id = absint($request->get_param('id'));
$trash_post = filter_var($request->get_param('trash_post'), FILTER_VALIDATE_BOOLEAN);
$locked = $this->aicobr_lock_entries();
$entries = get_option('aicobr_entries', []);
if (!is_array($entries)) $entries = [];
// Capture the entry before removing it — we need its post_id and pillar.
$target = null;
foreach ($entries as $e) {
if (isset($e['id']) && $e['id'] == $id) { $target = $e; break; }
}
$entries = array_filter($entries, fn($entry) => $entry['id'] != $id);
update_option('aicobr_entries', array_values($entries));
if ($locked) $this->aicobr_unlock_entries();
$trashed = false;
if ($trash_post && $target) {
$pid = absint($target['post_id'] ?? 0);
if ($pid > 0 && get_post($pid) && self::aicobr_is_generated_post($pid)) {
$trashed = (bool) wp_trash_post($pid);
aicobr_dbg('[ACB] delete_entry: post ' . $pid . ' trashed=' . ($trashed ? '1' : '0'));
}
}
// The removed entry may have been a spoke — refresh its pillar's block.
if ($target && !empty($target['cluster_pillar']['post_id'])) {
$this->aicobr_bust_cluster_cache($target['cluster_pillar']['post_id']);
}
return rest_ensure_response(['success' => true, 'trashed' => $trashed]);
}
public function get_logs($request) {
$logs = get_option('aicobr_logs', []);
$per_page = absint($request->get_param('per_page') ?? 100);
return rest_ensure_response(array_slice(array_reverse($logs), 0, $per_page));
}
public function get_settings() {
// Opportunistically resync the cached tier from the licence server
// (throttled to once/minute inside the helper) so the Dashboard's
// settings poll transparently reflects upgrades, renewals, downgrades
// and cancellations without a manual key re-verify.
self::refresh_license_tier();
$settings = get_option('aicobr_settings', [
'brand_voice' => '{"voice": "Professional yet approachable"}',
'tone' => 'friendly'
]);
// Never return the raw licence key — expose a masked hint + boolean instead.
if (!empty($settings['license_key'])) {
$key = (string) $settings['license_key'];
$settings['license_key_set'] = true;
$settings['license_key_hint'] = str_repeat('*', max(0, strlen($key) - 4)) . substr($key, -4);
unset($settings['license_key']);
} else {
$settings['license_key_set'] = false;
}
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);
}
/**
* AICOBR_CLUSTER_PILLAR_PICKER_2026_08
*
* Search the site's own published pages and posts so the Content Strategist
* can offer them as the "pillar" a planned cluster will support. Returns the
* live permalink because that URL is snapshotted onto each planned brief and
* handed to the generation prompt — the spoke links up to a page that already
* exists, which is what makes v1 possible without ever editing that page.
*
* Read-only. Requires edit_posts, same as /post-types and /acf-fields.
*
* `excerpt` carries a trimmed slice of the page's own copy; the licence
* server feeds it into the planning prompt so the spokes it plans genuinely
* support THIS page rather than being guessed from the title alone.
*/
public function get_linkable_pages($request) {
$search = sanitize_text_field((string) $request->get_param('s'));
$per_page = absint($request->get_param('per_page')) ?: 20;
$per_page = max(1, min(50, $per_page));
$args = [
'post_type' => ['page', 'post'],
'post_status' => 'publish',
'posts_per_page' => $per_page,
'orderby' => 'title',
'order' => 'ASC',
'suppress_filters' => false,
// Skip the password-protected: their content can't be relied on as a
// public landing target for an internal link.
'has_password' => false,
];
if ($search !== '') {
$args['s'] = $search;
// Relevance beats alphabetical once the user is actually searching.
$args['orderby'] = 'relevance';
}
$posts = get_posts($args);
$result = [];
foreach ($posts as $p) {
// AICOBR_CLUSTER_PILLAR_PICKER_2026_08
// ACB-generated posts ARE offered. An earlier revision skipped them
// on the theory that a cluster under an ACB-written page was
// circular — that was wrong. Service Page is one of ACB's own
// content types, so generating a service page and then building its
// supporting cluster is the intended workflow, not an edge case.
// It is also how a user creates a pillar they do not already have.
// The only exclusion that matters is self-linking, which is handled
// at render time in aicobr_cluster_spokes().
$excerpt = has_excerpt($p) ? $p->post_excerpt : $p->post_content;
$excerpt = wp_strip_all_tags(strip_shortcodes((string) $excerpt), true);
$excerpt = trim(preg_replace('/\s+/', ' ', $excerpt));
$result[] = [
'id' => (int) $p->ID,
'title' => html_entity_decode(get_the_title($p), ENT_QUOTES, 'UTF-8'),
'url' => get_permalink($p),
'type' => $p->post_type,
'excerpt' => mb_substr($excerpt, 0, 2000),
];
}
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'] ?? ''),
];
// AICOBR_CLUSTER_PILLAR_2026_08
// Resolve the cluster pillar from the STORED diary entry, never from the
// request body. The browser has no business asserting which page a post
// links to, and the entries write-back doesn't carry these fields
// (they're server-owned). Re-resolve the permalink live so a slug change
// between planning and generation produces a correct link rather than
// relying on a WP redirect from the snapshotted URL.
$cluster_pillar = $this->aicobr_resolve_cluster_pillar($payload['entry_id']);
if ($cluster_pillar) {
$payload['cluster_pillar'] = $cluster_pillar;
aicobr_dbg('[ACB] cluster: linking up to pillar #' . $cluster_pillar['post_id'] . ' — ' . $cluster_pillar['url']);
}
// Inject active writing style profile if one is set
$active_id = $settings['active_style_profile_id'] ?? '';
$profiles = $settings['writing_style_profiles'] ?? [];
aicobr_dbg('[ACB] active_style_profile_id: ' . $active_id);
aicobr_dbg('[ACB] writing_style_profiles count: ' . count($profiles));
aicobr_dbg('[ACB] profiles raw: ' . print_r($profiles, true));
if ( !empty($active_id) && !empty($profiles) ) {
foreach ( $profiles as $profile ) {
aicobr_dbg('[ACB] checking profile id: ' . ($profile['id'] ?? 'NO_ID'));
if ( ($profile['id'] ?? '') === $active_id && !empty($profile['extracted']) ) {
$payload['style_profile'] = [
'name' => $profile['name'] ?? '',
'profile' => $profile['extracted'],
];
aicobr_dbg('[ACB] Style profile injected: ' . ($profile['name'] ?? 'unnamed'));
break;
}
}
}
$license_server_url = self::get_server_url();
// Server-to-server POST — no CORS, no browser involvement
$response = wp_remote_post("{$license_server_url}/api/generate", [
'timeout' => 30, // wait up to 30s for the server to accept the job
'headers' => [
'Content-Type' => 'application/json',
'x-generate-secret' => $generate_secret,
],
'body' => wp_json_encode($payload),
]);
if (is_wp_error($response)) {
aicobr_dbg('[ACB] trigger_generate: wp_remote_post failed: ' . $response->get_error_message());
return new WP_Error(
'server_unreachable',
'Could not reach the generation server. Please try again.',
['status' => 502]
);
}
$status_code = wp_remote_retrieve_response_code($response);
$body = json_decode(wp_remote_retrieve_body($response), true);
if ($status_code === 402) {
return new WP_Error('no_credits', $body['error'] ?? 'Insufficient credits.', [
'status' => 402,
'credits_remaining' => $body['credits_remaining'] ?? 0,
'plan_tier' => $settings['plan_tier'] ?? 'free',
]);
}
if ($status_code === 403) {
$code = $body['code'] ?? 'invalid_license';
return new WP_Error($code, $body['error'] ?? 'Access denied.', [
'status' => 403,
'plan_tier' => $settings['plan_tier'] ?? 'free',
]);
}
if ($status_code !== 200 || empty($body['success'])) {
$msg = $body['error'] ?? 'Generation server returned an unexpected error.';
aicobr_dbg("[ACB] trigger_generate: server error {$status_code}: {$msg}");
return new WP_Error('generate_failed', $msg, ['status' => 500]);
}
// Job accepted — mark the diary entry as generating with an authoritative
// server-side start time. This (not browser localStorage) is the source of
// truth the polling UI and the read-time timeout both rely on.
$is_regen = !empty($payload['post_id']);
$this->aicobr_apply_entry_fields(
[
'entry_id' => $data['entry_id'] ?? null,
'title' => $payload['title'],
'post_id' => $payload['post_id'],
],
[
'status' => $is_regen ? 'regenerating' : 'generating',
'stage' => 'queued',
'started_ts' => time(),
'error_message' => '',
]
);
// Job accepted — return success to React so it can update the UI
return rest_ensure_response([
'success' => true,
'message' => 'Generation started.',
'credits_deducted' => $body['credits_deducted'] ?? null,
]);
}
/**
* POST /ai-content/v1/trigger-regenerate-image
*
* Kicks off an IMAGE-ONLY regeneration for an existing entry. Mirrors
* trigger_generate: the browser calls this WP route, and WP forwards to the
* licence server's /api/regenerate-image server-to-server, so the shared
* secret and licence key never travel through the browser. Resolves the
* entry's title + post_id from stored state (the client only sends entry_id
* plus the optional "describe the new image" prompt/style), so the licence
* server has what it needs to generate and append the image.
*/
public function trigger_regenerate_image($request) {
$data = $request->get_json_params();
$entry_id = isset($data['entry_id']) && $data['entry_id'] !== '' ? (string) $data['entry_id'] : '';
if ($entry_id === '') {
return new WP_Error('missing_entry', 'entry_id is required.', ['status' => 400]);
}
// Credentials read server-side only — never from the request/browser.
$settings = get_option('aicobr_settings', []);
$license_key = $settings['license_key'] ?? '';
$generate_secret = self::get_secret();
if (empty($license_key)) {
return new WP_Error('no_license', 'No license key configured. Please visit Settings.', ['status' => 403]);
}
// Resolve title + post_id from the stored entry so the licence server can
// generate an appropriately-titled image and append it to the right post.
$entries = get_option('aicobr_entries', []);
$entry = null;
foreach ($entries as $e) {
if (isset($e['id']) && (string) $e['id'] === $entry_id) { $entry = $e; break; }
}
if (!$entry) {
return new WP_Error('not_found', 'Entry not found.', ['status' => 404]);
}
$payload = [
'license_key' => $license_key,
'entry_id' => $entry_id,
'domain' => !empty($settings['domain']) ? $settings['domain'] : wp_parse_url(home_url(), PHP_URL_HOST),
'title' => sanitize_text_field($entry['title'] ?? ''),
'post_id' => !empty($entry['post_id']) ? intval($entry['post_id']) : 0,
// The "describe the new image" box (optional). Falls back to the
// entry's stored image_style so a silent regen still matches the look.
'image_prompt' => sanitize_text_field($data['image_prompt'] ?? ''),
'image_style' => sanitize_text_field($data['image_style'] ?? ($entry['image_style'] ?? 'professional')),
'serp_gl' => sanitize_text_field($settings['serp_gl'] ?? ''),
// Auto-active with easy revert via the picker (see image-regen scope).
'set_active' => true,
];
$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 ===
// === CLUSTER INTERNAL LINK ===
// AICOBR_CLUSTER_PILLAR_2026_08
// Record whether the generated article really did link up to its pillar. We
// COUNT the link in the finished content rather than assuming the model
// complied — the Monthly Report figure has to be a fact, not an intention.
// Stored as one meta key, which also serves as the idempotency marker if a
// re-generation path is ever added.
$cluster_pillar_pub = $this->aicobr_resolve_cluster_pillar($data['entry_id'] ?? null);
if ($cluster_pillar_pub) {
$pillar_url = $cluster_pillar_pub['url'];
// Match the href regardless of trailing-slash or entity-encoded output.
$needle = rtrim($pillar_url, '/');
$haystack = html_entity_decode((string) $content, ENT_QUOTES, 'UTF-8');
$link_count = substr_count($haystack, 'href="' . $needle)
+ substr_count($haystack, "href='" . $needle);
update_post_meta($updated_id, '_aicobr_cluster_links', wp_json_encode([
'cluster_id' => sanitize_text_field($cluster_pillar_pub['cluster_id'] ?? ''),
'pillar_id' => (int) $cluster_pillar_pub['post_id'],
'pillar_url' => esc_url_raw($pillar_url),
'count' => (int) $link_count,
'linked_at' => current_time('mysql'),
]));
aicobr_dbg("=== CLUSTER LINK: {$link_count} link(s) to pillar #{$cluster_pillar_pub['post_id']} ===");
// AICOBR_CLUSTER_SHORTCODE_2026_08 — this spoke has just gone live, so
// the pillar's [content_cluster] block is now stale. Clear it immediately
// rather than waiting out the transient: the whole point of the block is
// that new spokes appear in it as they publish.
$this->aicobr_bust_cluster_cache($cluster_pillar_pub['post_id']);
}
// === END CLUSTER INTERNAL LINK ===
// === 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('