wrapper, deliberately: the step-card grouping in
* AICOBR_Content_Formatter::steps() relies on step bodies containing no
* nested
, so a wp-block-image div dropped inside a "### Step 3" section
* silently breaks the numbered cards.
*
* Attributes are limited to src / alt / class / width / height, all of which
* wp_kses_allowed_html('post') permits, so the markup survives both the
* sanitiser here and any later save by a user without unfiltered_html.
*/
private static function aicobr_build_figure($attachment_id, $alt, $caption, $layout) {
$url = wp_get_attachment_url($attachment_id);
if (!$url) return '';
$layouts = ['band', 'inline-left', 'inline-right'];
if (!in_array($layout, $layouts, true)) $layout = 'band';
$w = $h = 0;
$meta = wp_get_attachment_metadata($attachment_id);
if (is_array($meta)) {
$w = absint($meta['width'] ?? 0);
$h = absint($meta['height'] ?? 0);
}
$dim = ($w && $h) ? ' width="' . $w . '" height="' . $h . '"' : '';
$out = '
';
$out .= ' ';
if ($caption !== '') {
$out .= '' . esc_html($caption) . ' ';
}
$out .= ' ';
return $out;
}
/**
* Locate every H2 in rendered content, with byte offsets.
* Returns [ ['text'=>..., 'start'=>int, 'end'=>int], ... ] in document order.
*/
private static function aicobr_find_headings($html) {
$out = [];
if (!preg_match_all('/
]*>(.*?)<\/h2>/is', (string) $html, $m, PREG_OFFSET_CAPTURE | PREG_SET_ORDER)) {
return $out;
}
foreach ($m as $hit) {
$out[] = [
'text' => trim(wp_strip_all_tags($hit[1][0])),
'start' => $hit[0][1],
'end' => $hit[0][1] + strlen($hit[0][0]),
];
}
return $out;
}
/** Comparable form of a heading — case, punctuation and spacing collapsed. */
private static function aicobr_heading_key($s) {
$s = html_entity_decode(wp_strip_all_tags((string) $s), ENT_QUOTES, 'UTF-8');
$s = strtolower($s);
$s = preg_replace('/[^\p{L}\p{N}\s]/u', ' ', $s);
return trim(preg_replace('/\s+/', ' ', $s));
}
/**
* AICOBR_INBODY_FLOAT_ANCHOR_2026_08
*
* Where inside a section a floated figure may safely be inserted.
*
* A float is wrapped by the boxes that FOLLOW it, and several things in an
* ACB article cannot do that job:
* - .acb-steps is display:grid and .acb-faq / .acb-tablewrap use overflow,
* so each establishes its own formatting context and sits BESIDE the
* float rather than flowing around it;
* - .acb-callout and .acb-takeaways render a background and border that
* would slide underneath the float.
* So the figure has to go after any component that opens the section, and
* before the prose.
*
* This must not assume tags exist. Classic-editor content stores
* paragraphs as bare text between block elements and lets wpautop() create
* the
at render time — a real ACB landing page can contain zero
in
* post_content. Rather than hunting for paragraphs, this skips forward past
* any leading block components and
rules and returns the first offset
* that is not one, which is correct for both content shapes.
*
* Returns an offset in [$from, $to], never false: with nothing to skip it
* returns $from, which is the position directly under the heading.
*/
private static function aicobr_prose_anchor_offset($html, $from, $to) {
$offset = $from;
while ($offset < $to) {
// Skip whitespace and stray newlines between blocks.
while ($offset < $to && preg_match('/\s/', $html[$offset])) $offset++;
if ($offset >= $to) break;
if ($html[$offset] !== '<') break; // bare text — prose starts here
// A horizontal rule is a section divider, not prose to wrap.
if (preg_match('/^
]*>/i', substr($html, $offset, 40), $m)) {
$offset += strlen($m[0]);
continue;
}
// A leading block component: skip to just past its matching close.
if (preg_match('/^<(div|table|figure|ul|ol|blockquote|pre)\b[^>]*>/i', substr($html, $offset, 200), $m)) {
$tag = strtolower($m[1]);
$depth = 0;
$scan = $offset;
$end = false;
while ($scan < $to && preg_match('/<(\/?)' . $tag . '\b[^>]*>/i', $html, $t, PREG_OFFSET_CAPTURE, $scan)) {
$pos = $t[0][1];
if ($pos >= $to) break;
$depth += ($t[1][0] === '/') ? -1 : 1;
$scan = $pos + strlen($t[0][0]);
if ($depth === 0) { $end = $scan; break; }
}
if ($end === false) break; // unbalanced — do not try to be clever
$offset = $end;
continue;
}
break; // a heading,
, , or anything else inline: prose
}
// Walked the whole section without finding prose — it is entirely
// components (an FAQ block, a steps grid). There is nothing to wrap
// either way, so put the figure under the heading rather than stranding
// it against the next one; the components now clear the float, so it
// reads as a standalone image introducing the section.
while ($offset < $to && preg_match('/\s/', $html[$offset])) $offset++;
if ($offset >= $to) return $from;
return min($offset, $to);
}
/**
* Insert $figure into $html at the requested anchor.
*
* Returns the new HTML, or WP_Error when the anchor cannot be located. It
* NEVER guesses: a wrong placement is worse than no placement, because the
* user has to find and undo it by hand.
*
* Positions:
* before-heading — immediately before the matched
* after-intro — after the first inside that section
* end-of-section — immediately before the NEXT , or at the end of the
* section content (inside the .acb-content wrapper when
* one is present, so the figure stays scoped and styled)
*/
private static function aicobr_insert_figure($html, $figure, $section_heading, $position) {
$html = (string) $html;
if ($figure === '') return new WP_Error('no_figure', 'Could not build the image markup.');
$headings = self::aicobr_find_headings($html);
if (empty($headings)) {
return new WP_Error('no_headings', 'This post has no section headings to place an image against.');
}
$want = self::aicobr_heading_key($section_heading);
$idx = -1;
foreach ($headings as $i => $h) {
if (self::aicobr_heading_key($h['text']) === $want) { $idx = $i; break; }
}
if ($idx === -1) {
return new WP_Error('no_section', 'That section is no longer in this post, so ACB will not guess where the image should go.');
}
$sectionStart = $headings[$idx]['end'];
$sectionEnd = isset($headings[$idx + 1]) ? $headings[$idx + 1]['start'] : strlen($html);
if ($position === 'before-heading') {
$at = $headings[$idx]['start'];
} elseif ($position === 'after-heading' || $position === 'after-intro') {
// AICOBR_INBODY_AFTER_HEADING_2026_08
// Directly beneath the heading, ahead of the section's prose, so the
// whole section body wraps around the float.
//
// AICOBR_INBODY_FLOAT_ANCHOR_2026_08: anchored to the first
// TOP-LEVEL paragraph rather than to the heading itself. A float is
// wrapped by what follows it, and a section that opens with a
// component — steps (display:grid), an FAQ or table wrapper
// (overflow), a callout or takeaways box (background and border
// rendering under the float) — gives it nothing that can wrap. The
// image still floats, so it looks aligned but stranded.
//
// In the usual case (heading, then a paragraph) this is the same
// position. It only differs when a component comes first.
//
// 'after-intro' is the legacy key for the same intent and is handled
// here rather than migrated, so images placed before this change
// land in the improved position too.
$at = self::aicobr_prose_anchor_offset($html, $sectionStart, $sectionEnd);
} else { // end-of-section
$at = $sectionEnd;
// When the section runs to the end of the document, stay INSIDE the
// .acb-content wrapper — outside it the figure loses every scoped
// style and lands after the container the theme is laying out.
if ($at >= strlen($html)) {
$close = strrpos($html, ' ');
if ($close !== false && trim(substr($html, $close + 6)) === '') {
$at = $close;
}
}
}
return substr($html, 0, $at) . "\n" . $figure . "\n" . substr($html, $at);
}
private function aicobr_str_cap($s, $len) {
$s = (string) $s;
return function_exists('mb_substr') ? mb_substr($s, 0, $len) : substr($s, 0, $len);
}
private function aicobr_sanitise_image_suggestions($raw) {
if (empty($raw) || !is_array($raw)) return [];
$layouts = ['band', 'inline-left', 'inline-right'];
// AICOBR_INBODY_POSITION_2026_08 — mirrors POSITION_LAYOUTS in
// lib/image-suggestions.js. Only 'after-intro' survives: the other two
// positions leave no body text after the figure inside that section, and
// the next heading carries `clear: both`, so a floated image ends up
// alone on its own row with a blank column beside it.
//
// Legacy entries may still carry the old values; aicobr_insert_figure()
// still understands them, so anything already stored keeps working. New
// suggestions are normalised to the one position that reads correctly.
$positions = ['after-heading'];
$allowed = [
'after-heading' => ['band', 'inline-left', 'inline-right'],
];
$out = [];
$seen = [];
foreach ($raw as $item) {
if (!is_array($item)) continue;
$heading = sanitize_text_field($item['section_heading'] ?? '');
$desc = sanitize_textarea_field($item['description'] ?? '');
if ($heading === '' || $desc === '') continue;
// One suggestion per section, matching the server's dedupe rule.
$key = strtolower(trim(preg_replace('/\s+/', ' ', $heading)));
if (isset($seen[$key])) continue;
$seen[$key] = true;
// AICOBR_INBODY_POSITION_DEFAULT_2026_08
// Fall back to 'after-heading' — the only position still produced,
// and crucially the only key present in $allowed below.
//
// This previously defaulted to the retired 'end-of-section'. That
// value is absent from $allowed, so the very next line indexed a
// missing key and passed null to in_array(), which is a FATAL
// TypeError on PHP 8 — taking down the whole publish handler and
// stranding the entry mid-generation. It never fired only because
// the licence server always sends a valid position; any older
// server, a malformed payload, or an omitted key would have hit it.
$position = sanitize_key($item['position'] ?? '');
if (!in_array($position, $positions, true)) $position = 'after-heading';
$layout = sanitize_text_field($item['layout'] ?? '');
if (!in_array($layout, $layouts, true)) $layout = 'band';
if (!in_array($layout, $allowed[$position], true)) $layout = $allowed[$position][0];
$alt = sanitize_text_field($item['alt_text'] ?? '');
$out[] = [
'id' => 'sug_' . substr(md5($key . '|' . $desc), 0, 12),
'section_heading' => $this->aicobr_str_cap($heading, 200),
'position' => $position,
'layout' => $layout,
'description' => $this->aicobr_str_cap($desc, 400),
// Alt text is mandatory for accessibility; fall back to the
// description rather than storing an image with none.
'alt_text' => $this->aicobr_str_cap($alt !== '' ? $alt : $desc, 250),
'caption' => $this->aicobr_str_cap(sanitize_text_field($item['caption'] ?? ''), 200),
'used' => false,
];
if (count($out) >= 3) break; // SUGGESTIONS_MAX
}
return $out;
}
private function aicobr_post_in_diary($post_id) {
$post_id = intval($post_id);
if ($post_id <= 0) return false;
$entries = get_option('aicobr_entries', []);
if (!is_array($entries)) return false;
foreach ($entries as $e) {
if (isset($e['post_id']) && intval($e['post_id']) === $post_id) {
return true;
}
}
return false;
}
/**
* Cross-request lock for read-modify-write of the aicobr_entries option.
* The option is a single serialised array; concurrent /publish (and start-of-
* generation) requests that each get_option -> modify one entry -> update_option
* will clobber each other's writes (lost update), which is what left a
* regenerated card stuck on "regenerating" when two items finished within ~1s.
* A MySQL named lock serialises those critical sections. GET_LOCK is connection
* scoped and auto-released when the request ends, so a leaked lock self-heals.
* On failure to acquire, the caller proceeds unlocked rather than block a publish.
*/
private function aicobr_entries_lock_name() {
// MySQL lock names max 64 chars; scope per site for multisite safety.
return 'aicobr_entries_' . get_current_blog_id();
}
private function aicobr_lock_entries($timeout = 10) {
global $wpdb;
$got = $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, %d)', $this->aicobr_entries_lock_name(), $timeout));
if ((string) $got !== '1') {
aicobr_dbg('[ACB] entries lock not acquired (got=' . var_export($got, true) . ') — proceeding unlocked');
return false;
}
return true;
}
private function aicobr_unlock_entries() {
global $wpdb;
$wpdb->query($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $this->aicobr_entries_lock_name()));
}
/**
* Find a diary entry by id (preferred), else post_id, else normalised title,
* apply $fields, and persist. If $require_status is given, the entry is only
* updated when its current status is in that list. Returns true if updated.
* The read-modify-write runs under the entries lock with a fresh re-read so a
* concurrent writer cannot clobber the status it sets.
*/
private function aicobr_apply_entry_fields($match, $fields, $require_status = null) {
$locked = $this->aicobr_lock_entries();
try {
$entries = get_option('aicobr_entries', []);
$entry_id = isset($match['entry_id']) && $match['entry_id'] !== '' ? (string) $match['entry_id'] : null;
$post_id = !empty($match['post_id']) ? intval($match['post_id']) : 0;
$title = isset($match['title']) && $match['title'] !== ''
? strtolower(trim(preg_replace('/\s+/', ' ', $match['title']))) : null;
// Resolve the target by PRIORITY, not by "first entry matching anything".
// A title can collide across entries (same title generated twice), so an
// exact entry-id match must always win over a title match — otherwise an
// older same-titled entry hijacks the update and the real one is orphaned.
$targetIdx = -1;
if ($entry_id !== null) {
foreach ($entries as $idx => $e) {
if (isset($e['id']) && (string) $e['id'] === $entry_id) { $targetIdx = $idx; break; }
}
}
if ($targetIdx === -1 && $post_id) {
foreach ($entries as $idx => $e) {
if (isset($e['post_id']) && intval($e['post_id']) === $post_id) { $targetIdx = $idx; break; }
}
}
if ($targetIdx === -1 && $title !== null) {
foreach ($entries as $idx => $e) {
if (strtolower(trim(preg_replace('/\s+/', ' ', $e['title'] ?? ''))) === $title) { $targetIdx = $idx; break; }
}
}
if ($targetIdx === -1) return false;
if (is_array($require_status) && !in_array(($entries[$targetIdx]['status'] ?? ''), $require_status, true)) {
return false; // matched, but not in an updatable state
}
foreach ($fields as $k => $v) { $entries[$targetIdx][$k] = $v; }
update_option('aicobr_entries', $entries);
return true;
} finally {
if ($locked) $this->aicobr_unlock_entries();
}
}
public function save_entries($request) {
$data = $request->get_json_params() ?: $request->get_body_params();
// Handle manual reset of stuck item
if (isset($data['action']) && $data['action'] === 'reset_stuck' && !empty($data['entry_id'])) {
$entryId = absint($data['entry_id']);
$locked = $this->aicobr_lock_entries();
$entries = get_option('aicobr_entries', []);
foreach ($entries as &$entry) {
if ($entry['id'] == $entryId) {
$entry['status'] = 'pending';
$entry['generationStartTime'] = null;
break;
}
}
update_option('aicobr_entries', $entries);
if ($locked) $this->aicobr_unlock_entries();
return rest_ensure_response(['success' => true, 'message' => 'Item reset']);
}
// Normal full entries update — MERGE so the client can never overwrite
// server-owned fields (status, stage, post_id, etc.). The browser owns the
// editable content fields; the server owns the generation lifecycle.
if (isset($data['entries'])) {
$locked = $this->aicobr_lock_entries();
$existing = get_option('aicobr_entries', []);
$byId = [];
foreach ($existing as $e) { if (isset($e['id'])) $byId[(string) $e['id']] = $e; }
// Fields the server stamps during publish/finalise and the client must
// never overwrite with stale local state. The browser owns editable
// content fields; the server owns the generation lifecycle AND the
// record of what was produced. mailpoet_newsletter_id/tnp_newsletter_id
// were missing here, so the entries-write-back useEffect could race the
// publish and strip a freshly-stamped newsletter id (dropping the
// "View in Newsletter/MailPoet" button).
//
// image_history / featured_image / featured_image_id are ALSO
// server-owned: they're written only by the publish path,
// set_featured_image, and append_image (image-only regeneration). The
// client merely mirrors them optimistically. Without protecting them,
// the ~2.5s entries POST-back races an image regen and overwrites the
// freshly-appended image with the client's stale copy — the new image
// vanishes from the diary card and the regen's completion (which keys
// off image_history growing) never fires. Same last-writer-wins class
// as the 2026-07-06 thumbnail fix; this closes it for the append path.
// AICOBR_CLUSTER_PILLAR_2026_08: cluster_id / cluster_role /
// cluster_pillar are written by strategist_populate and read by the
// publish path to build the internal link. They are structural, not
// editable content, so the browser must never write them back — the
// entries POST-back would otherwise strip the cluster from every
// entry the moment the diary re-renders, silently killing the
// interlinking. Same last-writer-wins class as image_history.
// AICOBR_INBODY_IMAGES_2026_08: image_suggestions is stamped by the
// publish path from the licence server's payload and mutated only by
// the server-side routes, so it is server-owned for the same reason.
// AICOBR_APPROVAL_2026_08: approved / approved_at / approved_by are
// written only by /approve-entry under this same lock. Left off this
// list, the diary's periodic write-back would post a stale local copy
// and silently un-approve a brief the user had just signed off —
// exactly the race that stripped freshly-appended images.
$serverOwned = ['status', 'stage', 'started_ts', 'post_id', 'error_message', 'updated_at', 'generationStartTime', 'mailpoet_newsletter_id', 'tnp_newsletter_id', 'excerpt', 'post_type', 'image_history', 'featured_image', 'featured_image_id', 'seo_report', 'cluster_id', 'cluster_role', 'cluster_pillar', 'image_suggestions', 'approved', 'approved_at', 'approved_by'];
$merged = array_map(function ($e) use ($byId, $serverOwned) {
$id = isset($e['id']) ? (string) $e['id'] : null;
if ($id !== null && isset($byId[$id])) {
foreach ($serverOwned as $f) {
if (array_key_exists($f, $byId[$id])) { $e[$f] = $byId[$id][$f]; }
}
}
return $e;
}, $data['entries']);
update_option('aicobr_entries', $merged);
if ($locked) $this->aicobr_unlock_entries();
}
return rest_ensure_response(['success' => true]);
}
/**
* Delete a diary entry. Optionally also move its generated post to TRASH.
*
* AICOBR_DELETE_TRASHES_POST_2026_08
* Previously the post was orphaned: still published, no longer tracked by
* ACB. `trash_post=1` (opt-in per deletion, never a stored setting) moves it
* to the WordPress trash — trash, never wp_delete_post(force), so the user
* can always recover content they didn't mean to unpublish.
*
* Guarded: only posts ACB actually created are trashable, so a brief that
* was pointed at a pre-existing page can never take that page down.
*/
public function delete_entry($request) {
$id = absint($request->get_param('id'));
$trash_post = filter_var($request->get_param('trash_post'), FILTER_VALIDATE_BOOLEAN);
$locked = $this->aicobr_lock_entries();
$entries = get_option('aicobr_entries', []);
if (!is_array($entries)) $entries = [];
// Capture the entry before removing it — we need its post_id and pillar.
$target = null;
foreach ($entries as $e) {
if (isset($e['id']) && $e['id'] == $id) { $target = $e; break; }
}
$entries = array_filter($entries, fn($entry) => $entry['id'] != $id);
update_option('aicobr_entries', array_values($entries));
if ($locked) $this->aicobr_unlock_entries();
$trashed = false;
if ($trash_post && $target) {
$pid = absint($target['post_id'] ?? 0);
if ($pid > 0 && get_post($pid) && self::aicobr_is_generated_post($pid)) {
$trashed = (bool) wp_trash_post($pid);
aicobr_dbg('[ACB] delete_entry: post ' . $pid . ' trashed=' . ($trashed ? '1' : '0'));
}
}
// The removed entry may have been a spoke — refresh its pillar's block.
if ($target && !empty($target['cluster_pillar']['post_id'])) {
$this->aicobr_bust_cluster_cache($target['cluster_pillar']['post_id']);
}
return rest_ensure_response(['success' => true, 'trashed' => $trashed]);
}
public function get_logs($request) {
$logs = get_option('aicobr_logs', []);
$per_page = absint($request->get_param('per_page') ?? 100);
return rest_ensure_response(array_slice(array_reverse($logs), 0, $per_page));
}
public function get_settings() {
// Opportunistically resync the cached tier from the licence server
// (throttled to once/minute inside the helper) so the Dashboard's
// settings poll transparently reflects upgrades, renewals, downgrades
// and cancellations without a manual key re-verify.
self::refresh_license_tier();
$settings = get_option('aicobr_settings', [
'brand_voice' => '{"voice": "Professional yet approachable"}',
'tone' => 'friendly'
]);
// Never return the raw licence key — expose a masked hint + boolean instead.
if (!empty($settings['license_key'])) {
$key = (string) $settings['license_key'];
$settings['license_key_set'] = true;
$settings['license_key_hint'] = str_repeat('*', max(0, strlen($key) - 4)) . substr($key, -4);
unset($settings['license_key']);
} else {
$settings['license_key_set'] = false;
}
// AICOBR_INBOUND_CPT_2026_08 — the choices, and what is in force. The
// effective list is returned as well as the stored one because a filter
// can override the setting, and a settings screen that shows a value
// the plugin is not actually using is worse than showing nothing.
$settings['available_post_types'] = $this->aicobr_available_post_types();
$settings['inbound_post_types'] = $settings['inbound_post_types'] ?? ['post'];
$settings['inbound_post_types_effective'] = $this->aicobr_inbound_post_types();
// AICOBR_ACF_CONTENT_FIELDS_2026_08 — the stored map, plus a small live
// preview so the user can confirm they named the right fields instead of
// configuring blind. The preview reads the most recent published post of
// each configured type and shows what text those fields actually yield.
$settings['acf_content_fields'] = $settings['acf_content_fields'] ?? [];
$settings['acf_content_preview'] = $this->aicobr_acf_fields_preview($settings['acf_content_fields']);
$settings['acf_field_types'] = $settings['acf_field_types'] ?? [];
// AICOBR_ACF_FIELD_LIST_2026_08 — the fields the picker offers, per type.
$settings['acf_available_fields'] = $this->aicobr_acf_available_fields_map();
return rest_ensure_response($settings);
}
public function save_settings($request) {
$data = $request->get_json_params() ?: $request->get_body_params();
$saved = get_option('aicobr_settings', []); // preserve fields not sent in this request
// AICOBR_INBOUND_CPT_2026_08 — remember the effective source pool before
// the write, so a change to it can invalidate cached scans below.
$types_before = $this->aicobr_inbound_post_types();
$acf_fields_before = $this->aicobr_acf_content_fields();
update_option('aicobr_settings', [
'brand_voice' => wp_kses_post($data['brand_voice'] ?? ($saved['brand_voice'] ?? '')),
'tone' => sanitize_text_field($data['tone'] ?? ($saved['tone'] ?? 'friendly')),
'default_word_count' => (intval($data['default_word_count'] ?? ($saved['default_word_count'] ?? 1500)) ?: 1500),
'default_image_style' => sanitize_text_field($data['default_image_style'] ?? ($saved['default_image_style'] ?? 'professional')),
'default_post_type' => sanitize_text_field($data['default_post_type'] ?? ($saved['default_post_type'] ?? 'post')),
'notifications_enabled' => !empty($data['notifications_enabled']),
// AICOBR_INBOUND_CPT_2026_08 — which post types inbound linking and
// the forecast search. Sanitised to existing public types here as
// well as on read: a type saved today can be removed tomorrow when a
// plugin is deactivated, and the stored value would then quietly
// return nothing with no way to tell why.
'inbound_post_types' => (function () use ($data, $saved) {
if (!array_key_exists('inbound_post_types', $data)) {
return $saved['inbound_post_types'] ?? ['post'];
}
$out = [];
foreach ((array) $data['inbound_post_types'] as $t) {
$t = sanitize_key((string) $t);
if ($t && post_type_exists($t) && is_post_type_viewable($t)) $out[] = $t;
}
return $out ? array_values(array_unique($out)) : ['post'];
})(),
// AICOBR_ACF_CONTENT_FIELDS_2026_08
// Per-post-type list of meta fields whose text ACB should read as
// though it were post_content. Sites that store body copy in ACF
// fields (common on custom post types) are otherwise invisible to
// link scanning, because the scanner reads post_content and theirs
// is empty. Shape: { post_type: ['field_a','field_b'] }.
//
// Sanitised hard: keys must be existing post types, values are
// meta_key strings, and meta keys allow more than sanitize_key would
// pass (ACF uses lowercase with underscores, but third parties use
// hyphens and digits), so the filter is "printable, no whitespace,
// no quotes" rather than sanitize_key, which would silently mangle a
// real field name into one that reads nothing.
'acf_content_fields' => (function () use ($data, $saved) {
if (!array_key_exists('acf_content_fields', $data)) {
return $saved['acf_content_fields'] ?? [];
}
$out = [];
foreach ((array) $data['acf_content_fields'] as $pt => $fields) {
$pt = sanitize_key((string) $pt);
if (!$pt || !post_type_exists($pt)) continue;
$clean = [];
foreach ((array) $fields as $f) {
$f = trim((string) $f);
// A meta key: no whitespace, no quotes, printable.
if ($f === '' || preg_match('/[\s"\'`]/', $f)) continue;
if (!preg_match('/^[A-Za-z0-9_\-]+$/', $f)) continue;
$clean[] = $f;
}
if ($clean) $out[$pt] = array_values(array_unique($clean));
}
return $out;
})(),
// AICOBR_ACF_WRITE_2026_08 — per-field type, so insertion knows which
// fields can safely take a link. { post_type: { field: type } }.
'acf_field_types' => (function () use ($data, $saved) {
if (!array_key_exists('acf_field_types', $data)) {
return $saved['acf_field_types'] ?? [];
}
$allowed = ['wysiwyg', 'text', 'other'];
$out = [];
foreach ((array) $data['acf_field_types'] as $pt => $fields) {
$pt = sanitize_key((string) $pt);
if (!$pt || !post_type_exists($pt)) continue;
$clean = [];
foreach ((array) $fields as $f => $type) {
$f = trim((string) $f);
if ($f === '' || !preg_match('/^[A-Za-z0-9_\-]+$/', $f)) continue;
$type = in_array($type, $allowed, true) ? $type : 'text';
$clean[$f] = $type;
}
if ($clean) $out[$pt] = $clean;
}
return $out;
})(),
// ── Output styling (presentation layer) ──────────────────────────
// Booleans default to TRUE when never configured (preserves the
// current always-on behaviour); use the form value when sent, else
// the saved value, else true.
'enable_frontend_styling' => array_key_exists('enable_frontend_styling', $data)
? (bool) $data['enable_frontend_styling']
: (array_key_exists('enable_frontend_styling', $saved) ? !empty($saved['enable_frontend_styling']) : true),
'style_post_title_serif' => array_key_exists('style_post_title_serif', $data)
? (bool) $data['style_post_title_serif']
: (array_key_exists('style_post_title_serif', $saved) ? !empty($saved['style_post_title_serif']) : true),
// AICOBR_HERO_IMAGE_2026_08 — default ON (absent means true), same
// convention as the two settings above, so existing installs get the
// hero without having to opt in. Turn off if the theme already
// renders the thumbnail above the content.
'auto_hero_image' => array_key_exists('auto_hero_image', $data)
? (bool) $data['auto_hero_image']
: (array_key_exists('auto_hero_image', $saved) ? !empty($saved['auto_hero_image']) : true),
// AICOBR_SCHEDULE_PUBLISH_2026_08 — time of day a scheduled brief
// publishes, in the SITE timezone. The diary stores a date only, so
// this supplies the clock half. Validated to HH:MM 24-hour; anything
// else falls back to 09:00 rather than producing an unusable
// post_date (aicobr_resolve_schedule_datetime revalidates on read).
'schedule_time' => (function ($v, $fallback) {
$v = sanitize_text_field((string) $v);
if (preg_match('/^([01]\d|2[0-3]):([0-5]\d)$/', $v)) return $v;
return preg_match('/^([01]\d|2[0-3]):([0-5]\d)$/', (string) $fallback) ? (string) $fallback : '09:00';
})($data['schedule_time'] ?? ($saved['schedule_time'] ?? '09:00'), $saved['schedule_time'] ?? '09:00'),
// Accent colour: present-but-empty clears it (falls back to default);
// absent preserves the saved value. sanitize_hex_color rejects junk.
'brand_color' => array_key_exists('brand_color', $data)
? (sanitize_hex_color($data['brand_color']) ?: '')
: ($saved['brand_color'] ?? ''),
'domain' => sanitize_text_field(!empty($data['domain']) ? $data['domain'] : (!empty($saved['domain']) ? $saved['domain'] : wp_parse_url(home_url(), PHP_URL_HOST))),
'email' => sanitize_email($data['email'] ?? ($saved['email'] ?? '')),
'license_key' => sanitize_text_field(!empty($data['license_key']) ? $data['license_key'] : ($saved['license_key'] ?? '')),
'email_verified' => !empty($saved['email_verified']), // never set from form — only set by verify-license endpoint
'plan_tier' => sanitize_text_field(($saved['plan_tier'] ?? '') ?: 'free'), // never overwrite from form — only set by verify-license
// Wizard dismissed — use strict boolean check so false is preserved correctly
'_wizard_dismissed' => array_key_exists('_wizard_dismissed', $data) ? ($data['_wizard_dismissed'] === true) : !empty($saved['_wizard_dismissed']),
// Writing Style Profiles
'writing_style_profiles' => $data['writing_style_profiles'] ?? ($saved['writing_style_profiles'] ?? []),
'active_style_profile_id' => sanitize_text_field($data['active_style_profile_id'] ?? ($saved['active_style_profile_id'] ?? '')),
// User instruction snippets
'user_snippets' => $data['user_snippets'] ?? ($saved['user_snippets'] ?? []),
'global_snippets' => $data['global_snippets'] ?? ($saved['global_snippets'] ?? []),
// ── AI Content Strategist ────────────────────────────────────────
'business_context' => [
'what' => sanitize_text_field($data['business_context']['what'] ?? ($saved['business_context']['what'] ?? '')),
'industry' => sanitize_text_field($data['business_context']['industry'] ?? ($saved['business_context']['industry'] ?? '')),
'location' => sanitize_text_field($data['business_context']['location'] ?? ($saved['business_context']['location'] ?? '')),
],
'target_keywords' => array_values(array_filter(array_map(
'sanitize_text_field',
is_array($data['target_keywords'] ?? null) ? $data['target_keywords'] : ($saved['target_keywords'] ?? [])
))),
'serp_gl' => sanitize_text_field($data['serp_gl'] ?? ($saved['serp_gl'] ?? '')),
]);
// AICOBR_INBOUND_CPT_2026_08
// The searchable post types ARE the source pool, so changing them
// changes the answer to every cached scan and every cached forecast.
// Without this the user ticks "Pages", sees no change for fifteen
// minutes, and concludes the setting does nothing. Same shape as the
// version-in-cache-key fix: the cache encodes a world state, and
// something changed the world without telling it.
// AICOBR_ACF_CONTENT_FIELDS_2026_08 — the field map changes what text the
// scanner reads, so a change to it must invalidate cached scans exactly
// as a change to the searched post types does.
$acf_before = wp_json_encode($acf_fields_before);
$acf_after = wp_json_encode($this->aicobr_acf_content_fields());
if ($this->aicobr_inbound_post_types() !== $types_before || $acf_before !== $acf_after) {
$this->aicobr_bump_inbound_gen();
}
return rest_ensure_response(['success' => true]);
}
public function get_stats() {
$logs = get_option('aicobr_logs', []);
$recent = array_filter($logs, fn($l) => strtotime($l['created_at'] ?? 'now') > strtotime('-30 days'));
$totalRuns = count($recent);
$successful = count(array_filter($recent, fn($l) => ($l['status'] ?? '') === 'success'));
$tokens = array_filter(array_column($recent, 'tokens_used'));
$avgTokens = $tokens ? (int)round(array_sum($tokens) / count($tokens)) : 0;
$image_costs = array_filter(array_column($recent, 'image_cost'));
$avgImageCost = $image_costs ? round(array_sum($image_costs) / count($image_costs), 4) : 0.0;
$lastRun = !empty($logs) ? human_time_diff(strtotime(end($logs)['created_at']), current_time('timestamp')) . ' ago' : null;
return rest_ensure_response([
'totalRuns' => $totalRuns,
'successful' => $successful,
'avgTokens' => $avgTokens,
'avgImageCost' => $avgImageCost,
'lastRun' => $lastRun,
]);
}
public function get_usage() {
// Usage tab + Settings page. Plan, monthly allowance, balance and reset
// date come from the licence server's /api/credits; activity is local.
$logs = get_option('aicobr_logs', []);
$totalPosts = count($logs);
$settings = get_option('aicobr_settings', []);
$license_key = $settings['license_key'] ?? '';
// Local activity history is always available regardless of licence state.
$base = [
'totalPostsEver' => $totalPosts,
'avgPostsPerMonth' => $totalPosts > 0 ? max(1, round($totalPosts / 3)) : 0,
'recentActivity' => array_slice(array_reverse($logs), 0, 3),
];
$TIER_LIMITS = ['free' => 5, 'starter' => 30, 'pro' => 100, 'agency' => 300];
$fallback_tier = strtolower($settings['plan_tier'] ?? 'free');
$fallback_limit = $TIER_LIMITS[$fallback_tier] ?? 5;
// If we can't reach the licence server (or there's no key), fall back to the
// known tier limit rather than the misleading free/5 stub.
$degraded = function () use ($base, $fallback_tier, $fallback_limit) {
return rest_ensure_response(array_merge($base, [
'plan' => $fallback_tier,
'postsLimit' => $fallback_limit,
'postsUsed' => 0,
'creditsRemaining' => 0,
'daysUntilReset' => null,
]));
};
if (empty($license_key)) {
return $degraded();
}
$license_url = self::get_server_url();
$response = wp_remote_get(
"{$license_url}/api/credits?license_key=" . rawurlencode($license_key),
['timeout' => 20]
);
$body = is_wp_error($response) ? null : json_decode(wp_remote_retrieve_body($response), true);
if (!is_array($body) || empty($body['success'])) {
return $degraded();
}
$tier = strtolower($body['tier'] ?? $fallback_tier);
$limit = isset($body['monthly_credit_limit']) ? (int) $body['monthly_credit_limit'] : ($TIER_LIMITS[$tier] ?? $fallback_limit);
$remaining = isset($body['credits_remaining']) ? (int) $body['credits_remaining'] : 0;
$days = null;
if (!empty($body['next_expiry_date'])) {
$exp = strtotime($body['next_expiry_date']);
if ($exp) {
$days = (int) ceil(($exp - current_time('timestamp')) / DAY_IN_SECONDS);
if ($days < 0) { $days = 0; }
}
}
return rest_ensure_response(array_merge($base, [
'plan' => $tier,
'postsLimit' => $limit,
'postsUsed' => max(0, $limit - $remaining),
'creditsRemaining' => $remaining,
'daysUntilReset' => $days,
]));
}
/**
* POST /ai-content/v1/generation-stage
* Lightweight progress beacon from the licence server pipeline. Secured by the
* shared generate secret. Only advances the stage of an entry that is still
* generating, so a late beacon can never overwrite a finished/failed status.
*/
public function generation_stage($request) {
$secret = $request->get_header('x-generate-secret');
if (!hash_equals((string) self::get_secret(), (string) $secret)) {
return new WP_Error('unauthorized', 'Unauthorized', ['status' => 401]);
}
$data = $request->get_json_params() ?: [];
$stage = sanitize_text_field($data['stage'] ?? '');
if ($stage === '') {
return rest_ensure_response(['success' => false, 'error' => 'no_stage']);
}
$this->aicobr_apply_entry_fields(
['title' => $data['title'] ?? '', 'post_id' => $data['post_id'] ?? 0],
['stage' => $stage],
['generating', 'regenerating']
);
return rest_ensure_response(['success' => true]);
}
/**
* GET /ai-content/v1/credits
* Proxies the licence server's /api/credits using the server-stored licence key,
* so the raw key never travels to the browser or appears in a URL.
*/
public function get_credits() {
$settings = get_option('aicobr_settings', []);
$license_key = $settings['license_key'] ?? '';
if (empty($license_key)) {
return rest_ensure_response(['success' => false, 'error' => 'no_license']);
}
$license_url = self::get_server_url();
$response = wp_remote_get(
"{$license_url}/api/credits?license_key=" . rawurlencode($license_key),
['timeout' => 20]
);
if (is_wp_error($response)) {
return new WP_Error('server_unreachable', 'Could not reach the licence server.', ['status' => 502]);
}
$body = json_decode(wp_remote_retrieve_body($response), true);
if (!is_array($body)) {
return rest_ensure_response(['success' => false, 'error' => 'bad_response']);
}
return rest_ensure_response($body);
}
/**
* POST /aicobr/v1/approve-entry
*
* AICOBR_APPROVAL_2026_08
*
* Mark a generated brief as reviewed and approved by a human.
*
* This is an EDITORIAL marker, deliberately with no mechanical power: it
* does not gate scheduling, does not hold a post back, and does not change
* post_status. Scheduling stays automatic (a planned date should mean the
* post publishes on that date), so approval is here to answer "which of
* these have I actually read?" — a question that gets harder with every
* cluster planned, and which the diary previously could not answer at all.
*
* Making it a blocking gate was considered and rejected: a brief that
* silently fails to publish because nobody ticked a box is a worse failure
* than one that publishes unreviewed, and it would undo the point of
* planning dates in the first place.
*
* Written through a dedicated endpoint under the SAME entries lock every
* other read-modify-write uses, rather than relying on the browser's
* ~2.5s entries POST-back — that write races the publish path (the same
* class of bug as the image_history overwrite), so the flag is server-owned.
*/
public function approve_entry($request) {
$data = $request->get_json_params() ?: [];
$entry_id = isset($data['entry_id']) && $data['entry_id'] !== '' ? (string) $data['entry_id'] : '';
if ($entry_id === '') {
return new WP_Error('missing_entry', 'entry_id is required.', ['status' => 400]);
}
// Absent 'approved' means "approve"; send false explicitly to withdraw.
$approved = array_key_exists('approved', $data) ? (bool) $data['approved'] : true;
$locked = $this->aicobr_lock_entries();
try {
$entries = get_option('aicobr_entries', []);
if (!is_array($entries)) $entries = [];
$found = false;
foreach ($entries as &$entry) {
if ((string) ($entry['id'] ?? '') !== $entry_id) continue;
if ($approved) {
$user = wp_get_current_user();
$entry['approved'] = true;
$entry['approved_at'] = current_time('mysql');
$entry['approved_by'] = sanitize_text_field(
($user && $user->exists()) ? ($user->display_name ?: $user->user_login) : ''
);
} else {
// Withdrawn: clear the whole record rather than leaving a
// stale name and timestamp against approved=false.
$entry['approved'] = false;
$entry['approved_at'] = '';
$entry['approved_by'] = '';
}
$found = true;
break;
}
unset($entry);
if (!$found) {
return new WP_Error('not_found', 'Entry not found.', ['status' => 404]);
}
update_option('aicobr_entries', $entries);
} finally {
if ($locked) $this->aicobr_unlock_entries();
}
return rest_ensure_response([
'success' => true,
'approved' => $approved,
]);
}
/**
* POST /aicobr/v1/inbound-suggestions
*
* AICOBR_INBOUND_LINKS_2026_08
*
* Which existing published posts could link to this spoke, and where.
* READ-ONLY — writes nothing. The user approves each suggestion individually
* in a later phase; nothing here changes any post.
*/
public function inbound_suggestions($request) {
$data = $request->get_json_params() ?: [];
$entry_id = isset($data['entry_id']) && $data['entry_id'] !== '' ? (string) $data['entry_id'] : '';
if ($entry_id === '') {
return new WP_Error('missing_entry', 'entry_id is required.', ['status' => 400]);
}
$entries = get_option('aicobr_entries', []);
$entry = null;
foreach ((array) $entries as $e) {
if ((string) ($e['id'] ?? '') === $entry_id) { $entry = $e; break; }
}
if (!$entry) {
return new WP_Error('not_found', 'Entry not found.', ['status' => 404]);
}
$post_id = absint($entry['post_id'] ?? 0);
if (!$post_id || !get_post($post_id)) {
return new WP_Error('no_post', 'This brief has no generated post yet.', ['status' => 400]);
}
// Published only. 2.9.2 schedules future-dated briefs, so a link to a
// 'future' post would 404 for every reader until its date arrives.
if (get_post_status($post_id) !== 'publish') {
return rest_ensure_response([
'success' => false,
'reason' => 'not_published',
'message' => 'Inbound links can only point at a published post. Publish this one first.',
'suggestions' => [],
]);
}
$target = [
'post_id' => $post_id,
'url' => get_permalink($post_id),
'title' => html_entity_decode(get_the_title($post_id), ENT_QUOTES, 'UTF-8'),
'date' => get_post_field('post_date', $post_id),
'categories' => array_merge(
wp_get_post_categories($post_id) ?: [],
wp_get_post_tags($post_id, ['fields' => 'ids']) ?: []
),
];
$opts = [
'limit' => min(20, max(1, absint($data['limit'] ?? AICOBR_Inbound_Links::MAX_PER_TARGET))),
'post_types' => $this->aicobr_inbound_post_types(),
// AICOBR_ACF_CONTENT_FIELDS_2026_08 — so posts whose body lives in
// ACF fields are scanned, not silently skipped for empty content.
'acf_content_fields' => $this->aicobr_acf_content_fields(),
];
// Cached on the target and its content hash: reopening the panel should
// not rescan, but an edited post must not serve stale offsets to phase 4.
//
// AICOBR_INBOUND_RESCAN_2026_08
// post_modified covers the TARGET, but a suggestion's offsets and its
// re-scan context fingerprint describe the SOURCE post — which this key
// cannot see. So the generation salt below is bumped whenever ACB writes
// to any source post (see aicobr_bump_inbound_gen), and `force` lets the
// UI demand a fresh scan outright.
//
// This is what makes the insert route's own advice actionable: when an
// insert is refused with `spot_changed` it tells the user to re-scan, and
// before this the next scan inside the 15-minute window handed back the
// identical stale context and refused again, with no way out but waiting.
$force = !empty($data['force']);
$key = 'aicobr_inb_' . md5($post_id . '|' . $opts['limit'] . '|' . wp_json_encode($opts['post_types'])
. '|' . get_post_field('post_modified', $post_id)
. '|' . (int) get_option('aicobr_inb_gen', 0)
// AICOBR_NAME_GUARD_2026_08 — the plugin VERSION is
// part of the key because the matching RULES are part
// of what a cached scan encodes. Without it, updating
// the plugin leaves every cached scan answering with
// the old rules for up to fifteen minutes, and the
// update looks like it did nothing. That is exactly
// how the name guard appeared not to work after the
// deploy that shipped it.
. '|' . self::VERSION);
if (!$force) {
$cached = get_transient($key);
if (is_array($cached)) {
$cached['cached'] = true;
return rest_ensure_response($cached);
}
}
$result = AICOBR_Inbound_Links::suggestions($entry, $target, $opts);
$result['success'] = true;
$result['target'] = [
'post_id' => $post_id,
'title' => $target['title'],
'url' => $target['url'],
];
set_transient($key, $result, 15 * MINUTE_IN_SECONDS);
return rest_ensure_response($result);
}
/**
* AICOBR_INBOUND_RESCAN_2026_08
*
* Invalidate every cached inbound-link scan.
*
* Deliberately global rather than per-target. A cached scan's value is a set
* of (source post, byte offset, surrounding context) triples, so ANY edit to
* ANY source post can stale a scan run against a completely different target.
* Since ACB only writes to a source when the user approves or undoes a link —
* a rare, deliberate act — the cheapest correct rule is "any write invalidates
* everything". Re-scanning costs one indexed query plus ~0.5 ms per candidate;
* serving a stale offset costs the user a refused insert they cannot clear.
*
* Not autoloaded: read only on the inbound-scan path.
*/
/**
* POST /aicobr/v1/link-forecast
*
* AICOBR_LINK_FORECAST_2026_08
*
* How connected a brief WILL be, before it is written. READ-ONLY, and about
* a post that does not exist yet, so it writes nothing anywhere.
*
* Body: entry_id, optional limit (named examples, max 10) and force.
* Returns: spoke_sources, pillar_sources, pillar, suggestions[] (named
* examples), scanned, skipped{}, phrases[].
*
* A brief that HAS been generated and published is refused with
* reason:'already_published' and pointed at inbound-suggestions instead: the
* forecast is a prediction, and predicting something that has already
* happened would report a number the live scan disagrees with (the live scan
* excludes sources that already link, the forecast does not).
*/
public function link_forecast($request) {
$data = $request->get_json_params() ?: [];
$entry_id = isset($data['entry_id']) && $data['entry_id'] !== '' ? (string) $data['entry_id'] : '';
$phrase = isset($data['phrase']) ? trim((string) $data['phrase']) : '';
// AICOBR_LINK_FORECAST_2026_08 — two callers, two shapes.
//
// A saved diary brief is looked up by entry_id. A row in the Strategist
// plan review has NO entry_id, because nothing is written to the diary
// until the user approves the plan, and the review gate is exactly where
// this forecast is worth the most: it is the last moment a bad keyword
// can be fixed for free. So a bare phrase is accepted too.
//
// The pillar is passed by ID and resolved here rather than accepted as a
// URL from the client, so a caller cannot aim the forecast at an
// arbitrary address.
if ($entry_id === '' && $phrase === '') {
return new WP_Error('missing_entry', 'entry_id or phrase is required.', ['status' => 400]);
}
if ($entry_id === '') {
$pillar_id = absint($data['pillar_post_id'] ?? 0);
$pillar = null;
if ($pillar_id && get_post($pillar_id) && get_post_status($pillar_id) === 'publish') {
$pc = get_post($pillar_id);
$pillar = [
'post_id' => $pillar_id,
'url' => (string) get_permalink($pillar_id),
'title' => (string) get_the_title($pillar_id),
// AICOBR_CLUSTER_LINKDOWN_2026_08 — a pillar carrying
// [content_cluster] lists each spoke as it publishes, so the
// spoke gets an inbound link from the pillar. Without this
// check the forecast calls such a spoke an orphan, which is
// simply untrue.
'lists_spokes' => $pc && has_shortcode((string) $pc->post_content, 'content_cluster'),
];
}
$entry = [
'id' => 'plan_' . md5($phrase . '|' . $pillar_id),
'title' => (string) ($data['title'] ?? $phrase),
'primary_keyword' => $phrase,
'seo_focus_keyword' => '',
// AICOBR_ANCHOR_FORECAST_2026_08 — the phrasings that actually
// match prose. A spoke's keyphrase is long-tail by design and
// will forecast zero on any real site; the variants are what
// close that gap, so dropping them measured the wrong thing.
'anchor_variants' => array_values(array_filter(array_map(
fn($v) => trim((string) $v),
(array) ($data['anchor_variants'] ?? [])
))),
'cluster_pillar' => $pillar,
];
} else {
$entry = null;
foreach ((array) get_option('aicobr_entries', []) as $e) {
if ((string) ($e['id'] ?? '') === $entry_id) { $entry = $e; break; }
}
if (!$entry) {
return new WP_Error('not_found', 'Entry not found.', ['status' => 404]);
}
$post_id = absint($entry['post_id'] ?? 0);
if ($post_id && get_post($post_id) && get_post_status($post_id) === 'publish') {
return rest_ensure_response([
'success' => false,
'reason' => 'already_published',
'message' => 'This brief is already published. Scan it for inbound links instead.',
]);
}
}
$opts = [
'limit' => min(10, max(1, absint($data['limit'] ?? 3))),
'post_types' => $this->aicobr_inbound_post_types(),
// AICOBR_ACF_CONTENT_FIELDS_2026_08 — so posts whose body lives in
// ACF fields are scanned, not silently skipped for empty content.
'acf_content_fields' => $this->aicobr_acf_content_fields(),
];
// Cached on the entry's declared phrases rather than on a post that does
// not exist. The generation salt covers publication (see
// aicobr_bump_inbound_gen, now also bumped by handle_publish), and the
// plugin VERSION covers a change to the matching rules, for the same
// reason the scan cache carries it.
$fingerprint = wp_json_encode([
$entry['primary_keyword'] ?? '',
$entry['seo_focus_keyword'] ?? '',
$entry['anchor_variants'] ?? [],
$entry['cluster_pillar']['post_id'] ?? 0,
]);
$key = 'aicobr_fc_' . md5(($entry['id'] ?? '') . '|' . $fingerprint . '|' . $opts['limit']
. '|' . wp_json_encode($opts['post_types'])
. '|' . (int) get_option('aicobr_inb_gen', 0)
. '|' . self::VERSION);
if (empty($data['force'])) {
$cached = get_transient($key);
if (is_array($cached)) {
$cached['cached'] = true;
return rest_ensure_response($cached);
}
}
$result = AICOBR_Inbound_Links::forecast($entry, $opts);
$result['success'] = true;
$result['entry_id'] = (string) ($entry['id'] ?? '');
set_transient($key, $result, 15 * MINUTE_IN_SECONDS);
return rest_ensure_response($result);
}
/**
* AICOBR_INBOUND_CPT_2026_08
*
* Which post types inbound linking and the forecast search for candidate
* sources.
*
* This defaulted to ['post'] with a filter as the only way to change it,
* which meant pages and custom post types were invisible to every scan and
* no user without a developer would ever discover why. On a site whose
* catalogue is largely CPTs (locations, makes, models on a vehicle site,
* say) that is most of the content, so every count the plugin reported was
* a fraction of the truth with nothing on screen to say so.
*
* Now a setting. The filter still runs last, so existing code overrides
* keep working.
*/
/**
* AICOBR_INBOUND_CPT_2026_08 — public post types the user can choose from,
* for the settings UI. Attachments excluded: they hold no prose to link in.
*/
/**
* AICOBR_ACF_FIELD_LIST_2026_08
*
* The ACF fields registered for a post type that could hold body text, so
* the settings screen can offer a pick-list instead of asking the user to
* recall a meta key. Same principle as reading the type from ACF: the names
* already exist in the field group, so do not make the user retype them.
*
* Limited to the prose-bearing field types: text, textarea, wysiwyg. Three
* reasons, and they line up. Those are the types that hold sentences worth
* scanning for links; they are the types stored as a single scalar in
* postmeta, which is exactly what the reader can flatten (a repeater stores
* its rows across many keys, so a raw postmeta read of the parent key would
* not return its text anyway); and it stops the user picking an image or
* relationship field that holds no prose.
*
* Requires ACF. Returns [] when ACF is inactive, when a post type has no
* field groups, or when none of its fields are prose-bearing. The UI then
* falls back to a free-text input, so a non-ACF meta key stays reachable.
*/
public function aicobr_acf_available_fields($post_type) {
if (!function_exists('acf_get_field_groups') || !function_exists('acf_get_fields')) {
return [];
}
$groups = acf_get_field_groups(['post_type' => $post_type]);
if (!is_array($groups) || !$groups) return [];
$prose = ['text', 'textarea', 'wysiwyg'];
$out = [];
$seen = [];
foreach ($groups as $g) {
$key = $g['key'] ?? '';
if ($key === '') continue;
$fields = acf_get_fields($key);
if (!is_array($fields)) continue;
foreach ($fields as $f) {
$name = (string) ($f['name'] ?? '');
$type = (string) ($f['type'] ?? '');
if ($name === '' || isset($seen[$name])) continue;
if (!in_array($type, $prose, true)) continue;
$seen[$name] = true;
$out[] = [
'name' => $name,
'label' => (string) ($f['label'] ?? $name),
'type' => $type,
];
}
}
return $out;
}
/** Available prose fields for every viewable non-post/page type, for the picker. */
public function aicobr_acf_available_fields_map() {
$out = [];
foreach ($this->aicobr_available_post_types() as $pt) {
$name = $pt['name'];
if ($name === 'post' || $name === 'page') continue;
$fields = $this->aicobr_acf_available_fields($name);
if ($fields) $out[$name] = $fields;
}
return $out;
}
public function aicobr_available_post_types() {
$out = [];
foreach (get_post_types(['public' => true], 'objects') as $pt) {
if ($pt->name === 'attachment') continue;
// wp_count_posts() can return an object with no `publish` property
// for oddly-registered types, and reading it unguarded is a fatal
// on the settings screen. The count is decoration; the type list is
// the point, so degrade to zero rather than take the page down.
$counts = wp_count_posts($pt->name);
$out[] = [
'name' => $pt->name,
'label' => $pt->labels->name ?? $pt->name,
'count' => (is_object($counts) && isset($counts->publish)) ? (int) $counts->publish : 0,
];
}
return $out;
}
/**
* AICOBR_ACF_CONTENT_FIELDS_2026_08
* The configured ACF-field map: { post_type: ['field', ...] }.
*/
/**
* AICOBR_ACF_WRITE_2026_08
*
* Insertion into ACF WYSIWYG fields.
*
* The insert/undo lifecycle is written against post_content. To make it work
* for a WYSIWYG ACF field too, the three operations it depends on — read the
* body, write it back, and know whether writing is allowed — are abstracted
* here and keyed on an optional field name.
*
* field === '' -> post_content, exactly as before. Nothing changes.
* field !== '' -> the named meta value, but ONLY if the user declared that
* field WYSIWYG. A plain-text or repeater field cannot hold
* a rendering
tag, so writing is refused; reading such a
* field for MATCHING stays enabled. Only WRITING is gated.
*
* Type map, beside the field-name map in settings:
* acf_field_types = { post_type: { field_name: 'wysiwyg'|'text'|'other' } }
*/
public function aicobr_acf_field_types() {
$map = get_option('aicobr_settings', [])['acf_field_types'] ?? [];
return is_array($map) ? $map : [];
}
/**
* AICOBR_ACF_TYPE_DETECT_2026_08
*
* The real type of an ACF field, read from ACF itself.
*
* The field type is not something the user should re-declare: ACF already
* stores it, set when the field was created, and asking the user to repeat
* it invites a mismatch that would write a link into a field that renders it
* as literal text. So ask ACF.
*
* get_field_object() needs the field REGISTERED and ACF LOADED, and reads
* the type from the field group's definition. It returns false (or no
* 'type') when ACF is inactive, or when a field was never registered through
* ACF (a bare postmeta key with no field group). In those cases detection is
* genuinely impossible, and the honest answer is "unknown" — the caller then
* falls back to the user's declared type, and failing that, refuses. A post
* id is needed to resolve the field against a real object; the most recent
* published post of the type is representative, since the field group is a
* property of the type, not the individual post.
*
* Returns '' when the type cannot be determined.
*/
public function aicobr_acf_detect_field_type($post_type, $field) {
if ($field === '' || !function_exists('get_field_object')) return '';
static $cache = [];
$ck = $post_type . '|' . $field;
if (array_key_exists($ck, $cache)) return $cache[$ck];
$recent = get_posts([
'post_type' => $post_type, 'post_status' => 'publish',
'posts_per_page' => 1, 'fields' => 'ids', 'orderby' => 'date', 'order' => 'DESC',
'suppress_filters' => true,
]);
$type = '';
if ($recent) {
$obj = get_field_object($field, (int) $recent[0], false, false);
if (is_array($obj) && !empty($obj['type'])) {
$type = (string) $obj['type'];
}
}
return $cache[$ck] = $type;
}
/**
* The effective type ACB will act on: detected from ACF when possible, else
* the user's declared type, else empty. Detection wins because it is the
* source of truth; the manual setting is only a fallback for when ACF cannot
* answer (inactive, or a non-ACF meta key).
*/
public function aicobr_acf_effective_field_type($post_type, $field) {
if ($field === '') return '';
$detected = $this->aicobr_acf_detect_field_type($post_type, $field);
if ($detected !== '') return $detected;
return $this->aicobr_acf_field_types()[$post_type][$field] ?? '';
}
public function aicobr_acf_field_is_writable($post_type, $field) {
if ($field === '') return true;
// AICOBR_ACF_TYPE_DETECT_2026_08 — writable only when the field's REAL
// type is WYSIWYG. Detection is preferred; the manual setting is the
// fallback when ACF cannot be asked. Anything else, including an
// undetermined type, refuses: a wrong write corrupts a live page, so the
// safe default is not to write.
return $this->aicobr_acf_effective_field_type($post_type, $field) === 'wysiwyg';
}
public function aicobr_source_body($post, $field) {
if ($field === '') {
return [(string) $post->post_content, false];
}
return [(string) get_post_meta((int) $post->ID, $field, true), true];
}
public function aicobr_write_source_body($post_id, $field, $new_body) {
if ($field === '') {
// AICOBR_WP_SLASH_2026_08 — wp_update_post() unslashes post_content
// before the DB write (wp-includes/post.php), so it expects slashed
// input. Without wp_slash, a single backslash in the body is stripped:
// \d -> d, C:\Users -> C:Users. Invisible on plain prose, silent
// corruption on code/regex/Windows paths. The meta branch below already
// slashes; a correctness rule that lives in only one of two sibling
// paths is not a rule.
$r = wp_update_post(['ID' => (int) $post_id, 'post_content' => wp_slash($new_body)], true);
return is_wp_error($r) ? $r : true;
}
$current = (string) get_post_meta((int) $post_id, $field, true);
if ($current === $new_body) return true;
$ok = update_post_meta((int) $post_id, $field, wp_slash($new_body));
return $ok !== false ? true : new WP_Error('meta_write_failed', 'Could not update the field.');
}
public function aicobr_acf_content_fields() {
$map = get_option('aicobr_settings', [])['acf_content_fields'] ?? [];
return is_array($map) ? $map : [];
}
/**
* Text held in a post's configured ACF fields, joined, for a given post type.
*
* Read straight from postmeta rather than via get_field(), so this works
* whether or not ACF is active: the value is the same, and ACB should not
* hard-depend on a third-party plugin being loaded to read its own data.
* Non-scalar values (repeaters, groups: arrays) are flattened to their
* scalar leaves; an array cannot be matched as text and would otherwise
* throw. Returns '' when nothing is configured or found.
*/
public function aicobr_acf_field_text($post_id, $post_type) {
$fields = $this->aicobr_acf_content_fields()[$post_type] ?? [];
if (!$fields) return '';
$parts = [];
foreach ($fields as $field) {
$val = get_post_meta((int) $post_id, $field, true);
$this->aicobr_collect_scalars($val, $parts);
}
return trim(implode(' ', $parts));
}
/** Depth-first flatten of scalar leaves out of a meta value. */
private function aicobr_collect_scalars($val, array &$out) {
if (is_string($val) || is_numeric($val)) {
$s = trim((string) $val);
if ($s !== '') $out[] = $s;
return;
}
if (is_array($val)) {
foreach ($val as $v) $this->aicobr_collect_scalars($v, $out);
}
}
/**
* Live preview for the settings screen: for each configured type, the most
* recent published post and the text its fields yield, so the user confirms
* they named the right fields instead of trusting a blind configuration.
*/
private function aicobr_acf_fields_preview($map) {
if (!is_array($map) || !$map) return [];
$out = [];
foreach ($map as $pt => $fields) {
if (!post_type_exists($pt)) continue;
// AICOBR_ACF_TYPE_DETECT_2026_08 — the detected type per field, so the
// settings screen can SHOW what ACF reports instead of asking the user
// to re-enter it. Empty means ACF could not be asked (inactive, or a
// non-ACF meta key), and the UI then offers the manual fallback.
$detected = [];
foreach ((array) $fields as $f) {
$detected[(string) $f] = $this->aicobr_acf_detect_field_type($pt, (string) $f);
}
$recent = get_posts([
'post_type' => $pt, 'post_status' => 'publish',
'posts_per_page' => 1, 'fields' => 'ids', 'orderby' => 'date', 'order' => 'DESC',
'suppress_filters' => true,
]);
if (!$recent) { $out[$pt] = ['title' => '', 'text' => '', 'found' => false]; continue; }
$pid = (int) $recent[0];
$text = $this->aicobr_acf_field_text($pid, $pt);
$out[$pt] = [
'title' => (string) get_the_title($pid),
'text' => $this->aicobr_str_cap($text, 300), // AICOBR_MB_GUARD_2026_08
'found' => $text !== '',
'detected_types' => $detected,
];
}
return $out;
}
private function aicobr_inbound_post_types() {
$settings = get_option('aicobr_settings', []);
$chosen = $settings['inbound_post_types'] ?? null;
if (!is_array($chosen) || !$chosen) {
$chosen = ['post'];
}
// Only types that exist and are public: a stored type from a plugin the
// user has since removed would otherwise silently return nothing.
$valid = [];
foreach ($chosen as $t) {
$t = sanitize_key((string) $t);
if ($t && post_type_exists($t) && is_post_type_viewable($t)) $valid[] = $t;
}
if (!$valid) $valid = ['post'];
return apply_filters('aicobr_inbound_link_post_types', array_values(array_unique($valid)));
}
/**
* POST /aicobr/v1/cluster-stats
*
* AICOBR_CLUSTER_STATS_2026_08
*
* Execution state for one cluster: what has been built, what is stalled,
* and what the pillar would gain if the plan were finished. Read-only.
*
* Body: pillar_post_id. Optional: no_pillar (bool) for briefs with none.
*
* Reports LINKS ONLY. It says nothing about rankings, positions or traffic,
* because ACB cannot observe those and a number here that hinted at them
* would be a claim it could not support.
*/
public function cluster_stats($request) {
$data = $request->get_json_params() ?: [];
$pillar_id = absint($data['pillar_post_id'] ?? 0);
if (!$pillar_id) {
return new WP_Error('missing_pillar', 'pillar_post_id is required.', ['status' => 400]);
}
$entries = [];
// AICOBR_PILLAR_URL_FORMS_2026_08 — every address the cluster's up-links
// were actually written with. Collected from the entries themselves
// because that is the URL the generation path used; get_permalink() can
// report a different form today.
$alt_urls = [];
foreach ((array) get_option('aicobr_entries', []) as $e) {
if ((int) ($e['cluster_pillar']['post_id'] ?? 0) !== $pillar_id) continue;
$stored = (string) ($e['cluster_pillar']['url'] ?? '');
if ($stored !== '') $alt_urls[$stored] = true;
$pid = absint($e['post_id'] ?? 0);
$entries[] = [
'id' => (string) ($e['id'] ?? ''),
'title' => (string) ($e['title'] ?? ''),
'post_id' => $pid,
'status' => (string) ($e['status'] ?? ''),
'wp_status' => $pid ? (string) get_post_status($pid) : '',
'post_url' => $pid ? (string) get_permalink($pid) : '',
'primary_keyword' => (string) ($e['primary_keyword'] ?? ''),
];
}
$stats = AICOBR_Cluster_Stats::summarise([
'post_id' => $pillar_id,
'title' => (string) get_the_title($pillar_id),
'url' => (string) get_permalink($pillar_id),
'alt_urls' => array_keys($alt_urls),
], $entries, ['post_types' => $this->aicobr_inbound_post_types()]);
// AICOBR_CLUSTER_REPORT_2026_08
// Resolve activity titles from the target post id. The ledger records
// target_post_id and target_url but NOT target_title (see the insert
// route), so reading a title straight off the record yields nothing and
// every activity line reads "a post". Titles are looked up live rather
// than backfilled into the meta, so a renamed post reads correctly here
// instead of showing whatever it was called on the day.
foreach ($stats['ledger']['recent'] as &$r) {
$tid = (int) ($r['target_post_id'] ?? 0);
if ($tid && $r['target_title'] === '') {
$r['target_title'] = (string) get_the_title($tid);
}
}
unset($r);
// AICOBR_FRONT_PAGE_PILLAR_2026_08
// Is this pillar the site's front page? It changes what the inbound
// count MEANS. Every theme links home from its header, footer and logo,
// so a front-page pillar counts site chrome rather than editorial link
// building, and a number in the hundreds is normal and says nothing
// about the work the user has done. The figure is true either way; the
// report has to say which kind of true it is.
$stats['pillar']['is_front_page'] =
((int) get_option('page_on_front') === $pillar_id)
|| untrailingslashit((string) get_permalink($pillar_id)) === untrailingslashit((string) home_url('/'));
$stats['success'] = true;
// Returned so the dashboard can deep-link a brief without re-deriving
// which entries belong to this cluster.
$stats['entries'] = $entries;
return rest_ensure_response($stats);
}
private function aicobr_bump_inbound_gen() {
update_option('aicobr_inb_gen', ((int) get_option('aicobr_inb_gen', 0)) + 1, false);
}
/**
* POST /aicobr/v1/insert-inbound-link
*
* AICOBR_RESCAN_CONTEXT_2026_08
*
* Insert ONE approved inbound link into a source post. Correct-or-nothing:
* the byte offset from the scan is never trusted — the reviewed occurrence is
* re-located against the source post's CURRENT content, and the link is only
* written where the reviewed sentence still is. If it has moved, changed, or
* gone, nothing is written and the caller is told to re-scan.
*
* Every insert is recorded in the source post's _aicobr_inbound_links meta
* (with the exact stored HTML) so it can be undone surgically later.
*/
public function insert_inbound_link($request) {
$data = $request->get_json_params() ?: [];
$source_id = absint($data['source_post_id'] ?? 0);
$phrase = trim((string) ($data['phrase'] ?? ''));
$target_url = esc_url_raw((string) ($data['target_url'] ?? ''));
$target_pid = absint($data['target_post_id'] ?? 0);
$title_attr = sanitize_text_field($data['target_title'] ?? '');
$sugg_id = sanitize_text_field($data['suggestion_id'] ?? '');
// AICOBR_ACF_WRITE_2026_08 — which body to write into. Empty = post_content.
$field = trim((string) ($data['field'] ?? ''));
if ($field !== '' && !preg_match('/^[A-Za-z0-9_\-]+$/', $field)) {
return new WP_Error('bad_field', 'Invalid field name.', ['status' => 400]);
}
$context = [
'before' => (string) ($data['context']['before'] ?? ''),
'after' => (string) ($data['context']['after'] ?? ''),
];
if (!$source_id || !$phrase || $target_url === '') {
return new WP_Error('bad_request', 'source_post_id, phrase and target_url are required.', ['status' => 400]);
}
// Write path: re-check the SPECIFIC source post, not just the general cap.
if (!current_user_can('edit_post', $source_id)) {
return new WP_Error('forbidden', 'You cannot edit that source post.', ['status' => 403]);
}
$post = get_post($source_id);
if (!$post) {
return new WP_Error('no_source', 'Source post not found.', ['status' => 404]);
}
// AICOBR_ACF_WRITE_2026_08 — writing into a field is allowed ONLY when
// the user declared it WYSIWYG. A link written into a plain-text field
// would render as literal angle brackets, so refuse rather than corrupt
// the page. Reading that field to FIND matches is unaffected; this gate
// is only about writing.
if (!$this->aicobr_acf_field_is_writable($post->post_type, $field)) {
return rest_ensure_response([
'success' => false,
'reason' => 'field_not_writable',
'message' => 'That field is not a WYSIWYG field, so a link inserted into it would not render. Add the link in the page editor instead.',
]);
}
list($content, $is_meta) = $this->aicobr_source_body($post, $field);
// Already links to the destination -> nothing to do (idempotent, no double link).
if (AICOBR_Link_Scanner::links_to($content, $target_url)) {
return rest_ensure_response([
'success' => false,
'reason' => 'already_links',
'message' => 'That post already links to the destination.',
]);
}
// The guard: re-find the reviewed occurrence in CURRENT content.
//
// AICOBR_NAME_GUARD_2026_08 — rebuilt here rather than trusted from the
// client, and excluding the destination itself, so the write path enforces
// exactly what the scan enforced. A suggestion approved before a longer
// product was published is refused now, which is the correct answer: the
// sentence means something different than it did at review time.
$extenders = AICOBR_Inbound_Links::extenders(
$phrase,
AICOBR_Inbound_Links::catalogue_names([$phrase], [$target_pid])
);
$hit = AICOBR_Link_Scanner::find_reviewed_occurrence($content, $phrase, $context, 48, $extenders);
if (!$hit) {
$split = $extenders
&& AICOBR_Link_Scanner::find_reviewed_occurrence($content, $phrase, $context) !== null;
return rest_ensure_response([
'success' => false,
'reason' => $split ? 'name_collision' : 'spot_changed',
'message' => $split
? 'That phrase now runs into a longer product name on this site, so linking it would split the name. Nothing was written.'
: 'The spot you approved has changed since the scan. Re-scan this post to see current options.',
]);
}
// Manual splice so the stored HTML is provably identical to what is written.
$anchor_text = substr($content, $hit['start'], $hit['end'] - $hit['start']);
$stored_html = AICOBR_Link_Scanner::build_anchor($target_url, $anchor_text, $title_attr);
$new_content = substr($content, 0, $hit['start']) . $stored_html . substr($content, $hit['end']);
$updated = $this->aicobr_write_source_body($source_id, $field, $new_content);
if (is_wp_error($updated)) {
return new WP_Error('write_failed', 'Could not update the source: ' . $updated->get_error_message(), ['status' => 500]);
}
// Record it so undo can strip exactly this link later.
$log = get_post_meta($source_id, '_aicobr_inbound_links', true);
if (!is_array($log)) $log = [];
$log[] = [
'suggestion_id' => $sugg_id,
'target_post_id'=> $target_pid,
'target_url' => $target_url,
'anchor_text' => $anchor_text,
'stored_html' => $stored_html,
// AICOBR_ACF_WRITE_2026_08 — WHERE the link was written, so undo
// strips it from the same place. Absent/empty means post_content, so
// links recorded before this change keep working unchanged.
'field' => $field,
'inserted_at' => current_time('mysql'),
'inserted_by' => get_current_user_id(),
];
update_post_meta($source_id, '_aicobr_inbound_links', $log);
// The source post's content just moved — every cached scan that referenced
// it now holds offsets and context taken from the previous revision.
$this->aicobr_bump_inbound_gen();
return rest_ensure_response([
'success' => true,
'source_post_id' => $source_id,
'target_post_id' => $target_pid,
'target_url' => $target_url,
'anchor' => $anchor_text,
'stored_html' => $stored_html,
'suggestion_id' => $sugg_id,
'message' => 'Link inserted.',
]);
}
/**
* POST /aicobr/v1/remove-inbound-link
*
* AICOBR_RESCAN_CONTEXT_2026_08
*
* Undo one previously-inserted inbound link. Surgical: it strips ONLY ACB's
* own stored (matched on the exact HTML written at insert time) and
* leaves the anchor text behind. If the source post has been edited so that
* exact HTML is gone, it refuses rather than guessing — the same
* correct-or-nothing posture as insertion.
*
* Identify the link by suggestion_id (looked up from the source post's
* _aicobr_inbound_links meta) or by passing stored_html directly.
*/
public function remove_inbound_link($request) {
$data = $request->get_json_params() ?: [];
$source_id = absint($data['source_post_id'] ?? 0);
$sugg_id = sanitize_text_field($data['suggestion_id'] ?? '');
$stored = (string) ($data['stored_html'] ?? '');
if (!$source_id) {
return new WP_Error('bad_request', 'source_post_id is required.', ['status' => 400]);
}
if (!current_user_can('edit_post', $source_id)) {
return new WP_Error('forbidden', 'You cannot edit that source post.', ['status' => 403]);
}
$post = get_post($source_id);
if (!$post) {
return new WP_Error('no_source', 'Source post not found.', ['status' => 404]);
}
$log = get_post_meta($source_id, '_aicobr_inbound_links', true);
if (!is_array($log)) $log = [];
// Resolve the stored HTML from the log by suggestion_id when not given.
// AICOBR_ACF_WRITE_2026_08 — also recover WHICH body it was written into,
// so undo strips from the same place. Older records have no 'field' key,
// which correctly resolves to post_content.
$log_index = null;
$field = '';
if ($stored === '' && $sugg_id !== '') {
foreach ($log as $i => $row) {
if (($row['suggestion_id'] ?? '') === $sugg_id) {
$stored = (string) ($row['stored_html'] ?? '');
$field = (string) ($row['field'] ?? '');
$log_index = $i;
break;
}
}
} elseif ($stored !== '') {
foreach ($log as $i => $row) {
if (($row['stored_html'] ?? '') === $stored) {
$field = (string) ($row['field'] ?? '');
$log_index = $i;
break;
}
}
}
if ($stored === '') {
return new WP_Error('not_recorded', 'No recorded ACB link matched — nothing to remove.', ['status' => 404]);
}
if ($field !== '' && !preg_match('/^[A-Za-z0-9_\-]+$/', $field)) {
return new WP_Error('bad_field', 'Recorded field name is invalid.', ['status' => 500]);
}
list($body, $is_meta) = $this->aicobr_source_body($post, $field);
$new_content = AICOBR_Link_Scanner::remove_link($body, $stored);
if ($new_content === false) {
return rest_ensure_response([
'success' => false,
'reason' => 'not_found',
'message' => 'The link was not found in the post — it may have been edited since. Remove it manually to be safe.',
]);
}
$updated = $this->aicobr_write_source_body($source_id, $field, $new_content);
if (is_wp_error($updated)) {
return new WP_Error('write_failed', 'Could not update the source: ' . $updated->get_error_message(), ['status' => 500]);
}
// Drop it from the log.
if ($log_index !== null) {
unset($log[$log_index]);
update_post_meta($source_id, '_aicobr_inbound_links', array_values($log));
}
// Undo rewrites the source too — same staleness, same invalidation.
$this->aicobr_bump_inbound_gen();
return rest_ensure_response([
'success' => true,
'source_post_id' => $source_id,
'suggestion_id' => $sugg_id,
'message' => 'Link removed.',
]);
}
/**
* Reset a stuck entry (manual reset after >12 mins stuck)
* Supports optional credit refund request
*/
public function reset_entry($request) {
$data = $request->get_json_params();
$entry_id = absint($data['entry_id'] ?? 0);
$refund_credits = !empty($data['refund_credits']);
$reason = sanitize_text_field($data['reason'] ?? '');
$locked = $this->aicobr_lock_entries();
try {
$entries = get_option('aicobr_entries', []);
$found = false;
foreach ($entries as &$entry) {
if ($entry['id'] == $entry_id) {
$entry['status'] = 'pending';
$entry['generationStartTime'] = null;
$entry['updated_at'] = current_time('mysql');
$found = true;
break;
}
}
if (!$found) {
return new WP_Error('not_found', 'Entry not found', ['status' => 404]);
}
update_option('aicobr_entries', $entries);
} finally {
if ($locked) $this->aicobr_unlock_entries();
}
$refund_success = false;
// Attempt a real credit refund via the license server
if ($refund_credits) {
$settings = get_option('aicobr_settings', []);
$license_key = $settings['license_key'] ?? '';
$license_server = self::get_server_url();
if (!empty($license_key)) {
$refund_response = wp_remote_post("{$license_server}/api/refund-credits", [
'timeout' => 15,
'headers' => [
'Content-Type' => 'application/json',
'x-generate-secret' => self::get_secret(),
],
'body' => wp_json_encode([
'license_key' => $license_key,
'credits' => 1, // refund 1 credit — conservative default
'reason' => $reason,
'entry_id' => $entry_id,
]),
]);
if (!is_wp_error($refund_response)) {
$refund_body = json_decode(wp_remote_retrieve_body($refund_response), true);
$refund_success = !empty($refund_body['success']);
}
}
// Always log the refund request regardless of API outcome
$logs = get_option('aicobr_refund_requests', []);
$logs[] = [
'created_at' => current_time('mysql'),
'entry_id' => $entry_id,
'reason' => $reason,
'api_refunded' => $refund_success,
'status' => $refund_success ? 'refunded' : 'pending_manual',
];
update_option('aicobr_refund_requests', $logs);
}
return rest_ensure_response([
'success' => true,
'refund_requested' => $refund_credits,
'refund_success' => $refund_success,
]);
}
/**
* Sets any image from the image_history as the active WordPress featured image.
* Called when the user clicks "Set as Featured" in the image history lightbox.
*/
public function set_featured_image($request) {
$data = $request->get_json_params();
$entry_id = absint($data['entry_id'] ?? 0);
$attachment_id = absint($data['attachment_id'] ?? 0);
if (!$entry_id || !$attachment_id) {
return new WP_Error('missing_params', 'entry_id and attachment_id are required', ['status' => 400]);
}
$locked = $this->aicobr_lock_entries();
try {
$entries = get_option('aicobr_entries', []);
$found = false;
foreach ($entries as &$entry) {
if ($entry['id'] != $entry_id) continue;
$post_id = absint($entry['post_id'] ?? 0);
if (!$post_id) {
return new WP_Error('no_post', 'Entry has no associated post', ['status' => 400]);
}
// Ensure the caller can edit THIS post (avoids IDOR on posts they don't own).
if (!current_user_can('edit_post', $post_id)) {
return new WP_Error('forbidden', 'You cannot edit this post.', ['status' => 403]);
}
// Set as WordPress featured image
set_post_thumbnail($post_id, $attachment_id);
// Update the active featured_image URL on the entry
$new_url = wp_get_attachment_url($attachment_id);
$entry['featured_image'] = $new_url;
$entry['featured_image_id'] = $attachment_id;
// Mark which image is active in image_history
if (!empty($entry['image_history']) && is_array($entry['image_history'])) {
foreach ($entry['image_history'] as &$img) {
$img['is_active'] = ($img['attachment_id'] === $attachment_id);
}
}
$found = true;
break;
}
if (!$found) {
return new WP_Error('not_found', 'Entry not found', ['status' => 404]);
}
update_option('aicobr_entries', $entries);
return rest_ensure_response(['success' => true]);
} finally {
if ($locked) $this->aicobr_unlock_entries();
}
}
/**
* Append a freshly-generated image to an entry's image_history (image-only
* regeneration). Called SERVER-TO-SERVER by the licence server's
* /api/regenerate-image pipeline after it has generated a new image and
* pushed it through /upload-image. Unlike set_featured_image (which only
* re-flags an image that's already in history), this ADDS a new one.
*
* Secured by the shared generate secret (the route's verify_secret
* permission_callback) — there is no logged-in user on a server-to-server
* call, so, exactly like /publish and /upload-image, it trusts the secret
* and the fact the entry_id originated from the authenticated trigger.
*
* This runs under the SAME entries lock as the publish path, so an image
* append and a status write can never clobber each other (the non-destructive
* fix from 2026-07-06 relies on this coexistence).
*/
public function append_image($request) {
$data = $request->get_json_params();
$entry_id = absint($data['entry_id'] ?? 0);
$attachment_id = absint($data['attachment_id'] ?? 0);
// Default to making the new image active — auto-active with easy revert
// via the existing image-history picker (see the image-regen scope).
$set_active = array_key_exists('set_active', (array) $data) ? (bool) $data['set_active'] : true;
// AICOBR_INBODY_IMAGES_2026_08
// An in-body image is NOT a featured image. It must never set the post
// thumbnail and must never overwrite featured_image / featured_image_id,
// or generating an illustration for section three would silently replace
// the article's lead image. The role is sent explicitly by the licence
// server rather than inferred, and set_active is forced off for it.
$img_role = (($data['role'] ?? 'featured') === 'body') ? 'body' : 'featured';
if ($img_role === 'body') $set_active = false;
$img_layout = sanitize_text_field($data['layout'] ?? '');
if (!in_array($img_layout, ['band', 'inline-left', 'inline-right'], true)) $img_layout = 'band';
$img_alt = $this->aicobr_str_cap(sanitize_text_field($data['alt_text'] ?? ''), 250);
$img_caption = $this->aicobr_str_cap(sanitize_text_field($data['caption'] ?? ''), 200);
$img_sug_id = sanitize_text_field($data['suggestion_id'] ?? '');
$img_placement = [];
if (!empty($data['placement']) && is_array($data['placement'])) {
$img_placement = [
'section_heading' => $this->aicobr_str_cap(sanitize_text_field($data['placement']['section_heading'] ?? ''), 200),
'position' => sanitize_key($data['placement']['position'] ?? 'after-heading'),
];
}
if (!$entry_id || !$attachment_id) {
return new WP_Error('missing_params', 'entry_id and attachment_id are required', ['status' => 400]);
}
// Resolve the new image URL up front; a missing attachment is a hard error
// (the licence server just uploaded it, so it should exist).
$new_url = wp_get_attachment_url($attachment_id);
if (!$new_url) {
return new WP_Error('bad_attachment', 'Attachment not found', ['status' => 400]);
}
$locked = $this->aicobr_lock_entries();
try {
$entries = get_option('aicobr_entries', []);
$found = false;
foreach ($entries as &$entry) {
if ($entry['id'] != $entry_id) continue;
// ── Append to image_history — the EXACT block the publish path uses
// (ai-content-bridge.php publish handler ~2421-2437). Never
// overwrite previous images; mark the new one active. ──
if (!isset($entry['image_history']) || !is_array($entry['image_history'])) {
$entry['image_history'] = [];
}
// Only a FEATURED image deactivates the previous featured image.
// An in-body image is an addition, not a replacement, so it must not
// clear the active flag on the post's lead image.
if ($img_role !== 'body') {
foreach ($entry['image_history'] as &$img) {
$img['is_active'] = false;
}
unset($img);
}
$entry['image_history'][] = array_merge([
'attachment_id' => $attachment_id,
'url' => $new_url,
'generated_at' => current_time('mysql'),
'is_active' => ($img_role !== 'body'),
// AICOBR_INBODY_IMAGES_2026_08 — a row with no role is a legacy
// featured image, so absence reads correctly on existing data.
'role' => $img_role,
], $img_role === 'body' ? [
'layout' => $img_layout,
'placement' => $img_placement,
'alt_text' => $img_alt,
'caption' => $img_caption,
'suggestion_id' => $img_sug_id,
'inserted' => false,
] : []);
// ── Optionally make it the featured image — same write as
// set_featured_image. Auto-active by default; the user can revert
// to any earlier image from the picker (which flips is_active back).
if ($set_active) {
$post_id = absint($entry['post_id'] ?? 0);
if ($post_id) {
set_post_thumbnail($post_id, $attachment_id);
}
$entry['featured_image'] = $new_url;
$entry['featured_image_id'] = $attachment_id;
}
$found = true;
break;
}
unset($entry);
if (!$found) {
return new WP_Error('not_found', 'Entry not found', ['status' => 404]);
}
update_option('aicobr_entries', $entries);
return rest_ensure_response([
'success' => true,
'attachment_id' => $attachment_id,
'url' => $new_url,
]);
} finally {
if ($locked) $this->aicobr_unlock_entries();
}
}
/**
* Returns ACF text/textarea/wysiwyg fields registered to a given post type.
* Returns an empty array if ACF is not active — no errors.
*/
/**
* POST /aicobr/v1/image-suggestions
*
* AICOBR_INBODY_IMAGES_2026_08
*
* Retroactive in-body image suggestions for a post that already exists —
* the half of the feature that works on everything a user generated before
* it shipped.
*
* The article body is read HERE with get_post(), not accepted from the
* browser: /entries deliberately does not persist post content, and the
* client has no business asserting what a post says. The licence server
* validates every returned heading against this same text, so a suggestion
* it cannot anchor is dropped rather than producing a placement the
* insertion step could never find.
*
* Free: no credit is deducted here or on the licence server. The spend
* happens when the user generates one of the suggestions.
*/
public function get_image_suggestions($request) {
$data = $request->get_json_params() ?: [];
$entry_id = isset($data['entry_id']) && $data['entry_id'] !== '' ? (string) $data['entry_id'] : '';
if ($entry_id === '') {
return new WP_Error('missing_entry', 'entry_id is required.', ['status' => 400]);
}
$settings = get_option('aicobr_settings', []);
$license_key = $settings['license_key'] ?? '';
if (empty($license_key)) {
return new WP_Error('no_license', 'No license key configured. Please visit Settings.', ['status' => 403]);
}
// Resolve the entry -> post.
$entries = get_option('aicobr_entries', []);
$entry = null;
foreach ((array) $entries as $e) {
if (isset($e['id']) && (string) $e['id'] === $entry_id) { $entry = $e; break; }
}
if (!$entry) {
return new WP_Error('not_found', 'Entry not found.', ['status' => 404]);
}
$post_id = absint($entry['post_id'] ?? 0);
$post = $post_id ? get_post($post_id) : null;
if (!$post) {
return new WP_Error('no_post', 'This brief has no generated post yet.', ['status' => 400]);
}
// Per-post capability, not just edit_posts — the same rule the insert
// route uses, so reading a post's body can't be used to sidestep it.
if (!current_user_can('edit_post', $post_id)) {
return new WP_Error('forbidden', 'You cannot edit this post.', ['status' => 403]);
}
$payload = [
'title' => (string) get_the_title($post),
'content' => (string) $post->post_content,
'content_type' => sanitize_text_field($entry['content_type'] ?? 'blog_post'),
'count' => max(0, min(3, intval($data['count'] ?? 3))),
// Cost attribution only — the licence server never gates on these.
'license_key' => (string) $license_key,
'domain' => wp_parse_url(home_url(), PHP_URL_HOST),
];
$response = wp_remote_post(self::get_server_url() . '/api/image-suggestions', [
'timeout' => 120, // one small Claude call; the user is waiting in the modal
'headers' => [
'Content-Type' => 'application/json',
'x-generate-secret' => self::get_secret(),
],
'body' => wp_json_encode($payload),
]);
if (is_wp_error($response)) {
aicobr_dbg('[ACB] get_image_suggestions: wp_remote_post failed: ' . $response->get_error_message());
return new WP_Error('server_unreachable', 'Could not reach the suggestion server. Please try again.', ['status' => 502]);
}
$code = wp_remote_retrieve_response_code($response);
$body = json_decode(wp_remote_retrieve_body($response), true);
if ($code >= 400 || empty($body['success'])) {
$err = $body['error'] ?? 'Could not generate image suggestions.';
return new WP_Error($body['code'] ?? 'suggest_failed', $err, ['status' => $code >= 400 ? $code : 500]);
}
// Sanitise through the SAME function the publish path uses, so a
// retroactive suggestion and a generation-time one are stored identically
// and the modal cannot tell them apart.
$suggestions = $this->aicobr_sanitise_image_suggestions($body['suggestions'] ?? []);
// Persist onto the entry under the entries lock, preserving any existing
// suggestion the user has already used — a fresh run must not silently
// un-tick work they have done.
$locked = $this->aicobr_lock_entries();
$all = get_option('aicobr_entries', []);
foreach ($all as &$en) {
if ((string) ($en['id'] ?? '') !== $entry_id) continue;
$used = [];
foreach ((array) ($en['image_suggestions'] ?? []) as $prev) {
if (!empty($prev['used'])) $used[] = $prev;
}
$usedIds = wp_list_pluck($used, 'id');
$fresh = array_values(array_filter($suggestions, function ($sg) use ($usedIds) {
return !in_array($sg['id'], $usedIds, true);
}));
$en['image_suggestions'] = array_merge($used, $fresh);
$suggestions = $en['image_suggestions'];
break;
}
unset($en);
update_option('aicobr_entries', $all);
if ($locked) $this->aicobr_unlock_entries();
return rest_ensure_response([
'success' => true,
'suggestions' => $suggestions,
]);
}
/**
* POST /aicobr/v1/insert-youtube
*
* AICOBR_YT_INSERT_FIX_2026_08
*
* Place a YouTube URL into the body of an ACB post so WordPress auto-embeds
* it. Replaces insertYouTubeIntoPost() in ContentDiary.js, which spliced the
* content from the browser and carried two faults:
*
* 1. It called /wp/v2/posts/{id} with the post type hard-coded. Since
* 2.8.5 the Landing Page, Service Page, About Us and FAQ Page types
* publish as `page`, and events as `tribe_events`, so the route was
* simply wrong for all of them and the insert failed outright.
*
* 2. It read `post.content.raw || post.content.rendered`. apiFetch sends
* no context=edit, so the response carries no `content.raw` and the
* expression fell through to `rendered` — then wrote rendered HTML back
* as the raw content. Shortcodes arrive expanded, so a
* [content_cluster] block would be frozen into static HTML and
* [content_featured_image] into a fixed that stops tracking the
* featured image. Silent, and only visible much later.
*
* Doing it in PHP avoids both by construction: post_content is read raw, and
* the post type is whatever the post actually is.
*/
public function insert_youtube_into_post($request) {
$data = $request->get_json_params() ?: [];
$entry_id = isset($data['entry_id']) && $data['entry_id'] !== '' ? (string) $data['entry_id'] : '';
$url = trim((string) ($data['url'] ?? ''));
$position = sanitize_key($data['position'] ?? 'top');
if ($entry_id === '' || $url === '') {
return new WP_Error('missing_params', 'entry_id and url are required.', ['status' => 400]);
}
// Only YouTube. The URL is written into post content and relied on to
// auto-embed, so an arbitrary URL here would be an injection surface.
$host = strtolower((string) wp_parse_url($url, PHP_URL_HOST));
$host = preg_replace('/^www\./', '', $host);
if (!in_array($host, ['youtube.com', 'm.youtube.com', 'youtu.be', 'youtube-nocookie.com'], true)) {
return new WP_Error('bad_url', 'That does not look like a YouTube URL.', ['status' => 400]);
}
$url = esc_url_raw($url);
if ($url === '') {
return new WP_Error('bad_url', 'That URL could not be read.', ['status' => 400]);
}
$entries = get_option('aicobr_entries', []);
$entry = null;
foreach ((array) $entries as $e) {
if (isset($e['id']) && (string) $e['id'] === $entry_id) { $entry = $e; break; }
}
if (!$entry) {
return new WP_Error('not_found', 'Entry not found.', ['status' => 404]);
}
$post_id = absint($entry['post_id'] ?? 0);
if (!$post_id || !get_post($post_id)) {
return new WP_Error('no_post', 'This brief has no generated post yet.', ['status' => 400]);
}
if (!current_user_can('edit_post', $post_id)) {
return new WP_Error('forbidden', 'You cannot edit this post.', ['status' => 403]);
}
if (!self::aicobr_is_generated_post($post_id) && !$this->aicobr_post_in_diary($post_id)) {
return new WP_Error('not_ours', 'This post was not created by AI Content Bridge.', ['status' => 403]);
}
// A page builder owns the layout — same hard refusal as image insertion.
if (self::aicobr_is_page_builder_post($post_id)) {
return rest_ensure_response([
'success' => false,
'reason' => 'page_builder',
'message' => 'This post is built with a page builder, so ACB will not edit its content. Paste the video URL on its own line where you want it instead.',
'url' => $url,
]);
}
$post = get_post($post_id);
$content = (string) $post->post_content;
$stored = (string) get_post_meta($post_id, '_aicobr_content_hash', true);
$edited = ($stored === '' || $stored !== self::aicobr_content_hash($content));
$updated = self::aicobr_insert_embed_url($content, $url, $position);
if (is_wp_error($updated)) {
return rest_ensure_response([
'success' => false,
'reason' => $updated->get_error_code(),
'message' => $updated->get_error_message(),
'url' => $url,
]);
}
// AICOBR_WP_SLASH_2026_08 — $content came from get_post()->post_content
// (unslashed) and $updated is that body with an embed spliced in.
// wp_update_post unslashes before writing, so slash here or lose backslashes.
$res = wp_update_post(['ID' => $post_id, 'post_content' => wp_slash($updated)], true);
if (is_wp_error($res)) {
return new WP_Error('save_failed', $res->get_error_message(), ['status' => 500]);
}
$saved = get_post($post_id);
update_post_meta($post_id, '_aicobr_content_hash', self::aicobr_content_hash($saved->post_content));
aicobr_dbg("[ACB] youtube embed inserted into post {$post_id} ({$position})");
return rest_ensure_response([
'success' => true,
'post_id' => $post_id,
'edited' => $edited,
'position' => $position,
]);
}
/**
* AICOBR_YT_INSERT_FIX_2026_08
*
* Insert a bare URL into content so WordPress auto-embeds it.
*
* Auto-embed only fires when the URL is alone in its own paragraph, so it is
* written on its own line with blank lines either side.
*
* Placement stays INSIDE the .acb-content wrapper when one is present. The
* old browser-side version prepended to the whole string, which put the
* embed above the opening div and outside every scoped style — and appending
* put it after the closing div. Both looked like a stray element rather than
* part of the article.
*/
private static function aicobr_insert_embed_url($html, $url, $position) {
$html = (string) $html;
$block = "\n\n" . $url . "\n\n";
// Bounds of the article body, inside the wrapper when there is one.
//
// AICOBR_YT_WRAPPER_SEARCH_2026_08
// Located by SEARCHING for the wrapper, not by anchoring to the start of
// the string. The anchored version (/^\s*/i', $html, $m, PREG_OFFSET_CAPTURE)) {
$start = $m[0][1] + strlen($m[0][0]);
$close = strrpos($html, '
');
if ($close !== false && $close > $start) $end = $close;
}
if ($position === 'top') {
$at = $start;
} elseif ($position === 'after-first') {
// After the first block of prose. NOT by hunting for '' — the old
// version did that, and classic-editor content stores paragraphs as
// bare text with wpautop creating the at render time, so on a
// real ACB landing page there is no '
' to find and the option
// silently appended to the end instead.
$at = self::aicobr_first_block_end($html, $start, $end);
if ($at === false) $at = $end;
} else {
$at = $end;
}
return substr($html, 0, $at) . $block . substr($html, $at);
}
/**
* End offset of the first paragraph-ish block of prose within a range, or
* false. Works on both markup shapes: a closing when one exists, and
* otherwise the blank line that separates bare-text paragraphs.
*/
private static function aicobr_first_block_end($html, $from, $to) {
$seg = substr($html, $from, $to - $from);
// Skip a leading heading, rule or component so the video does not land
// between a heading and the text it introduces.
$offset = self::aicobr_prose_anchor_offset($html, $from, $to) - $from;
if ($offset < 0) $offset = 0;
$p = stripos($seg, '', $offset);
if ($p !== false) return $from + $p + 4;
// Bare-text paragraph: ends at the next blank line.
if (preg_match('/\R[ \t]*\R/', $seg, $m, PREG_OFFSET_CAPTURE, $offset)) {
return $from + $m[0][1] + strlen($m[0][0]);
}
return false;
}
/**
* POST /aicobr/v1/insert-image
*
* AICOBR_INBODY_INSERT_2026_08
*
* Place an already-generated image into the body of an ACB-written post.
* Nothing is generated here and no credit is spent — the image exists in the
* media library already; this is purely the content edit.
*
* This is the only place ACB writes into a post after creating it, so the
* guards are layered and deliberately fail CLOSED:
*
* 1. The caller must be able to edit THIS post (not merely edit_posts).
* 2. The post must be one ACB created (_aicobr_generated, with the
* content-diary fallback the publish handler already uses).
* 3. No page builder may own the layout. Elementor and friends render from
* their own postmeta; a post_content edit is ignored or desynchronises
* them. This is a hard block, not a warning.
* 4. The target section must still exist. ACB never guesses a position.
*
* The content fingerprint is a SOFT signal by design. A fresh ACB draft that
* the user opened in Gutenberg and saved without changing a word is exactly
* the moment they will first try this feature, and refusing there would make
* the feature look broken. So a mismatch still inserts, and returns
* edited=true so the UI can say "this post has been edited since ACB wrote
* it — check the placement". A page builder is the case that actually
* cannot work, and that one refuses.
*
* Every refusal returns a `shortcode` the user can paste where they want it,
* so the feature degrades visibly instead of silently doing nothing.
*/
public function insert_image_into_post($request) {
$data = $request->get_json_params() ?: [];
$entry_id = isset($data['entry_id']) && $data['entry_id'] !== '' ? (string) $data['entry_id'] : '';
$attachment_id = absint($data['attachment_id'] ?? 0);
$layout = sanitize_text_field($data['layout'] ?? 'band');
$position = sanitize_key($data['position'] ?? 'after-heading');
$heading = sanitize_text_field($data['section_heading'] ?? '');
$alt = sanitize_text_field($data['alt_text'] ?? '');
$caption = sanitize_text_field($data['caption'] ?? '');
if ($entry_id === '' || !$attachment_id) {
return new WP_Error('missing_params', 'entry_id and attachment_id are required.', ['status' => 400]);
}
if (!wp_get_attachment_url($attachment_id)) {
return new WP_Error('bad_attachment', 'That image no longer exists in the media library.', ['status' => 400]);
}
// Resolve the entry -> post.
$entries = get_option('aicobr_entries', []);
$entry = null;
foreach ((array) $entries as $e) {
if (isset($e['id']) && (string) $e['id'] === $entry_id) { $entry = $e; break; }
}
if (!$entry) {
return new WP_Error('not_found', 'Entry not found.', ['status' => 404]);
}
$post_id = absint($entry['post_id'] ?? 0);
if (!$post_id || !get_post($post_id)) {
return new WP_Error('no_post', 'This brief has no generated post yet.', ['status' => 400]);
}
// Guard 1 — per-post capability, not just edit_posts (avoids IDOR).
if (!current_user_can('edit_post', $post_id)) {
return new WP_Error('forbidden', 'You cannot edit this post.', ['status' => 403]);
}
// Guard 2 — ACB ownership, same rule as the publish handler.
if (!self::aicobr_is_generated_post($post_id) && !$this->aicobr_post_in_diary($post_id)) {
return new WP_Error('not_ours', 'This post was not created by AI Content Bridge.', ['status' => 403]);
}
$figure = self::aicobr_build_figure($attachment_id, $alt, $caption, $layout);
$shortcode = '[content_image id="' . $attachment_id . '" layout="' . esc_attr($layout) . '"'
. ($caption !== '' ? ' caption="' . esc_attr($caption) . '"' : '')
. ($alt !== '' ? ' alt="' . esc_attr($alt) . '"' : '')
. ']';
// Guard 3 — a page builder owns the layout. Hard refusal.
if (self::aicobr_is_page_builder_post($post_id)) {
return rest_ensure_response([
'success' => false,
'reason' => 'page_builder',
'message' => 'This post is built with a page builder, so ACB will not edit its content. Paste this shortcode where you want the image instead.',
'shortcode' => $shortcode,
]);
}
$post = get_post($post_id);
$content = (string) $post->post_content;
// Soft signal — has the post changed since ACB wrote it?
$stored = (string) get_post_meta($post_id, '_aicobr_content_hash', true);
$edited = ($stored === '' || $stored !== self::aicobr_content_hash($content));
// Guard 4 — the anchor must still exist. Never guess.
$updated = self::aicobr_insert_figure($content, $figure, $heading, $position);
if (is_wp_error($updated)) {
return rest_ensure_response([
'success' => false,
'reason' => $updated->get_error_code(),
'message' => $updated->get_error_message() . ' Paste this shortcode where you want the image instead.',
'shortcode' => $shortcode,
]);
}
// AICOBR_WP_SLASH_2026_08 — same contract as the embed path above:
// $updated is the post body with a figure spliced in; wp_update_post
// unslashes before the DB write, so slash to preserve backslashes.
$res = wp_update_post(['ID' => $post_id, 'post_content' => wp_slash($updated)], true);
if (is_wp_error($res)) {
return new WP_Error('save_failed', $res->get_error_message(), ['status' => 500]);
}
// Re-fingerprint so a SECOND insertion on the same post is not told the
// user edited it — ACB's own write is not a user edit.
$saved = get_post($post_id);
update_post_meta($post_id, '_aicobr_content_hash', self::aicobr_content_hash($saved->post_content));
// Mark the image as placed and its suggestion as used, so the modal can
// flip the button and grey the chip. Both writes happen under the SAME
// entries lock every other read-modify-write of this option uses — an
// unlocked write here would race the diary's ~2.5s POST-back exactly as
// the image_history append once did.
$suggestion_id = sanitize_text_field($data['suggestion_id'] ?? '');
$locked = $this->aicobr_lock_entries();
$all = get_option('aicobr_entries', []);
foreach ($all as &$en) {
if ((string) ($en['id'] ?? '') !== $entry_id) continue;
if (!empty($en['image_history']) && is_array($en['image_history'])) {
foreach ($en['image_history'] as &$hist) {
if (absint($hist['attachment_id'] ?? 0) === $attachment_id) {
$hist['inserted'] = true;
break;
}
}
unset($hist);
}
if ($suggestion_id !== '' && !empty($en['image_suggestions']) && is_array($en['image_suggestions'])) {
foreach ($en['image_suggestions'] as &$sug) {
if (($sug['id'] ?? '') === $suggestion_id) { $sug['used'] = true; break; }
}
unset($sug);
}
break;
}
unset($en);
update_option('aicobr_entries', $all);
if ($locked) $this->aicobr_unlock_entries();
aicobr_dbg("[ACB] in-body image {$attachment_id} inserted into post {$post_id} ({$layout}/{$position})");
return rest_ensure_response([
'success' => true,
'post_id' => $post_id,
'edited' => $edited,
'layout' => $layout,
'position' => $position,
]);
}
public function get_acf_fields($request) {
$post_type = sanitize_text_field($request->get_param('post_type') ?? 'post');
// ACF not installed — return empty gracefully
if (!function_exists('acf_get_field_groups')) {
return rest_ensure_response([]);
}
$allowed_types = ['text', 'textarea', 'wysiwyg'];
$result = [];
// Get all field groups assigned to this post type
$groups = acf_get_field_groups(['post_type' => $post_type]);
foreach ($groups as $group) {
$fields = acf_get_fields($group['key']);
if (!$fields) continue;
foreach ($fields as $field) {
if (!in_array($field['type'], $allowed_types)) continue;
$result[] = [
'key' => $field['key'],
'name' => $field['name'],
'label' => $field['label'],
'type' => $field['type'],
'group' => $group['title'],
];
}
}
return rest_ensure_response($result);
}
/**
* Returns all publicly usable post types registered on the site,
* filtered to exclude WordPress internals.
*/
public function get_post_types() {
$excluded = [
'attachment', 'revision', 'nav_menu_item', 'custom_css',
'customize_changeset', 'oembed_cache', 'user_request',
'wp_block', 'wp_template', 'wp_template_part', 'wp_navigation',
'wp_font_face', 'wp_font_family', 'wp_global_styles'
];
$post_types = get_post_types(['public' => true], 'objects');
$result = [];
foreach ($post_types as $slug => $obj) {
if (in_array($slug, $excluded)) continue;
$result[] = [
'slug' => $slug,
'label' => $obj->labels->singular_name ?: $obj->label,
'plural_label' => $obj->label,
'supports_thumbnail' => post_type_supports($slug, 'thumbnail'),
'supports_excerpt' => post_type_supports($slug, 'excerpt'),
'is_woocommerce' => ($slug === 'product'),
];
}
return rest_ensure_response($result);
}
/**
* AICOBR_CLUSTER_PILLAR_PICKER_2026_08
*
* Search the site's own published pages and posts so the Content Strategist
* can offer them as the "pillar" a planned cluster will support. Returns the
* live permalink because that URL is snapshotted onto each planned brief and
* handed to the generation prompt — the spoke links up to a page that already
* exists, which is what makes v1 possible without ever editing that page.
*
* Read-only. Requires edit_posts, same as /post-types and /acf-fields.
*
* `excerpt` carries a trimmed slice of the page's own copy; the licence
* server feeds it into the planning prompt so the spokes it plans genuinely
* support THIS page rather than being guessed from the title alone.
*/
/**
* AICOBR_CLUSTER_PILLAR_CPT_2026_08
* Singular label for a post type, for the pillar picker. "Page" stays blank
* because a page is the unmarked default and labelling it adds noise.
*/
private function aicobr_post_type_label($post_type) {
if ($post_type === 'page') return '';
$obj = get_post_type_object($post_type);
if (!$obj) return '';
$label = $obj->labels->singular_name ?? $obj->label ?? $post_type;
return (string) $label;
}
public function get_linkable_pages($request) {
$search = sanitize_text_field((string) $request->get_param('s'));
$per_page = absint($request->get_param('per_page')) ?: 20;
$per_page = max(1, min(50, $per_page));
// AICOBR_CLUSTER_PILLAR_CPT_2026_08
// The pillar picker searched ['page','post'] only, so a site whose real
// landing pages are a custom post type could not choose them as pillars
// at all. On a vehicle site the intended pillars are exactly that: a
// "Makes" CPT holding "Sell my Ford van", a "Models" CPT holding "Sell
// my Ford Transit Custom". These are the pages the owner wants to rank
// and build clusters around, and ACB was hiding them.
//
// Every PUBLIC post type is now eligible. Public is the right test: a
// pillar has to be a real, indexable URL a reader can land on, which is
// exactly what public post types are and what internal ones (revisions,
// menu items, ACF field groups) are not. A site can still narrow or
// widen the set with the filter, which also carries the honest caveat
// for builder-managed types: see the note on the response.
$types = get_post_types(['public' => true], 'names');
unset($types['attachment']);
$types = array_values($types);
/**
* Filter which post types can be chosen as a cluster pillar.
*
* @param string[] $types Public post type names.
* @param string $context 'pillar_picker'
*/
$types = apply_filters('aicobr_pillar_post_types', $types, 'pillar_picker');
$types = array_values(array_filter((array) $types, 'post_type_exists'));
if (!$types) $types = ['page', 'post'];
$args = [
'post_type' => $types,
'post_status' => 'publish',
'posts_per_page' => $per_page,
'orderby' => 'title',
'order' => 'ASC',
'suppress_filters' => false,
// Skip the password-protected: their content can't be relied on as a
// public landing target for an internal link.
'has_password' => false,
];
if ($search !== '') {
$args['s'] = $search;
// Relevance beats alphabetical once the user is actually searching.
$args['orderby'] = 'relevance';
}
$posts = get_posts($args);
$result = [];
foreach ($posts as $p) {
// AICOBR_CLUSTER_PILLAR_PICKER_2026_08
// ACB-generated posts ARE offered. An earlier revision skipped them
// on the theory that a cluster under an ACB-written page was
// circular — that was wrong. Service Page is one of ACB's own
// content types, so generating a service page and then building its
// supporting cluster is the intended workflow, not an edge case.
// It is also how a user creates a pillar they do not already have.
// The only exclusion that matters is self-linking, which is handled
// at render time in aicobr_cluster_spokes().
$excerpt = has_excerpt($p) ? $p->post_excerpt : $p->post_content;
$excerpt = wp_strip_all_tags(strip_shortcodes((string) $excerpt), true);
$excerpt = trim(preg_replace('/\s+/', ' ', $excerpt));
$result[] = [
'id' => (int) $p->ID,
'title' => html_entity_decode(get_the_title($p), ENT_QUOTES, 'UTF-8'),
'url' => get_permalink($p),
'type' => $p->post_type,
// AICOBR_CLUSTER_PILLAR_CPT_2026_08 — a readable label so the
// picker can show "Make" or "Model" beside a result, since with
// CPTs in the list a bare title no longer tells the user what
// kind of thing they are choosing.
'type_label' => $this->aicobr_post_type_label($p->post_type),
'excerpt' => $this->aicobr_str_cap($excerpt, 2000), // AICOBR_MB_GUARD_2026_08
];
}
return rest_ensure_response($result);
}
/**
* POST /ai-content/v1/create-checkout
*
* Proxies a Stripe Checkout session request to the license server.
* Returns the Stripe Checkout URL for the browser to redirect to.
*/
public function create_checkout($request) {
$settings = get_option('aicobr_settings', []);
$license_key = $settings['license_key'] ?? '';
if (empty($license_key)) {
return new WP_Error('no_license', 'No license key configured.', ['status' => 403]);
}
$data = $request->get_json_params();
$bundle_id = sanitize_text_field($data['bundle_id'] ?? '');
$plan = sanitize_text_field($data['plan'] ?? '');
$interval = sanitize_text_field($data['interval'] ?? 'month');
if (empty($bundle_id) && empty($plan)) {
return new WP_Error('no_item', 'bundle_id or plan is required.', ['status' => 400]);
}
$admin_url = admin_url('admin.php?page=ai-content-bridge');
$license_url = self::get_server_url();
$payload = [
'license_key' => $license_key,
'success_url' => $admin_url,
'cancel_url' => $admin_url,
];
if (!empty($plan)) {
$payload['plan'] = $plan;
$payload['interval'] = ($interval === 'year') ? 'year' : 'month';
} else {
$payload['bundle_id'] = $bundle_id;
}
$response = wp_remote_post("{$license_url}/api/create-checkout-session", [
'timeout' => 20,
'headers' => ['Content-Type' => 'application/json'],
'body' => wp_json_encode($payload),
]);
if (is_wp_error($response)) {
return new WP_Error('server_error', 'Could not reach license server.', ['status' => 502]);
}
$body = json_decode(wp_remote_retrieve_body($response), true);
$code = wp_remote_retrieve_response_code($response);
if ($code !== 200 || empty($body['success'])) {
return new WP_Error('checkout_failed', $body['error'] ?? 'Checkout failed.', ['status' => 500]);
}
return rest_ensure_response([
'success' => true,
'checkout_url' => $body['checkout_url'],
]);
}
/**
* POST /ai-content/v1/upload-image
*
* Accepts a base64-encoded image from the license server,
* uploads it to the WordPress Media Library, and returns
* the attachment ID and URL.
*
* Secured by the same generate_secret used for /trigger-generate.
* No user credentials needed — runs inside WordPress context.
*/
public function upload_image($request) {
// Verify the shared secret (constant-time) so only our licence server can call this
if (!self::verify_secret($request)) {
return new WP_Error('unauthorized', 'Unauthorized', ['status' => 401]);
}
$data = $request->get_json_params();
$b64 = $data['image_b64'] ?? '';
$title = sanitize_text_field($data['title'] ?? 'featured-image');
$post_id = absint($data['post_id'] ?? 0);
if (empty($b64)) {
return new WP_Error('no_image', 'No image data provided', ['status' => 400]);
}
$image_data = base64_decode($b64);
if (strlen($image_data) < 100) {
return new WP_Error('invalid_image', 'Image data invalid or too small', ['status' => 400]);
}
// Provides wp_generate_attachment_metadata(), used below.
require_once(ABSPATH . 'wp-admin/includes/image.php');
$filename = 'acb-' . sanitize_file_name($title) . '-' . time() . '.png';
$upload = wp_upload_bits($filename, null, $image_data);
if ($upload['error']) {
aicobr_dbg('[ACB] upload_image: wp_upload_bits error: ' . $upload['error']);
return new WP_Error('upload_failed', $upload['error'], ['status' => 500]);
}
$attachment_id = wp_insert_attachment([
'post_mime_type' => 'image/png',
'post_title' => sanitize_file_name($title),
'post_status' => 'inherit',
], $upload['file'], $post_id ?: 0);
if (is_wp_error($attachment_id)) {
aicobr_dbg('[ACB] upload_image: wp_insert_attachment error: ' . $attachment_id->get_error_message());
return new WP_Error('attachment_failed', $attachment_id->get_error_message(), ['status' => 500]);
}
$meta = wp_generate_attachment_metadata($attachment_id, $upload['file']);
wp_update_attachment_metadata($attachment_id, $meta);
$attachment_url = wp_get_attachment_url($attachment_id);
aicobr_dbg("[ACB] upload_image: OK — attachment_id={$attachment_id}, url={$attachment_url}");
return rest_ensure_response([
'success' => true,
'attachment_id' => $attachment_id,
'url' => $attachment_url,
]);
}
/**
* POST /ai-content/v1/trigger-generate
*
* Called by the React dashboard when a user clicks Generate.
* Reads the license key and secret from wp_options (server-side only —
* these never travel through the browser), then forwards the generation
* request to the license server via a server-to-server HTTP call.
*
* The license server responds immediately (credits deducted, job queued),
* and posts back to /publish when generation is complete.
*/
/**
* Proxy to the licence server's /api/outline. Browser → WP REST → server,
* so the shared secret never travels through the browser. Returns the
* structured outline JSON for the review step.
*/
public function get_outline($request) {
$data = $request->get_json_params();
$generate_secret = self::get_secret();
// license_key + domain: cost attribution only (the licence server logs
// the outline's real Anthropic/Serper cost against this licence — see
// add-cost-columns.sql). Read server-side from the stored option; the
// key never travels through the browser. The server never gates the
// outline on these — an older payload without them still works.
$settings = get_option('aicobr_settings', []);
$payload = [
'keyword' => sanitize_text_field($data['keyword'] ?? ''),
'title' => sanitize_text_field($data['title'] ?? ''),
'serp_gl' => sanitize_text_field($data['serp_gl'] ?? ''),
'serp_hl' => sanitize_text_field($data['serp_hl'] ?? ''),
'license_key' => (string) ($settings['license_key'] ?? ''),
'domain' => wp_parse_url(home_url(), PHP_URL_HOST),
];
$license_server_url = self::get_server_url();
$response = wp_remote_post("{$license_server_url}/api/outline", [
'timeout' => 60,
'headers' => [
'Content-Type' => 'application/json',
'x-generate-secret' => $generate_secret,
],
'body' => wp_json_encode($payload),
]);
if (is_wp_error($response)) {
aicobr_dbg('[ACB] get_outline: wp_remote_post failed: ' . $response->get_error_message());
return new WP_Error('server_unreachable', 'Could not reach the outline server. Please try again.', ['status' => 502]);
}
$code = wp_remote_retrieve_response_code($response);
$body = json_decode(wp_remote_retrieve_body($response), true);
if ($code >= 400) {
return new WP_Error('outline_failed', $body['error'] ?? 'Outline generation failed.', ['status' => $code]);
}
return rest_ensure_response($body);
}
public function trigger_generate($request) {
$data = $request->get_json_params();
// Read credentials from saved settings — never from the request
$settings = get_option('aicobr_settings', []);
$license_key = $settings['license_key'] ?? '';
$generate_secret = self::get_secret();
if (empty($license_key)) {
return new WP_Error('no_license', 'No license key configured. Please visit Settings.', ['status' => 403]);
}
// Build the payload for the license server
$payload = [
'license_key' => $license_key,
'domain' => !empty($settings['domain']) ? $settings['domain'] : wp_parse_url(home_url(), PHP_URL_HOST),
'title' => sanitize_text_field($data['title'] ?? ''),
'primary_keyword' => sanitize_text_field($data['primary_keyword'] ?? ''),
'target_word_count' => intval($data['target_word_count'] ?? 1500),
'special_instructions'=> sanitize_textarea_field($data['special_instructions'] ?? ''),
'image_prompt' => sanitize_text_field($data['image_prompt'] ?? ''),
'image_style' => sanitize_text_field($data['image_style'] ?? 'professional'),
'include_youtube' => !empty($data['include_youtube']),
'include_image' => isset($data['include_image']) ? (bool)$data['include_image'] : true,
'publish_date' => sanitize_text_field($data['publish_date'] ?? ''),
'brand_voice' => $data['brand_voice'] ?? ($settings['brand_voice'] ?? ''),
'tone' => sanitize_text_field($data['tone'] ?? ($settings['tone'] ?? 'professional')),
'post_id' => !empty($data['post_id']) ? intval($data['post_id']) : null,
'post_type' => sanitize_text_field($data['post_type'] ?? 'post'),
'acf_field_key' => sanitize_text_field($data['acf_field_key'] ?? ''),
'content_type' => sanitize_text_field($data['content_type'] ?? 'blog_post'),
'content_type_meta' => $data['content_type_meta'] ?? [],
// AICOBR_INBODY_IMAGES_2026_08 — opt-in for in-body image
// suggestions. Costs no extra credit: the model returns descriptions
// alongside the SEO block on the call we are already making. The
// licence server clamps the count to the content type's ceiling.
'suggest_images' => !empty($data['suggest_images']),
'image_suggestion_count' => max(0, min(3, intval($data['image_suggestion_count'] ?? 3))),
// SEO outline-review flow — forwarded to the licence server's generateContent
'approved_outline' => (isset($data['approved_outline']) && is_array($data['approved_outline'])) ? $data['approved_outline'] : null,
'serp_gl' => sanitize_text_field($data['serp_gl'] ?? ''),
// Round-tripped so /publish can match the exact diary entry (not by
// title, which can collide when the same title is generated twice).
'entry_id' => isset($data['entry_id']) && $data['entry_id'] !== '' ? (string) $data['entry_id'] : null,
// LMS integration
'mailpoet_list_id' => intval($data['mailpoet_list_id'] ?? 0),
'tnp_list_id' => intval($data['tnp_list_id'] ?? 0),
'tec_start_date' => sanitize_text_field($data['tec_start_date'] ?? ''),
'tec_end_date' => sanitize_text_field($data['tec_end_date'] ?? ''),
'tec_cost' => sanitize_text_field($data['tec_cost'] ?? ''),
'tec_url' => esc_url_raw($data['tec_url'] ?? ''),
'tec_venue' => sanitize_text_field($data['tec_venue'] ?? ''),
'tec_organiser' => sanitize_text_field($data['tec_organiser'] ?? ''),
'lms_type' => sanitize_text_field($data['lms_type'] ?? ''),
'lms_course_id' => intval($data['lms_course_id'] ?? 0),
'lms_lesson_id' => intval($data['lms_lesson_id'] ?? 0),
'lms_section_id' => intval($data['lms_section_id'] ?? 0),
'ld_post_type' => sanitize_text_field($data['ld_post_type'] ?? ''),
];
// AICOBR_CLUSTER_PILLAR_2026_08
// Resolve the cluster pillar from the STORED diary entry, never from the
// request body. The browser has no business asserting which page a post
// links to, and the entries write-back doesn't carry these fields
// (they're server-owned). Re-resolve the permalink live so a slug change
// between planning and generation produces a correct link rather than
// relying on a WP redirect from the snapshotted URL.
$cluster_pillar = $this->aicobr_resolve_cluster_pillar($payload['entry_id']);
if ($cluster_pillar) {
$payload['cluster_pillar'] = $cluster_pillar;
aicobr_dbg('[ACB] cluster: linking up to pillar #' . $cluster_pillar['post_id'] . ' — ' . $cluster_pillar['url']);
}
// Inject active writing style profile if one is set
$active_id = $settings['active_style_profile_id'] ?? '';
$profiles = $settings['writing_style_profiles'] ?? [];
aicobr_dbg('[ACB] active_style_profile_id: ' . $active_id);
aicobr_dbg('[ACB] writing_style_profiles count: ' . count($profiles));
aicobr_dbg('[ACB] profiles raw: ' . print_r($profiles, true));
if ( !empty($active_id) && !empty($profiles) ) {
foreach ( $profiles as $profile ) {
aicobr_dbg('[ACB] checking profile id: ' . ($profile['id'] ?? 'NO_ID'));
if ( ($profile['id'] ?? '') === $active_id && !empty($profile['extracted']) ) {
$payload['style_profile'] = [
'name' => $profile['name'] ?? '',
'profile' => $profile['extracted'],
];
aicobr_dbg('[ACB] Style profile injected: ' . ($profile['name'] ?? 'unnamed'));
break;
}
}
}
$license_server_url = self::get_server_url();
// Server-to-server POST — no CORS, no browser involvement
$response = wp_remote_post("{$license_server_url}/api/generate", [
'timeout' => 30, // wait up to 30s for the server to accept the job
'headers' => [
'Content-Type' => 'application/json',
'x-generate-secret' => $generate_secret,
],
'body' => wp_json_encode($payload),
]);
if (is_wp_error($response)) {
aicobr_dbg('[ACB] trigger_generate: wp_remote_post failed: ' . $response->get_error_message());
return new WP_Error(
'server_unreachable',
'Could not reach the generation server. Please try again.',
['status' => 502]
);
}
$status_code = wp_remote_retrieve_response_code($response);
$body = json_decode(wp_remote_retrieve_body($response), true);
if ($status_code === 402) {
return new WP_Error('no_credits', $body['error'] ?? 'Insufficient credits.', [
'status' => 402,
'credits_remaining' => $body['credits_remaining'] ?? 0,
'plan_tier' => $settings['plan_tier'] ?? 'free',
]);
}
if ($status_code === 403) {
$code = $body['code'] ?? 'invalid_license';
return new WP_Error($code, $body['error'] ?? 'Access denied.', [
'status' => 403,
'plan_tier' => $settings['plan_tier'] ?? 'free',
]);
}
if ($status_code !== 200 || empty($body['success'])) {
$msg = $body['error'] ?? 'Generation server returned an unexpected error.';
aicobr_dbg("[ACB] trigger_generate: server error {$status_code}: {$msg}");
return new WP_Error('generate_failed', $msg, ['status' => 500]);
}
// Job accepted — mark the diary entry as generating with an authoritative
// server-side start time. This (not browser localStorage) is the source of
// truth the polling UI and the read-time timeout both rely on.
$is_regen = !empty($payload['post_id']);
$this->aicobr_apply_entry_fields(
[
'entry_id' => $data['entry_id'] ?? null,
'title' => $payload['title'],
'post_id' => $payload['post_id'],
],
[
'status' => $is_regen ? 'regenerating' : 'generating',
'stage' => 'queued',
'started_ts' => time(),
'error_message' => '',
]
);
// Job accepted — return success to React so it can update the UI
return rest_ensure_response([
'success' => true,
'message' => 'Generation started.',
'credits_deducted' => $body['credits_deducted'] ?? null,
]);
}
/**
* POST /ai-content/v1/trigger-regenerate-image
*
* Kicks off an IMAGE-ONLY regeneration for an existing entry. Mirrors
* trigger_generate: the browser calls this WP route, and WP forwards to the
* licence server's /api/regenerate-image server-to-server, so the shared
* secret and licence key never travel through the browser. Resolves the
* entry's title + post_id from stored state (the client only sends entry_id
* plus the optional "describe the new image" prompt/style), so the licence
* server has what it needs to generate and append the image.
*/
public function trigger_regenerate_image($request) {
$data = $request->get_json_params();
$entry_id = isset($data['entry_id']) && $data['entry_id'] !== '' ? (string) $data['entry_id'] : '';
if ($entry_id === '') {
return new WP_Error('missing_entry', 'entry_id is required.', ['status' => 400]);
}
// Credentials read server-side only — never from the request/browser.
$settings = get_option('aicobr_settings', []);
$license_key = $settings['license_key'] ?? '';
$generate_secret = self::get_secret();
if (empty($license_key)) {
return new WP_Error('no_license', 'No license key configured. Please visit Settings.', ['status' => 403]);
}
// Resolve title + post_id from the stored entry so the licence server can
// generate an appropriately-titled image and append it to the right post.
$entries = get_option('aicobr_entries', []);
$entry = null;
foreach ($entries as $e) {
if (isset($e['id']) && (string) $e['id'] === $entry_id) { $entry = $e; break; }
}
if (!$entry) {
return new WP_Error('not_found', 'Entry not found.', ['status' => 404]);
}
$payload = [
'license_key' => $license_key,
'entry_id' => $entry_id,
'domain' => !empty($settings['domain']) ? $settings['domain'] : wp_parse_url(home_url(), PHP_URL_HOST),
'title' => sanitize_text_field($entry['title'] ?? ''),
'post_id' => !empty($entry['post_id']) ? intval($entry['post_id']) : 0,
// The "describe the new image" box (optional). Falls back to the
// entry's stored image_style so a silent regen still matches the look.
'image_prompt' => sanitize_text_field($data['image_prompt'] ?? ''),
'image_style' => sanitize_text_field($data['image_style'] ?? ($entry['image_style'] ?? 'professional')),
'serp_gl' => sanitize_text_field($settings['serp_gl'] ?? ''),
// Auto-active with easy revert via the picker (see image-regen scope).
'set_active' => true,
];
// AICOBR_INBODY_IMAGES_2026_08
// In-body request. Everything below is additive: with no role the
// payload is byte-identical to the previous build and the licence server
// performs a featured-image regeneration exactly as before.
if (($data['role'] ?? '') === 'body') {
$payload['role'] = 'body';
$payload['layout'] = sanitize_text_field($data['layout'] ?? 'band');
$payload['position'] = sanitize_key($data['position'] ?? 'after-heading');
$payload['section_heading'] = sanitize_text_field($data['section_heading'] ?? '');
$payload['alt_text'] = sanitize_text_field($data['alt_text'] ?? '');
$payload['caption'] = sanitize_text_field($data['caption'] ?? '');
$payload['suggestion_id'] = sanitize_text_field($data['suggestion_id'] ?? '');
// Style anchor: the brief's own featured-image prompt, so a set of
// in-body images reads as one commission rather than four unrelated
// stock photos. Read from the STORED entry, not the request.
$payload['style_anchor'] = sanitize_text_field($entry['image_prompt'] ?? '');
$payload['set_active'] = false;
}
$license_server_url = self::get_server_url();
$response = wp_remote_post("{$license_server_url}/api/regenerate-image", [
'timeout' => 30, // wait up to 30s for the server to ACCEPT the job (image runs async after)
'headers' => [
'Content-Type' => 'application/json',
'x-generate-secret' => $generate_secret,
],
'body' => wp_json_encode($payload),
]);
if (is_wp_error($response)) {
aicobr_dbg('[ACB] trigger_regenerate_image: wp_remote_post failed: ' . $response->get_error_message());
return new WP_Error('server_unreachable', 'Could not reach the image server. Please try again.', ['status' => 502]);
}
$status_code = wp_remote_retrieve_response_code($response);
$body = json_decode(wp_remote_retrieve_body($response), true);
// Map the licence server's status codes to the SAME error codes the
// client's generate handler already understands (no_credits → upgrade
// modal, etc.), plus no_image_plan for a no-image tier.
if ($status_code === 402) {
return new WP_Error('no_credits', $body['error'] ?? 'Insufficient credits.', [
'status' => 402,
'credits_remaining' => $body['credits_remaining'] ?? 0,
'plan_tier' => $settings['plan_tier'] ?? 'free',
]);
}
if ($status_code === 400 && ($body['code'] ?? '') === 'no_image_plan') {
return new WP_Error('no_image_plan', $body['error'] ?? "Images aren't included on your plan.", ['status' => 400]);
}
if ($status_code === 403) {
$code = $body['code'] ?? 'invalid_license';
return new WP_Error($code, $body['error'] ?? 'Access denied.', [
'status' => 403,
'plan_tier' => $settings['plan_tier'] ?? 'free',
]);
}
if ($status_code !== 200 || empty($body['success'])) {
$msg = $body['error'] ?? 'Image server returned an unexpected error.';
aicobr_dbg("[ACB] trigger_regenerate_image: server error {$status_code}: {$msg}");
return new WP_Error('regenerate_failed', $msg, ['status' => 500]);
}
// Job accepted — the licence server deducted the credit and will append
// the new image to image_history when the async pipeline finishes. The
// client's polling loop picks up the new image from /entries.
return rest_ensure_response([
'success' => true,
'message' => 'Image generation started.',
'credits_deducted' => $body['credits_deducted'] ?? 1,
]);
}
public function handle_publish($request) {
aicobr_dbg('=== AI-CONTENT PUBLISH v3.5 (Stable Base + Variable Credits) ===');
$data = $request->get_json_params();
if (empty($data)) $data = $request->get_body_params();
$title = sanitize_text_field($data['title'] ?? '');
$content = $data['content'] ?? '';
$status = sanitize_text_field($data['status'] ?? 'draft');
$post_id = absint($data['post_id'] ?? 0);
// ── Failure callback from the licence server ───────────────────────────
// generate.js posts { status: 'failed', error } when its pipeline throws.
// Mark the matched entry failed authoritatively and stop — no post to create.
if ($status === 'failed') {
$this->aicobr_apply_entry_fields(
['title' => $title, 'post_id' => $post_id],
[
'status' => 'failed',
'stage' => 'failed',
'error_message' => sanitize_text_field($data['error'] ?? 'Generation failed. Please retry.'),
'started_ts' => null,
]
);
return rest_ensure_response(['success' => true, 'failed' => true]);
}
$ai_model = sanitize_text_field($data['ai_model'] ?? '');
$tokens_used = intval($data['tokens_used'] ?? 0);
$image_cost = floatval($data['image_cost'] ?? 0);
$execution_time = intval($data['execution_time'] ?? 0);
$include_youtube = !empty($data['include_youtube']);
$image_style = sanitize_text_field($data['image_style'] ?? 'professional');
$youtube_video = sanitize_text_field($data['youtube_video'] ?? '');
$youtube_video_2 = sanitize_text_field($data['youtube_video_2'] ?? '');
$youtube_video_3 = sanitize_text_field($data['youtube_video_3'] ?? '');
$post_type = sanitize_text_field($data['post_type'] ?? 'post');
$acf_field_key = sanitize_text_field($data['acf_field_key'] ?? '');
// Log what we received to help diagnose issues
aicobr_dbg("=== PUBLISH: title={$title}, content_len=" . strlen($content) . ", has_b64=" . (!empty($data['featured_image_b64']) ? 'yes' : 'no') . ", has_bytearray=" . (!empty($data['featured_image']) && is_array($data['featured_image']) ? 'yes' : 'no') . ", status_in={$status} ===");
aicobr_dbg("[ACB] LMS debug: lms_type=" . ($data['lms_type'] ?? 'MISSING') . " | ld_post_type=" . ($data['ld_post_type'] ?? 'MISSING') . " | lms_course_id=" . ($data['lms_course_id'] ?? 'MISSING') . " | has_quiz_data=" . (!empty($data['quiz_data']) ? 'yes(' . count($data['quiz_data']['questions'] ?? []) . ' questions)' : 'no'));
// SEO fields from the licence server
$seo_title = sanitize_text_field($data['seo_title'] ?? '');
$seo_description = sanitize_text_field($data['seo_description'] ?? '');
$seo_focus_keyword = sanitize_text_field($data['seo_focus_keyword'] ?? '');
$seo_og_title = sanitize_text_field($data['seo_og_title'] ?? $seo_title);
$seo_og_description = sanitize_text_field($data['seo_og_description'] ?? $seo_description);
// SEO score + checklist from the licence server's deterministic scorer.
$seo_score = (isset($data['seo_score']) && $data['seo_score'] !== null) ? intval($data['seo_score']) : null;
$seo_report = (isset($data['seo_report']) && is_array($data['seo_report'])) ? $data['seo_report'] : null;
// AICOBR_INBODY_IMAGES_2026_08 — in-body image suggestions (descriptions
// only; nothing generated, no credit spent). Sanitised and bounded before it
// touches the diary option.
$image_suggestions = $this->aicobr_sanitise_image_suggestions($data['image_suggestions'] ?? null);
// === STRIP SEO BLOCK FROM CONTENT ===
// Claude appends ---SEO_DATA_START--- ... ---SEO_DATA_END--- to the article.
// We parse those values here on the PHP side (so server-side parsing is optional),
// then strip the block entirely so it never appears in the saved post content.
if (strpos($content, '---SEO_DATA_START---') !== false) {
// Extract the SEO block
preg_match('/---SEO_DATA_START---(.*?)---SEO_DATA_END---/s', $content, $seo_block_match);
if (!empty($seo_block_match[1])) {
$seo_block = $seo_block_match[1];
// Only use parsed values if the fields weren't already sent as discrete payload fields
if (empty($seo_title)) {
preg_match('/SEO_TITLE:\s*(.+)/m', $seo_block, $m);
$seo_title = sanitize_text_field(trim($m[1] ?? ''));
}
if (empty($seo_description)) {
preg_match('/SEO_DESCRIPTION:\s*(.+)/m', $seo_block, $m);
$seo_description = sanitize_text_field(trim($m[1] ?? ''));
}
if (empty($seo_focus_keyword)) {
preg_match('/SEO_FOCUS_KEYWORD:\s*(.+)/m', $seo_block, $m);
$seo_focus_keyword = sanitize_text_field(trim($m[1] ?? ''));
}
if (empty($seo_og_title)) {
preg_match('/SEO_OG_TITLE:\s*(.+)/m', $seo_block, $m);
$seo_og_title = sanitize_text_field(trim($m[1] ?? $seo_title));
}
if (empty($seo_og_description)) {
preg_match('/SEO_OG_DESCRIPTION:\s*(.+)/m', $seo_block, $m);
$seo_og_description = sanitize_text_field(trim($m[1] ?? $seo_description));
}
}
// Strip the entire SEO block (and any surrounding horizontal rules) from content
$content = preg_replace('/\n*-{3,}\s*SEO_DATA_START-{3,}.*?-{3,}\s*SEO_DATA_END-{3,}\n*/s', '', $content);
$content = trim($content);
}
// === END STRIP SEO BLOCK ===
// === STRIP IMAGE-SUGGESTIONS BLOCK ===
// AICOBR_INBODY_IMAGES_2026_08
// routes/generate.js already strips this server-side, so in the normal case
// there is nothing here to remove. This exists for the mismatched-version
// case the three-deployment-path rule makes possible: a plugin updated ahead
// of the licence server (or a stray emission by the model on a route that
// did not ask for suggestions) must never publish a post with a raw JSON
// block visible at the bottom. Matches an UNTERMINATED block too, so a
// truncated response cannot leak half an array into the page.
if (strpos($content, 'IMAGE_SUGGESTIONS_START') !== false) {
$content = preg_replace(
'/\n*-{3,}\s*IMAGE_SUGGESTIONS_START-{3,}.*?(?:-{3,}\s*IMAGE_SUGGESTIONS_END-{3,}|$)\n*/s',
'',
$content
);
$content = trim((string) $content);
aicobr_dbg('[ACB] stripped a stray IMAGE_SUGGESTIONS block from content');
}
// === END STRIP IMAGE-SUGGESTIONS BLOCK ===
// If LMS integration is active use the correct post type upfront
$early_ld_post_type = sanitize_text_field($data['ld_post_type'] ?? '');
// ACB_LMS_TYPE_DERIVE_2026_07_22: belt-and-braces — an L&D content type must
// NEVER land as a plain blog post when an LMS is installed. If ld_post_type
// arrives empty (lost anywhere along the round trip), derive it here from
// content_type + whichever LMS is active, instead of falling through to 'post'.
if ($early_ld_post_type === '') {
$aicobr_lms_ct_map = [
'course_overview' => 'course', 'training_module' => 'lesson',
'case_study_ld' => 'lesson', 'explainer_guide' => 'lesson',
'quiz_assessment' => 'llms_quiz', 'workshop_guide' => 'lesson',
'sop' => 'lesson',
];
$aicobr_ct_early = sanitize_text_field($data['content_type'] ?? '');
if (isset($aicobr_lms_ct_map[$aicobr_ct_early])) {
$aicobr_llms_on = class_exists('LLMS') || class_exists('LifterLMS') || function_exists('llms') || defined('LLMS_PLUGIN_FILE');
$aicobr_lp_on = class_exists('LearnPress') || defined('LP_PLUGIN_FILE') || post_type_exists('lp_course');
$aicobr_lms_engine = sanitize_text_field($data['lms_type'] ?? '');
if ($aicobr_lms_engine === '') {
$aicobr_lms_engine = $aicobr_llms_on ? 'lifterlms' : ($aicobr_lp_on ? 'learnpress' : '');
}
if ($aicobr_lms_engine === 'lifterlms') {
$early_ld_post_type = $aicobr_lms_ct_map[$aicobr_ct_early];
$data['lms_type'] = 'lifterlms';
} elseif ($aicobr_lms_engine === 'learnpress') {
$aicobr_lp_map = ['course' => 'lp_course', 'lesson' => 'lp_lesson', 'llms_quiz' => 'lp_quiz'];
$early_ld_post_type = $aicobr_lp_map[$aicobr_lms_ct_map[$aicobr_ct_early]];
$data['lms_type'] = 'learnpress';
}
if ($early_ld_post_type !== '') {
$data['ld_post_type'] = $early_ld_post_type;
aicobr_dbg('[ACB] LMS derive: ld_post_type was empty for ' . $aicobr_ct_early . ' — derived ' . $early_ld_post_type);
}
}
}
// Events Calendar: force tribe_events post type for event_description
$tec_content_type = sanitize_text_field($data['content_type'] ?? '');
$tec_meta_input = [];
if ($tec_content_type === 'event_description' && post_type_exists('tribe_events')) {
$post_type = 'tribe_events';
// Compute the event's date meta NOW, before the post is created, so it can
// be passed into wp_insert_post() via meta_input. TEC 6's custom-tables
// layer hooks save_post and validates the event the moment the post is
// created — if the date meta isn't already present it throws
// "start_date_utc requires a value" and never builds the occurrence row.
// Writing the dates at insert time makes that first sync succeed.
$e_start = sanitize_text_field($data['tec_start_date'] ?? '');
$e_end = sanitize_text_field($data['tec_end_date'] ?? '');
if ($e_start !== '') {
$e_fmt_start = str_replace('T', ' ', $e_start) . ':00';
$e_fmt_end = $e_end !== '' ? str_replace('T', ' ', $e_end) . ':00' : $e_fmt_start;
$e_tz_string = function_exists('wp_timezone_string') ? wp_timezone_string() : (get_option('timezone_string') ?: 'UTC');
$e_duration = 0;
$e_start_utc = '';
$e_end_utc = '';
try {
$e_tz = new DateTimeZone($e_tz_string ?: 'UTC');
$e_utc = new DateTimeZone('UTC');
$e_so = new DateTime($e_fmt_start, $e_tz);
$e_eo = new DateTime($e_fmt_end, $e_tz);
$e_duration = max(0, $e_eo->getTimestamp() - $e_so->getTimestamp());
$e_start_utc = (clone $e_so)->setTimezone($e_utc)->format('Y-m-d H:i:s');
$e_end_utc = (clone $e_eo)->setTimezone($e_utc)->format('Y-m-d H:i:s');
} catch (Exception $e) {
aicobr_dbg('[ACB] TEC early date parse failed: ' . $e->getMessage());
}
$tec_meta_input = [
'_EventStartDate' => $e_fmt_start,
'_EventEndDate' => $e_fmt_end,
'_EventStartDateUTC' => $e_start_utc,
'_EventEndDateUTC' => $e_end_utc,
'_EventDuration' => $e_duration,
'_EventTimezone' => $e_tz_string ?: 'UTC',
];
}
}
$early_lms_course_id = intval($data['lms_course_id'] ?? 0);
$early_lms_type = sanitize_text_field($data['lms_type'] ?? '');
// Special case: Course Overview + existing course selected
// → update that course's content rather than creating a new lp_course post
$is_course_overview_update = (
$early_ld_post_type === 'lp_course' &&
$early_lms_course_id > 0 &&
$early_lms_type === 'learnpress'
);
if ($is_course_overview_update) {
// Override post_id so publish handler updates the existing course
$data['post_id'] = $early_lms_course_id;
aicobr_dbg('[ACB] Course Overview: updating existing course ' . $early_lms_course_id);
} elseif (!empty($early_ld_post_type)) {
// Trust known LMS post types even if post_type_exists() returns false —
// LLMS and LearnPress register their types on 'init' but WP may not have
// fired that hook yet in the REST context, so the check gives a false
// negative. The known list covers every LMS type ACB can target.
$known_lms_types = ['llms_quiz', 'llms_question', 'lesson', 'course',
'lp_quiz', 'lp_lesson', 'lp_course'];
if (in_array($early_ld_post_type, $known_lms_types, true)
|| post_type_exists($early_ld_post_type)) {
$post_type = $early_ld_post_type;
}
}
// Validate post_type — allow known LMS types through even if not yet registered
$known_lms_types_fallback = ['llms_quiz', 'lesson', 'course',
'lp_quiz', 'lp_lesson', 'lp_course'];
if (!post_type_exists($post_type)
&& !in_array($post_type, $known_lms_types_fallback, true)) {
$post_type = 'post';
}
// Process content
aicobr_dbg('=== CONTENT: length before processing=' . strlen($content) . ' ===');
$aicobr_ct = sanitize_text_field($data['content_type'] ?? 'blog_post');
$content = $this->processGeneratedContent($content, $include_youtube, $aicobr_ct, $post_type);
aicobr_dbg('=== CONTENT: length after processing=' . strlen($content) . ' ===');
// === HERO IMAGE ===
// AICOBR_HERO_IMAGE_2026_08
// Whether a featured image appears above the article is a THEME decision,
// and plenty of themes never render the thumbnail — so a post that paid a
// credit for an image could publish looking like it had none. Prepending the
// shortcode puts it in the content, where it always shows.
//
// A shortcode rather than an for two reasons: the user can delete one
// line if their theme already displays the thumbnail (otherwise they get it
// twice), and it reads the CURRENT thumbnail at render time, so regenerating
// the featured image updates the hero with nothing to reconcile.
//
// Prepended OUTSIDE the .acb-content wrapper, which is correct — the hero is
// not part of the article body, and .acb-fig is styled unscoped for exactly
// this case (the same reason [content_cluster] is not scoped).
//
// Gated on a setting so a user whose theme already shows the thumbnail can
// turn it off once instead of deleting a line on every post. Only prepended
// when an image is actually expected: the shortcode renders nothing without
// a thumbnail, but adding a dead line to every image-less post is noise.
$aicobr_settings_pub = get_option('aicobr_settings', []);
$aicobr_hero_on = !array_key_exists('auto_hero_image', $aicobr_settings_pub)
|| !empty($aicobr_settings_pub['auto_hero_image']);
$aicobr_expect_image = !empty($data['featured_image_attachment_id'])
|| !empty($data['featured_image_b64'])
|| ($post_id && has_post_thumbnail($post_id));
if ($aicobr_hero_on
&& $aicobr_expect_image
&& !has_shortcode($content, 'content_featured_image')
&& !has_shortcode($content, 'aicobr_featured_image')) {
$content = '[content_featured_image ratio="16:9"]' . "\n\n" . $content;
aicobr_dbg('=== HERO: featured-image shortcode prepended ===');
}
// === END HERO IMAGE ===
// AICOBR_WP_SLASH_2026_08 — wp_insert_post/wp_update_post unslash the array
// before the DB write, so the free-text fields must be slashed here or they
// lose a backslash level. $content is the generated body (from REST
// get_json_params, which is unslashed) and $title is the generated title.
// Only the free-text fields need it; post_status/post_type/dates carry no
// backslashes. meta_input, if set below, is only TEC date/timezone strings —
// no backslashes there either, so it is left as-is.
$post_data = [
'post_title' => wp_slash($title),
'post_content' => wp_slash($content),
'post_status' => $status,
'post_type' => $post_type,
];
// === SCHEDULED PUBLICATION ===
// AICOBR_SCHEDULE_PUBLISH_2026_08
// A brief carries a publish_date and the diary calls it "scheduled", but the
// post itself was always created as a plain draft dated now — so the date
// the user planned around existed only inside ACB. Opening the post in
// WordPress showed "Draft" with no date anywhere, and the only way to find
// out when it was meant to go live was to navigate back to the diary.
//
// The post is now genuinely scheduled: post_status 'future' plus the date,
// so WordPress itself shows "Scheduled for ..." and publishes it on the day.
//
// Guards, in order:
// - only when the licence server asked for a draft. A 'failed' callback
// returns long before this, and an explicit 'publish' is honoured.
// - only when the entry's date resolves to a FUTURE local datetime.
// - on an UPDATE, only when the existing post is still a draft-ish or
// already-scheduled post. If the user has published it (or trashed it),
// regenerating must never drag it back out of the index.
$aicobr_schedule_at = null;
if ($status === 'draft') {
$aicobr_schedule_at = $this->aicobr_resolve_schedule_datetime($data['entry_id'] ?? null);
}
if ($aicobr_schedule_at !== null) {
$aicobr_existing_status = ($post_id && get_post($post_id)) ? get_post_status($post_id) : '';
$aicobr_may_schedule = ($aicobr_existing_status === '')
|| in_array($aicobr_existing_status, ['draft', 'auto-draft', 'pending', 'future'], true);
if ($aicobr_may_schedule) {
$post_data['post_status'] = 'future';
$post_data['post_date'] = $aicobr_schedule_at;
$post_data['post_date_gmt'] = get_gmt_from_date($aicobr_schedule_at);
// wp_update_post ignores post_date on an existing post unless this
// is set — without it a regeneration would silently keep the old
// date while flipping the status to 'future'.
$post_data['edit_date'] = true;
aicobr_dbg("=== SCHEDULE: post set to publish at {$aicobr_schedule_at} (site time) ===");
} else {
$aicobr_schedule_at = null;
aicobr_dbg("=== SCHEDULE: skipped — existing post status is '{$aicobr_existing_status}' ===");
}
}
// === END SCHEDULED PUBLICATION ===
// For TEC events, seed the date meta at creation so TEC's custom-tables sync
// (which fires on save_post during wp_insert_post) validates and builds the
// occurrence row on the first save instead of erroring on a missing date.
if (!empty($tec_meta_input)) {
$post_data['meta_input'] = $tec_meta_input;
}
if ($post_id && get_post($post_id)) {
// Only update posts AI Content Bridge created — prevents overwriting
// arbitrary posts (e.g. the home page) even if the shared secret leaks.
if (!self::aicobr_is_generated_post($post_id)) {
// Legacy posts created before the _aicobr_generated meta existed won't
// carry it. Fall back to the content diary: if this post_id is
// recorded on an ACB entry, the post is genuinely ours — backfill the
// meta and proceed (this also lets the styled front-end CSS load on it).
if ($this->aicobr_post_in_diary($post_id)) {
update_post_meta($post_id, '_aicobr_generated', 1);
} else {
return new WP_Error('forbidden', 'Target post was not created by AI Content Bridge.', ['status' => 403]);
}
}
$post_data['ID'] = $post_id;
$updated_id = wp_update_post($post_data, true);
} else {
$updated_id = wp_insert_post($post_data, true);
if (!is_wp_error($updated_id)) {
update_post_meta($updated_id, '_aicobr_generated', 1);
}
}
if (is_wp_error($updated_id)) {
aicobr_dbg('Post save failed: ' . $updated_id->get_error_message());
return new WP_Error('save_failed', $updated_id->get_error_message(), ['status' => 500]);
}
// === SEO META ===
$seo_data = [
'title' => $seo_title,
'description' => $seo_description,
'focus_keyword' => $seo_focus_keyword,
'og_title' => $seo_og_title,
'og_description' => $seo_og_description,
];
$seo_plugin = $this->apply_seo_meta($updated_id, $seo_data);
aicobr_dbg("=== SEO META: plugin=$seo_plugin ===");
// === END SEO META ===
// === SEO SCORE ===
// Store the checklist as JSON post meta so the Content Diary can render the
// score ring + fix list, and so the score is queryable per post.
if ($seo_report !== null) {
update_post_meta($updated_id, '_acb_seo_report', wp_json_encode($seo_report));
update_post_meta($updated_id, '_acb_seo_score', $seo_score);
aicobr_dbg("=== SEO SCORE: {$seo_score}/100 ===");
}
// === END SEO SCORE ===
// === CONTENT FINGERPRINT ===
// AICOBR_INBODY_INSERT_2026_08
// Record a hash of exactly what ACB wrote. In-body image insertion compares
// against this before editing the body: a match means the post is still
// ACB's prose and real markup can be inserted; a mismatch means the user has
// been in there, so ACB offers a shortcode instead of rearranging their
// edits. Written here because $content is the post-processed HTML actually
// saved — the same value the comparison will read back.
update_post_meta($updated_id, '_aicobr_content_hash', self::aicobr_content_hash($content));
// === END CONTENT FINGERPRINT ===
// === CLUSTER INTERNAL LINK ===
// AICOBR_CLUSTER_PILLAR_2026_08
// Record whether the generated article really did link up to its pillar. We
// COUNT the link in the finished content rather than assuming the model
// complied — the Monthly Report figure has to be a fact, not an intention.
// Stored as one meta key, which also serves as the idempotency marker if a
// re-generation path is ever added.
$cluster_pillar_pub = $this->aicobr_resolve_cluster_pillar($data['entry_id'] ?? null);
if ($cluster_pillar_pub) {
$pillar_url = $cluster_pillar_pub['url'];
// Match the href regardless of trailing-slash or entity-encoded output.
$needle = rtrim($pillar_url, '/');
$haystack = html_entity_decode((string) $content, ENT_QUOTES, 'UTF-8');
$link_count = substr_count($haystack, 'href="' . $needle)
+ substr_count($haystack, "href='" . $needle);
update_post_meta($updated_id, '_aicobr_cluster_links', wp_json_encode([
'cluster_id' => sanitize_text_field($cluster_pillar_pub['cluster_id'] ?? ''),
'pillar_id' => (int) $cluster_pillar_pub['post_id'],
'pillar_url' => esc_url_raw($pillar_url),
'count' => (int) $link_count,
'linked_at' => current_time('mysql'),
]));
aicobr_dbg("=== CLUSTER LINK: {$link_count} link(s) to pillar #{$cluster_pillar_pub['post_id']} ===");
// AICOBR_CLUSTER_SHORTCODE_2026_08 — this spoke has just gone live, so
// the pillar's [content_cluster] block is now stale. Clear it immediately
// rather than waiting out the transient: the whole point of the block is
// that new spokes appear in it as they publish.
$this->aicobr_bust_cluster_cache($cluster_pillar_pub['post_id']);
}
// === END CLUSTER INTERNAL LINK ===
// AICOBR_LINK_FORECAST_2026_08
// A new published post changes the SOURCE POOL, which is what every cached
// scan and every cached forecast was computed against. Until now the salt
// only moved when ACB wrote INTO a source (insert and undo), because that
// was the only thing that could stale a scan. A forecast depends on what is
// published, so publishing has to move it too.
//
// Without this, the compounding lift is exactly what breaks: publish a spoke
// and its siblings keep reporting the pre-publication number for fifteen
// minutes, so the one moment the feature exists to show appears not to work.
// Same shape as the two bugs that shipped the name guard inert: the cache
// encodes a world state, and something changed the world without telling it.
$this->aicobr_bump_inbound_gen();
// === ACF FIELD ===
// If the diary entry had an ACF field selected, write the generated content into it.
// We also look it up from the saved entry in case it wasn't sent in the payload.
if (empty($acf_field_key)) {
$all_entries_tmp = get_option('aicobr_entries', []);
foreach ($all_entries_tmp as $e) {
$title_match = strtolower(trim($e['title'] ?? '')) === strtolower(trim($title));
$id_match = ($post_id && isset($e['post_id']) && intval($e['post_id']) === $post_id);
if (($title_match || $id_match) && !empty($e['acf_field_key'])) {
$acf_field_key = sanitize_text_field($e['acf_field_key']);
break;
}
}
}
if (!empty($acf_field_key) && function_exists('update_field')) {
update_field($acf_field_key, $content, $updated_id);
aicobr_dbg("=== ACF FIELD WRITTEN: key=$acf_field_key, post=$updated_id ===");
}
// === END ACF FIELD ===
// === LMS INTEGRATION ===
// Extract LMS fields once — used both to set correct post_type upfront
// (handled above before wp_insert_post) and to set associations after.
$quiz_data = $data['quiz_data'] ?? null;
$lms_type = sanitize_text_field($data['lms_type'] ?? '');
$lms_course = intval($data['lms_course_id'] ?? 0);
$lms_lesson = intval($data['lms_lesson_id'] ?? 0);
$lms_section = intval($data['lms_section_id'] ?? 0);
$ld_post_type = sanitize_text_field($data['ld_post_type'] ?? '');
// ── LifterLMS ──────────────────────────────────────────────────────────
$lifterlms_active = class_exists('LLMS') || class_exists('LifterLMS') || defined('LLMS_PLUGIN_FILE');
if ($lms_type === 'lifterlms' && $lifterlms_active && $updated_id && !is_wp_error($updated_id)) {
if ($ld_post_type === 'lesson' && $lms_course) {
wp_update_post(['ID' => $updated_id, 'post_type' => 'lesson']);
update_post_meta($updated_id, '_llms_parent_course', $lms_course);
} elseif ($ld_post_type === 'course' && $lms_course) {
// Update the selected course directly — do not create a duplicate
// AICOBR_WP_SLASH_2026_08 — wp_update_post unslashes before writing;
// $content is the generated body, and the excerpt is derived from it,
// so both free-text fields are slashed to preserve backslashes.
wp_update_post([
'ID' => $lms_course,
'post_content' => wp_slash($content),
'post_excerpt' => wp_slash(wp_trim_words(wp_strip_all_tags($content), 40, '…')),
]);
if ($updated_id && $updated_id !== $lms_course) {
wp_delete_post($updated_id, true);
}
$updated_id = $lms_course;
} elseif ($ld_post_type === 'llms_quiz' && $lms_lesson) {
// LLMS v6+: scalar _llms_quiz meta on lesson + quiz_enabled flag
// post_parent must be the lesson for the builder to find the quiz
wp_update_post([
'ID' => $updated_id,
'post_type' => 'llms_quiz',
'post_status' => 'publish',
'post_parent' => $lms_lesson,
]);
update_post_meta($updated_id, '_llms_parent_course', $lms_course);
update_post_meta($updated_id, '_llms_lesson_id', $lms_lesson); // quiz→lesson back-ref (required by the builder; without it the quiz is an orphan and questions fail to load)
update_post_meta($lms_lesson, '_llms_quiz', $updated_id);
update_post_meta($lms_lesson, '_llms_quiz_enabled', 'yes');
} elseif ($lms_course) {
wp_update_post([
'ID' => $updated_id,
'post_parent' => $lms_course,
'post_type' => $ld_post_type ?: 'lesson',
]);
update_post_meta($updated_id, '_llms_parent_course', $lms_course);
}
aicobr_dbg("[ACB] LifterLMS: post $updated_id ($ld_post_type) → course $lms_course" . ($lms_section ? ", section $lms_section" : "") . ($lms_lesson ? ", lesson $lms_lesson" : ""));
}
// ── LearnPress ────────────────────────────────────────────────────────
$lp_active = class_exists('LearnPress') || defined('LP_PLUGIN_FILE') || post_type_exists('lp_course');
if ($lms_type === 'learnpress' && $lp_active && $updated_id && !is_wp_error($updated_id)) {
global $wpdb;
$tbl_sections = $wpdb->prefix . 'learnpress_sections';
$tbl_section_items = $wpdb->prefix . 'learnpress_section_items';
if ($ld_post_type === 'lp_lesson' || $ld_post_type === 'lp_quiz') {
// Find or create a section for this course
$section_id = (int) $wpdb->get_var($wpdb->prepare(
"SELECT section_id FROM $tbl_sections WHERE section_course_id = %d ORDER BY section_order ASC LIMIT 1",
$lms_course
));
if (!$section_id && $lms_course) {
$wpdb->insert($tbl_sections, [
'section_name' => 'Generated Content',
'section_course_id' => $lms_course,
'section_order' => 1,
'section_description' => '',
], ['%s', '%d', '%d', '%s']);
$section_id = (int) $wpdb->insert_id;
}
if ($section_id) {
// Check not already added
$exists = $wpdb->get_var($wpdb->prepare(
"SELECT section_item_id FROM $tbl_section_items WHERE section_id = %d AND item_id = %d",
$section_id, $updated_id
));
if (!$exists) {
$next_order = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COALESCE(MAX(item_order), 0) + 1 FROM $tbl_section_items WHERE section_id = %d",
$section_id
));
$wpdb->insert($tbl_section_items, [
'section_id' => $section_id,
'item_id' => $updated_id,
'item_order' => $next_order,
'item_type' => $ld_post_type,
], ['%d', '%d', '%d', '%s']);
}
}
// Clear LP4 caches
wp_cache_delete($lms_course, 'lp_course');
wp_cache_delete($updated_id, $ld_post_type);
if (function_exists('learn_press_reset_auto_draft')) learn_press_reset_auto_draft($lms_course);
delete_post_meta($lms_course, '_lp_course_count_items');
} elseif ($ld_post_type === 'lp_course') {
update_post_meta($updated_id, '_lp_associated_course', $lms_course);
}
aicobr_dbg("[ACB] LearnPress: post $updated_id ($ld_post_type) → course $lms_course, section $section_id");
}
// === QUIZ POPULATION ===
$is_quiz_type = in_array($ld_post_type, ['lp_quiz', 'llms_quiz']);
$is_lesson_type = in_array($ld_post_type, ['lesson', 'lp_lesson']);
$content_type = sanitize_text_field($data['content_type'] ?? '');
$is_training_mod = ($content_type === 'training_module');
if (!empty($quiz_data) && $updated_id && !is_wp_error($updated_id)) {
if ($is_quiz_type) {
// Explicit quiz content type — populate the quiz post directly
aicobr_dbg('[ACB] Quiz: ' . count($quiz_data['questions'] ?? []) . ' questions for ' . $updated_id);
if ($ld_post_type === 'lp_quiz') {
$this->populate_learnpress_quiz($updated_id, $quiz_data, $lms_course);
} elseif ($ld_post_type === 'llms_quiz') {
$this->populate_lifterlms_quiz($updated_id, $quiz_data, $lms_course);
}
} elseif ($is_lesson_type && $is_training_mod && $lms_course) {
// Training Module with knowledge check data — auto-create an attached quiz.
// The lesson ($updated_id) is already saved. Now create a sibling quiz post
// and link it to the lesson so students get an auto-graded knowledge check.
aicobr_dbg('[ACB] Training Module: auto-creating knowledge check quiz for lesson ' . $updated_id);
$quiz_title = get_the_title($updated_id) . ' — Knowledge Check';
$quiz_intro = sanitize_text_field($quiz_data['quiz_intro'] ?? '');
if ($lms_type === 'lifterlms') {
// Create the llms_quiz post parented to this lesson
$quiz_id = wp_insert_post([
'post_title' => $quiz_title,
'post_type' => 'llms_quiz',
'post_status' => 'publish',
'post_parent' => $updated_id, // lesson is the parent
'post_excerpt' => $quiz_intro,
]);
if (!is_wp_error($quiz_id)) {
update_post_meta($quiz_id, '_llms_parent_course', $lms_course);
update_post_meta($quiz_id, '_llms_lesson_id', $updated_id); // quiz→lesson back-ref (required by the builder)
update_post_meta($updated_id, '_llms_quiz', $quiz_id);
update_post_meta($updated_id, '_llms_quiz_enabled', 'yes');
$this->populate_lifterlms_quiz($quiz_id, $quiz_data, $lms_course);
aicobr_dbg('[ACB] LLMS knowledge check quiz ' . $quiz_id . ' created and linked to lesson ' . $updated_id);
} else {
aicobr_dbg('[ACB] LLMS knowledge check quiz creation failed: ' . $quiz_id->get_error_message());
}
} elseif ($lms_type === 'learnpress') {
// Create the lp_quiz post and slot it into the same course section
$quiz_id = wp_insert_post([
'post_title' => $quiz_title,
'post_type' => 'lp_quiz',
'post_status' => 'publish',
'post_excerpt' => $quiz_intro,
]);
if (!is_wp_error($quiz_id)) {
// Find the section the lesson is already in and add the quiz after it
global $wpdb;
$tbl_sections = $wpdb->prefix . 'learnpress_sections';
$tbl_section_items = $wpdb->prefix . 'learnpress_section_items';
$section_id = (int) $wpdb->get_var($wpdb->prepare(
"SELECT section_id FROM $tbl_section_items WHERE item_id = %d LIMIT 1",
$updated_id
));
if (!$section_id) {
// Fallback: first section of the course
$section_id = (int) $wpdb->get_var($wpdb->prepare(
"SELECT section_id FROM $tbl_sections WHERE section_course_id = %d ORDER BY section_order ASC LIMIT 1",
$lms_course
));
}
if ($section_id) {
$next_order = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COALESCE(MAX(item_order), 0) + 1 FROM $tbl_section_items WHERE section_id = %d",
$section_id
));
$wpdb->insert($tbl_section_items, [
'section_id' => $section_id,
'item_id' => $quiz_id,
'item_order' => $next_order,
'item_type' => 'lp_quiz',
], ['%d', '%d', '%d', '%s']);
}
$this->populate_learnpress_quiz($quiz_id, $quiz_data, $lms_course);
wp_cache_delete($lms_course, 'lp_course');
aicobr_dbg('[ACB] LP knowledge check quiz ' . $quiz_id . ' created and linked to lesson ' . $updated_id);
} else {
aicobr_dbg('[ACB] LP knowledge check quiz creation failed: ' . $quiz_id->get_error_message());
}
}
}
}
// === MAILPOET INTEGRATION ===
$mailpoet_list_id = intval($data['mailpoet_list_id'] ?? 0);
$mp_ct = $data['content_type'] ?? '';
if ($mailpoet_list_id && $mp_ct === 'email_newsletter' && class_exists('\MailPoet\API\API')) {
global $wpdb;
$mp_prefix = esc_sql($wpdb->prefix . 'mailpoet_'); // table-name identifier; esc_sql satisfies the DB linter
$mp_subject = $title;
$mp_preview = '';
$mp_body = $content;
if (preg_match('/^SUBJECT LINE:\s*(.+)$/m', $content, $sm)) { $mp_subject = sanitize_text_field(trim($sm[1])); }
if (preg_match('/^PREVIEW TEXT:\s*(.+)$/m', $content, $pm)) { $mp_preview = sanitize_text_field(trim($pm[1])); }
if (preg_match('/---\s*EMAIL BODY\s*---\s*(.+)/s', $content, $bm)) { $mp_body = trim($bm[1]); }
$mp_html = wpautop($mp_body);
$mp_newsletter_body = wp_json_encode([
'content' => [
'type' => 'container', 'columnLayout' => false, 'orientation' => 'vertical',
'image' => ['src' => null, 'display' => 'scale'],
'styles' => ['block' => ['backgroundColor' => 'transparent']],
'blocks' => [['type' => 'container', 'columnLayout' => false, 'orientation' => 'horizontal',
'image' => ['src' => null, 'display' => 'scale'],
'styles' => ['block' => ['backgroundColor' => 'transparent']],
'blocks' => [['type' => 'container', 'columnLayout' => false, 'orientation' => 'vertical',
'image' => ['src' => null, 'display' => 'scale'],
'styles' => ['block' => ['backgroundColor' => 'transparent']],
'blocks' => [['type' => 'text', 'text' => $mp_html]],
]]]],
],
'globalStyles' => [
'links' => ['fontColor' => '#0073aa', 'fontUnderline' => 'true'],
'text' => ['fontColor' => '#000000', 'fontFamily' => 'Arial', 'fontSize' => '15px', 'lineHeight' => '1.6'],
'backgroundColor' => '#ffffff',
],
]);
// Reuse the draft created for this entry on a previous run (regeneration)
// rather than piling up duplicates. Tracked in a WP option keyed by entry_id,
// because email newsletters do NOT keep a WP post (it's deleted below), so we
// can't rely on post meta. entry_id round-trips on regenerate.
$mp_entry_key = (isset($data['entry_id']) && $data['entry_id'] !== '') ? (string) $data['entry_id'] : '';
$mp_map = get_option('aicobr_mailpoet_drafts', []);
if (!is_array($mp_map)) { $mp_map = []; }
$existing_mp_id = ($mp_entry_key !== '' && !empty($mp_map[$mp_entry_key])) ? intval($mp_map[$mp_entry_key]) : 0;
// Backwards-compat: drafts created before this change tracked the id in post meta
if (!$existing_mp_id && $updated_id) {
$existing_mp_id = intval(get_post_meta($updated_id, '_acb_mailpoet_id', true));
}
$mp_exists = $existing_mp_id && $wpdb->get_var($wpdb->prepare(
"SELECT id FROM {$mp_prefix}newsletters WHERE id = %d AND deleted_at IS NULL", $existing_mp_id
));
if ($mp_exists) {
$wpdb->update(
$mp_prefix . 'newsletters',
[
'subject' => $mp_subject,
'preheader' => $mp_preview,
'body' => $mp_newsletter_body,
'status' => 'draft',
'updated_at' => current_time('mysql'),
],
['id' => $existing_mp_id],
['%s','%s','%s','%s','%s'],
['%d']
);
// Re-point the list link to the currently selected segment.
$wpdb->delete($mp_prefix . 'newsletter_segment', ['newsletter_id' => $existing_mp_id], ['%d']);
$wpdb->insert($mp_prefix . 'newsletter_segment',
['newsletter_id' => $existing_mp_id, 'segment_id' => $mailpoet_list_id],
['%d','%d']
);
$newsletter_id = $existing_mp_id;
aicobr_dbg('[ACB] MailPoet draft updated in place: id=' . $existing_mp_id . ' subject=' . $mp_subject);
} else {
$inserted = $wpdb->insert(
$mp_prefix . 'newsletters',
[
'type' => 'standard',
'subject' => $mp_subject,
'preheader' => $mp_preview,
'body' => $mp_newsletter_body,
'status' => 'draft',
'sender_name' => get_bloginfo('name'),
'sender_address' => get_option('admin_email'),
'reply_to_name' => get_bloginfo('name'),
'reply_to_address' => get_option('admin_email'),
'created_at' => current_time('mysql'),
'updated_at' => current_time('mysql'),
'deleted_at' => null,
],
['%s','%s','%s','%s','%s','%s','%s','%s','%s','%s','%s','%s']
);
if ($inserted) {
$newsletter_id = $wpdb->insert_id;
$wpdb->insert($mp_prefix . 'newsletter_segment',
['newsletter_id' => $newsletter_id, 'segment_id' => $mailpoet_list_id],
['%d','%d']
);
aicobr_dbg('[ACB] MailPoet draft created: id=' . $newsletter_id . ' subject=' . $mp_subject);
} else {
aicobr_dbg('[ACB] MailPoet insert failed: ' . $wpdb->last_error);
}
}
// Persist the entry→draft mapping so a later regeneration updates this same
// draft instead of creating a duplicate, then delete the placeholder WP post —
// an email newsletter lives in MailPoet, not as a WordPress post.
if (!empty($newsletter_id)) {
$aicobr_mp_newsletter_id = (int) $newsletter_id; // surfaced onto the diary entry below
if ($mp_entry_key !== '') {
$mp_map[$mp_entry_key] = (int) $newsletter_id;
update_option('aicobr_mailpoet_drafts', $mp_map, false);
}
if ($updated_id && !is_wp_error($updated_id)) {
wp_delete_post($updated_id, true);
aicobr_dbg('[ACB] MailPoet: deleted placeholder WP post ' . $updated_id . ' (newsletter lives in MailPoet)');
$updated_id = 0; // entry will store post_id=0; downstream blocks skip
}
}
}
// === END MAILPOET INTEGRATION ===
// === NEWSLETTER PLUGIN INTEGRATION ===
$tnp_list_id = intval($data['tnp_list_id'] ?? 0);
if ($tnp_list_id && $content_type === 'email_newsletter' && (class_exists('Newsletter') || class_exists('TNP') || defined('NEWSLETTER_VERSION'))) {
global $wpdb;
$tnp_subject = $title;
$tnp_preview = '';
$tnp_body = $content;
if (preg_match('/^SUBJECT LINE:\s*(.+)$/m', $content, $sm)) { $tnp_subject = sanitize_text_field(trim($sm[1])); }
if (preg_match('/^PREVIEW TEXT:\s*(.+)$/m', $content, $pm)) { $tnp_preview = sanitize_text_field(trim($pm[1])); }
if (preg_match('/---\s*EMAIL BODY\s*---\s*(.+)/s', $content, $bm)) { $tnp_body = trim($bm[1]); }
$tnp_html = wpautop($tnp_body);
// Build list targets: comma-separated list IDs as integers
$tnp_lists = serialize([$tnp_list_id => 1]);
$tnp_table = $wpdb->prefix . 'newsletter_emails';
// Reuse the draft created for this entry on a previous run (regeneration)
// rather than piling up duplicates. Tracked in a WP option keyed by entry_id,
// because the newsletter does NOT keep a WP post (it's deleted below), so we
// can't rely on post meta. entry_id round-trips on regenerate.
$tnp_entry_key = (isset($data['entry_id']) && $data['entry_id'] !== '') ? (string) $data['entry_id'] : '';
$tnp_map = get_option('aicobr_tnp_drafts', []);
if (!is_array($tnp_map)) { $tnp_map = []; }
$existing_tnp_id = ($tnp_entry_key !== '' && !empty($tnp_map[$tnp_entry_key])) ? intval($tnp_map[$tnp_entry_key]) : 0;
// Backwards-compat: drafts created before this change tracked the id in post meta
if (!$existing_tnp_id && $updated_id) {
$existing_tnp_id = intval(get_post_meta($updated_id, '_acb_tnp_id', true));
}
$tnp_exists = $existing_tnp_id && $wpdb->get_var($wpdb->prepare(
"SELECT id FROM {$tnp_table} WHERE id = %d", $existing_tnp_id
));
$tnp_newsletter_id = 0; // resolved id (existing or freshly inserted); stays 0 on failure
// The newsletter_emails schema varies across TNP versions: e.g. 9.x has no
// 'preheader' column (the preheader is a composer block, not a table field),
// which made a hard-coded INSERT fail with "Unknown column 'preheader'".
// Read the real columns once and only write keys that actually exist, so the
// write survives schema differences instead of erroring and leaving a stray
// WP post behind.
$tnp_cols = [];
foreach ((array) $wpdb->get_col("DESC `{$tnp_table}`", 0) as $c) { $tnp_cols[$c] = true; }
$tnp_fmt = [
'subject' => '%s', 'message' => '%s', 'message_text' => '%s', 'preheader' => '%s',
'subject_line' => '%s', 'status' => '%s', 'type' => '%s', 'lists' => '%s',
'created' => '%s', 'updated' => '%s', 'send_on' => '%d', 'sent' => '%d',
'total' => '%d', 'error' => '%d', 'query' => '%s', 'editor' => '%d',
'track' => '%d', 'options' => '%s',
];
// Build a candidate row, then drop any key the table doesn't have.
$filter_row = function (array $row) use ($tnp_cols, $tnp_fmt) {
$data = []; $fmt = [];
foreach ($row as $k => $v) {
if (!empty($tnp_cols[$k])) { $data[$k] = $v; $fmt[] = $tnp_fmt[$k] ?? '%s'; }
}
return [$data, $fmt];
};
if ($tnp_exists) {
list($u_data, $u_fmt) = $filter_row([
'subject' => $tnp_subject,
'message' => $tnp_html,
'message_text' => wp_strip_all_tags($tnp_body),
'preheader' => $tnp_preview,
'lists' => serialize([$tnp_list_id]),
'updated' => current_time('mysql'),
]);
$wpdb->update($tnp_table, $u_data, ['id' => $existing_tnp_id], $u_fmt, ['%d']);
$tnp_newsletter_id = $existing_tnp_id;
aicobr_dbg('[ACB] Newsletter Plugin draft updated in place: id=' . $existing_tnp_id . ' subject=' . $tnp_subject);
} else {
list($i_data, $i_fmt) = $filter_row([
'subject' => $tnp_subject,
'message' => $tnp_html,
'message_text' => wp_strip_all_tags($tnp_body),
'preheader' => $tnp_preview,
'status' => 'new',
'type' => 'message',
'lists' => serialize([$tnp_list_id]),
'created' => current_time('mysql'),
'send_on' => 0,
'sent' => 0,
'total' => 0,
'error' => 0,
'query' => '',
]);
$inserted = $wpdb->insert($tnp_table, $i_data, $i_fmt);
if ($inserted) {
$tnp_newsletter_id = (int) $wpdb->insert_id;
aicobr_dbg('[ACB] Newsletter Plugin draft created: id=' . $tnp_newsletter_id . ' subject=' . $tnp_subject . ' cols=' . implode(',', array_keys($i_data)));
} else {
aicobr_dbg('[ACB] Newsletter Plugin insert failed: ' . $wpdb->last_error);
}
}
// Persist the entry→draft mapping so a later regeneration updates this same
// draft instead of creating a duplicate, then delete the placeholder WP post —
// a newsletter lives in the Newsletter plugin, not as a WordPress post.
if (!empty($tnp_newsletter_id)) {
$aicobr_tnp_newsletter_id = (int) $tnp_newsletter_id; // surfaced onto the diary entry below
if ($tnp_entry_key !== '') {
$tnp_map[$tnp_entry_key] = (int) $tnp_newsletter_id;
update_option('aicobr_tnp_drafts', $tnp_map, false);
}
if ($updated_id && !is_wp_error($updated_id)) {
wp_delete_post($updated_id, true);
aicobr_dbg('[ACB] Newsletter Plugin: deleted placeholder WP post ' . $updated_id . ' (newsletter lives in the Newsletter plugin)');
$updated_id = 0; // entry will store post_id=0; downstream blocks skip
}
}
}
// === END NEWSLETTER PLUGIN INTEGRATION ===
// === THE EVENTS CALENDAR INTEGRATION ===
$tec_active = class_exists('Tribe__Events__Main') || post_type_exists('tribe_events');
if ($tec_active && $content_type === 'event_description' && $updated_id && !is_wp_error($updated_id)) {
$tec_start = sanitize_text_field($data['tec_start_date'] ?? '');
$tec_end = sanitize_text_field($data['tec_end_date'] ?? '');
$tec_cost = sanitize_text_field($data['tec_cost'] ?? '');
$tec_url = esc_url_raw($data['tec_url'] ?? '');
$tec_venue = sanitize_text_field($data['tec_venue'] ?? '');
$tec_org = sanitize_text_field($data['tec_organiser'] ?? '');
// Convert datetime-local (YYYY-MM-DDTHH:MM) to TEC's local format (YYYY-MM-DD HH:MM:SS).
$fmt_start = !empty($tec_start) ? str_replace('T', ' ', $tec_start) . ':00' : '';
$fmt_end = !empty($tec_end) ? str_replace('T', ' ', $tec_end) . ':00' : $fmt_start;
// Resolve / create the venue and organiser posts first so we can pass their
// IDs to the ORM (and fall back to meta below).
$tec_venue_id = 0;
if ($tec_venue) {
$existing_venue = get_posts(['post_type' => 'tribe_venue', 'title' => $tec_venue, 'posts_per_page' => 1, 'post_status' => 'publish']);
if (!empty($existing_venue)) {
$tec_venue_id = (int) $existing_venue[0]->ID;
} else {
$venue_id = wp_insert_post(['post_title' => $tec_venue, 'post_type' => 'tribe_venue', 'post_status' => 'publish']);
if (!is_wp_error($venue_id)) $tec_venue_id = (int) $venue_id;
}
}
$tec_org_id = 0;
if ($tec_org) {
$existing_org = get_posts(['post_type' => 'tribe_organizer', 'title' => $tec_org, 'posts_per_page' => 1, 'post_status' => 'publish']);
if (!empty($existing_org)) {
$tec_org_id = (int) $existing_org[0]->ID;
} else {
$org_id = wp_insert_post(['post_title' => $tec_org, 'post_type' => 'tribe_organizer', 'post_status' => 'publish']);
if (!is_wp_error($org_id)) $tec_org_id = (int) $org_id;
}
}
// Compute the full date meta set TEC needs. Writing only the local
// _EventStartDate/_EventEndDate makes an event show in the admin list but
// sort/display wrongly in calendar views, because TEC reads the *UTC* keys
// (and _EventDuration / _EventTimezone) for queries. We derive those from
// the site timezone here so a meta-only write still renders correctly.
$tec_tz_string = function_exists('wp_timezone_string') ? wp_timezone_string() : (get_option('timezone_string') ?: 'UTC');
$tec_duration = 0;
$fmt_start_utc = '';
$fmt_end_utc = '';
if ($fmt_start) {
try {
$tz = new DateTimeZone($tec_tz_string ?: 'UTC');
$utc = new DateTimeZone('UTC');
$startObj = new DateTime($fmt_start, $tz);
$endObj = new DateTime($fmt_end ?: $fmt_start, $tz);
$tec_duration = max(0, $endObj->getTimestamp() - $startObj->getTimestamp());
$fmt_start_utc = (clone $startObj)->setTimezone($utc)->format('Y-m-d H:i:s');
$fmt_end_utc = (clone $endObj)->setTimezone($utc)->format('Y-m-d H:i:s');
} catch (Exception $e) {
aicobr_dbg('[ACB] TEC date parse failed: ' . $e->getMessage());
}
}
// Preferred path: TEC's ORM (tribe_events()) sets every derived field AND
// populates the custom tables (tec_events / tec_occurrences) that TEC 6+
// uses for the calendar. Writing raw meta alone does not create those rows.
$tec_orm_ok = false;
if ($fmt_start && function_exists('tribe_events')) {
try {
$orm_args = [
'start_date' => $fmt_start,
'end_date' => $fmt_end ?: $fmt_start,
'timezone' => $tec_tz_string ?: 'UTC',
'status' => get_post_status($updated_id) ?: 'draft',
];
if ($tec_cost !== '') $orm_args['cost'] = $tec_cost;
if ($tec_url !== '') $orm_args['url'] = $tec_url;
if ($tec_venue_id) $orm_args['venue'] = $tec_venue_id;
if ($tec_org_id) $orm_args['organizer'] = $tec_org_id;
$orm_result = tribe_events()->where('id', $updated_id)->set_args($orm_args)->save();
$tec_orm_ok = !empty($orm_result);
aicobr_dbg('[ACB] TEC ORM update ' . ($tec_orm_ok ? 'ok' : 'returned empty') . ' for post ' . $updated_id);
} catch (Exception $e) {
aicobr_dbg('[ACB] TEC ORM update threw: ' . $e->getMessage() . ' — falling back to meta');
} catch (Error $e) {
aicobr_dbg('[ACB] TEC ORM update fatal: ' . $e->getMessage() . ' — falling back to meta');
}
}
// Fallback (and belt-and-braces on TEC 5.x): write the complete meta set
// directly. Harmless if the ORM already set these — values are identical.
if (!$tec_orm_ok) {
if ($fmt_start) update_post_meta($updated_id, '_EventStartDate', $fmt_start);
if ($fmt_end) update_post_meta($updated_id, '_EventEndDate', $fmt_end);
if ($fmt_start_utc) update_post_meta($updated_id, '_EventStartDateUTC', $fmt_start_utc);
if ($fmt_end_utc) update_post_meta($updated_id, '_EventEndDateUTC', $fmt_end_utc);
if ($fmt_start) update_post_meta($updated_id, '_EventDuration', $tec_duration);
update_post_meta($updated_id, '_EventTimezone', $tec_tz_string ?: 'UTC');
if (class_exists('Tribe__Events__Timezones') && $fmt_start) {
update_post_meta($updated_id, '_EventTimezoneAbbr', Tribe__Events__Timezones::wp_timezone_abbr($fmt_start));
}
if ($tec_cost !== '') update_post_meta($updated_id, '_EventCost', $tec_cost);
if ($tec_url !== '') update_post_meta($updated_id, '_EventURL', $tec_url);
if ($tec_venue_id) update_post_meta($updated_id, '_EventVenueID', $tec_venue_id);
if ($tec_org_id) update_post_meta($updated_id, '_EventOrganizerID', $tec_org_id);
}
// These display flags are safe to set regardless of path.
update_post_meta($updated_id, '_EventShowMap', 'yes');
update_post_meta($updated_id, '_EventShowMapLink', 'yes');
update_post_meta($updated_id, '_EventOrigin', 'events-calendar');
aicobr_dbg('[ACB] TEC event saved: post ' . $updated_id . ' start=' . $fmt_start . ' utc=' . $fmt_start_utc . ' dur=' . $tec_duration . ' venue_id=' . $tec_venue_id . ' org_id=' . $tec_org_id . ' via=' . ($tec_orm_ok ? 'orm' : 'meta'));
// The event post was first created (wp_insert_post) BEFORE its date meta
// existed, so TEC's save_post hooks ran against a dateless event and never
// built the occurrence row its single-event template / front-end preview
// needs — which is why a freshly generated draft event couldn't be
// previewed until you manually re-saved it in the editor. Now that all the
// meta is in place, fire one more save so TEC reprocesses the event with
// its dates present. This is the programmatic equivalent of clicking
// "Save draft", and it makes the draft previewable immediately. (ACB does
// not hook save_post, so this cannot recurse.)
if (!$tec_orm_ok && $fmt_start) {
clean_post_cache($updated_id);
wp_update_post(['ID' => $updated_id]);
aicobr_dbg('[ACB] TEC event re-saved to rebuild occurrence data (post ' . $updated_id . ')');
}
}
// === END THE EVENTS CALENDAR INTEGRATION ===
// === END QUIZ POPULATION ===
// === END LMS INTEGRATION ===
// === WOOCOMMERCE META ===
// If this is a product, read saved WooCommerce fields from the diary entry
// and apply them as post meta. We do NOT receive these from the licence server —
// they were saved on the entry when the user created the content request.
if ($post_type === 'product' && class_exists('WooCommerce')) {
// Find the diary entry to read saved woo fields
$all_entries = get_option('aicobr_entries', []);
$woo_entry = null;
$clean_title = strtolower(trim($title));
foreach ($all_entries as $e) {
$title_match = strtolower(trim($e['title'] ?? '')) === $clean_title;
$id_match = ($post_id && isset($e['post_id']) && intval($e['post_id']) === $post_id);
if ($title_match || $id_match) {
$woo_entry = $e;
break;
}
}
$woo_price = isset($woo_entry['woo_price']) ? floatval($woo_entry['woo_price']) : null;
$woo_sale_price = isset($woo_entry['woo_sale_price']) ? floatval($woo_entry['woo_sale_price']) : null;
$woo_sku = isset($woo_entry['woo_sku']) ? sanitize_text_field($woo_entry['woo_sku']) : '';
if ($woo_price !== null) {
update_post_meta($updated_id, '_price', $woo_price);
update_post_meta($updated_id, '_regular_price', $woo_price);
}
if ($woo_sale_price !== null && $woo_sale_price > 0) {
update_post_meta($updated_id, '_sale_price', $woo_sale_price);
// If sale price is set and lower, use it as the active price
if ($woo_price === null || $woo_sale_price < $woo_price) {
update_post_meta($updated_id, '_price', $woo_sale_price);
}
}
if (!empty($woo_sku)) {
update_post_meta($updated_id, '_sku', $woo_sku);
}
// Required WooCommerce defaults
update_post_meta($updated_id, '_visibility', 'visible');
update_post_meta($updated_id, '_stock_status', 'instock');
update_post_meta($updated_id, '_manage_stock', 'no');
update_post_meta($updated_id, '_virtual', 'no');
update_post_meta($updated_id, '_downloadable', 'no');
aicobr_dbg("=== WOOCOMMERCE META SET for post $updated_id ===");
}
// === END WOOCOMMERCE META ===
// Use SEO description as the diary excerpt — more useful than an auto-trimmed snippet
$excerpt = !empty($seo_description) ? $seo_description : wp_trim_words(wp_strip_all_tags($content), 30);
$featured_image_id = 0;
$featured_image_url = '';
// === FEATURED IMAGE ===
// The license server now uploads the image directly to the WP Media Library
// and sends back just the attachment ID — keeping the /publish payload small.
// Legacy base64/byte-array formats are also supported as fallbacks.
$attachment_id_from_server = absint($data['featured_image_attachment_id'] ?? 0);
if ($attachment_id_from_server > 0 && get_post($attachment_id_from_server)) {
// Native path: server uploaded the image, we just set it as thumbnail
set_post_thumbnail($updated_id, $attachment_id_from_server);
$featured_image_id = $attachment_id_from_server;
$featured_image_url = wp_get_attachment_url($attachment_id_from_server);
aicobr_dbg("=== IMAGE: set attachment {$attachment_id_from_server} as thumbnail ===");
} else {
// Fallback: base64 string (legacy or if WP media upload failed on server)
$image_data = '';
if (!empty($data['featured_image_b64'])) {
$image_data = base64_decode($data['featured_image_b64']);
aicobr_dbg('=== IMAGE: fallback base64 decode, length=' . strlen($image_data) . ' ===');
} elseif (!empty($data['featured_image']) && is_array($data['featured_image'])) {
foreach ($data['featured_image'] as $byte) $image_data .= chr($byte);
aicobr_dbg('=== IMAGE: fallback byte array, length=' . strlen($image_data) . ' ===');
}
if (strlen($image_data) > 500) {
$filename = 'featured-' . $updated_id . '-' . time() . '.png';
$upload = wp_upload_bits($filename, null, $image_data);
if (!$upload['error']) {
// Provides wp_generate_attachment_metadata(), used below.
require_once(ABSPATH . 'wp-admin/includes/image.php');
$att_id = wp_insert_attachment([
'post_mime_type' => 'image/png',
'post_title' => sanitize_file_name($title),
'post_status' => 'inherit',
], $upload['file'], $updated_id);
if (!is_wp_error($att_id)) {
wp_update_attachment_metadata($att_id, wp_generate_attachment_metadata($att_id, $upload['file']));
set_post_thumbnail($updated_id, $att_id);
$featured_image_id = $att_id;
$featured_image_url = wp_get_attachment_url($att_id);
aicobr_dbg("=== IMAGE: fallback upload OK, attachment_id={$att_id} ===");
}
} else {
aicobr_dbg('=== IMAGE: fallback upload error: ' . $upload['error'] . ' ===');
}
} else {
// No image data at all — use existing thumbnail if post already has one
$featured_image_url = has_post_thumbnail($updated_id)
? get_the_post_thumbnail_url($updated_id, 'medium') : '';
aicobr_dbg('=== IMAGE: no image data received ===');
}
}
// === END FEATURED IMAGE ===
// ============================================
// STATUS UPDATE - EXACT STABLE LOGIC FROM 30 APRIL
// ============================================
// Serialise the entries read-modify-write so two near-simultaneous publishes
// can't clobber each other's status write (the lost-update that left a card
// stuck on "regenerating"). The re-read below runs inside the lock for freshness.
$aicobr_pub_lock = $this->aicobr_lock_entries();
$entries = get_option('aicobr_entries', []);
$entryUpdated = false;
$cleanTitle = strtolower(trim(preg_replace('/\s+/', ' ', $title)));
// Resolve which diary entry this result belongs to, by PRIORITY:
// exact entry_id (round-tripped through the licence server) > post_id >
// normalised title. This stops a duplicate title from attaching the result to
// an older same-titled entry and orphaning the one that actually ran.
$publish_entry_id = isset($data['entry_id']) && $data['entry_id'] !== '' ? (string) $data['entry_id'] : null;
$matchId = null;
if ($publish_entry_id !== null) {
foreach ($entries as $e) {
if (isset($e['id']) && (string) $e['id'] === $publish_entry_id) { $matchId = (string) $e['id']; break; }
}
}
if ($matchId === null && $post_id) {
foreach ($entries as $e) {
if (isset($e['post_id']) && intval($e['post_id']) === $post_id) { $matchId = (string) $e['id']; break; }
}
}
if ($matchId === null) {
foreach ($entries as $e) {
if (strtolower(trim(preg_replace('/\s+/', ' ', $e['title'] ?? ''))) === $cleanTitle) { $matchId = (string) $e['id']; break; }
}
}
foreach ($entries as &$entry) {
if ($matchId !== null && (string) ($entry['id'] ?? '') === $matchId) {
$entry['post_id'] = $updated_id;
$entry['status'] = 'generated';
$entry['stage'] = 'generated';
$entry['started_ts'] = null;
$entry['updated_at'] = current_time('mysql');
$entry['generationStartTime'] = null;
$entry['excerpt'] = $excerpt;
$entry['post_type'] = $post_type;
// Email newsletters live in MailPoet (no WP post) — surface the draft id
// so the diary card can show a "View in MailPoet" button.
if (!empty($aicobr_mp_newsletter_id)) {
$entry['mailpoet_newsletter_id'] = (int) $aicobr_mp_newsletter_id;
}
// Same for the Newsletter (TNP) plugin — surface the draft id so the
// diary card can show a "View in Newsletter" button.
if (!empty($aicobr_tnp_newsletter_id)) {
$entry['tnp_newsletter_id'] = (int) $aicobr_tnp_newsletter_id;
}
if ($featured_image_id) {
$new_image_url = wp_get_attachment_url($featured_image_id);
// Always update the active featured image to the latest one
$entry['featured_image_id'] = $featured_image_id;
$entry['featured_image'] = $new_image_url;
// Accumulate into image_history — never overwrite previous images
if (!isset($entry['image_history']) || !is_array($entry['image_history'])) {
$entry['image_history'] = [];
}
// Mark all previous images as not active
foreach ($entry['image_history'] as &$img) {
$img['is_active'] = false;
}
unset($img);
// Append the new image as the active one
$entry['image_history'][] = [
'attachment_id' => $featured_image_id,
'url' => $new_image_url,
'generated_at' => current_time('mysql'),
'is_active' => true,
];
} elseif (empty($entry['featured_image']) && !empty($featured_image_url)) {
// This publish carried no NEW attachment id. Only fill the card
// image if the entry has none AND we resolved a url (e.g. from an
// existing thumbnail). Never blank an image the entry already has:
// the featured-image set and the status publish are separate
// server requests, and on long generations (tutorials especially)
// a status-only write can land last — that must not wipe the
// image the other write already recorded. (Was: unconditional
// overwrite, which cleared the card while WP kept the thumbnail.)
$entry['featured_image'] = $featured_image_url;
}
// Always write youtube_video so the field is never absent (even if empty string)
if (!empty($youtube_video)) {
$entry['youtube_video'] = $youtube_video;
} elseif (!isset($entry['youtube_video'])) {
$entry['youtube_video'] = '';
}
// Additional video results (optional — only written if present)
if (!empty($youtube_video_2)) {
$entry['youtube_video_2'] = $youtube_video_2;
} elseif (!isset($entry['youtube_video_2'])) {
$entry['youtube_video_2'] = '';
}
if (!empty($youtube_video_3)) {
$entry['youtube_video_3'] = $youtube_video_3;
} elseif (!isset($entry['youtube_video_3'])) {
$entry['youtube_video_3'] = '';
}
$entryUpdated = true;
aicobr_dbg("=== STATUS UPDATED SUCCESSFULLY (entry " . $matchId . ", entry_id=" . ($publish_entry_id ?? 'n/a') . ") ===");
// Save SEO values onto the entry for reference
if (!empty($seo_title)) $entry['seo_title'] = $seo_title;
if (!empty($seo_description)) $entry['seo_description'] = $seo_description;
if (!empty($seo_focus_keyword)) $entry['seo_focus_keyword'] = $seo_focus_keyword;
if (!empty($seo_og_title)) $entry['seo_og_title'] = $seo_og_title;
if (!empty($seo_og_description))$entry['seo_og_description'] = $seo_og_description;
if (!empty($seo_plugin)) $entry['seo_plugin'] = $seo_plugin;
if ($seo_score !== null) $entry['seo_score'] = $seo_score;
if ($seo_report !== null) $entry['seo_report'] = $seo_report;
if (!empty($acf_field_key)) $entry['acf_field_key'] = $acf_field_key;
// AICOBR_INBODY_IMAGES_2026_08 — only overwrite when this publish
// actually carried suggestions. A regeneration that did not ask for
// them must not wipe the ones the user has not used yet.
if (!empty($image_suggestions)) $entry['image_suggestions'] = $image_suggestions;
break;
}
}
// Safety fallback (prevents stuck items)
if (!$entryUpdated) {
aicobr_dbg("WARNING: No match found — creating fallback entry");
$entries[] = [
'id' => time(),
'title' => $title,
'primary_keyword' => '',
'publish_date' => current_time('mysql'),
'status' => 'generated',
'post_id' => $updated_id,
'generationStartTime' => null,
'updated_at' => current_time('mysql'),
'excerpt' => $excerpt,
'featured_image' => $featured_image_id ? wp_get_attachment_url($featured_image_id) : $featured_image_url,
'featured_image_id' => $featured_image_id,
'image_history' => $featured_image_id ? [[
'attachment_id' => $featured_image_id,
'url' => wp_get_attachment_url($featured_image_id),
'generated_at' => current_time('mysql'),
'is_active' => true,
]] : [],
'youtube_video' => $youtube_video,
'youtube_video_2' => $youtube_video_2,
'youtube_video_3' => $youtube_video_3,
'target_word_count' => intval($data['target_word_count'] ?? 1500),
'seo_title' => $seo_title,
'seo_description' => $seo_description,
'seo_focus_keyword' => $seo_focus_keyword,
'seo_og_title' => $seo_og_title,
'seo_og_description'=> $seo_og_description,
'seo_plugin' => $seo_plugin ?? '',
'seo_score' => $seo_score,
'seo_report' => $seo_report,
'image_suggestions' => $image_suggestions,
];
}
update_option('aicobr_entries', $entries);
if ($aicobr_pub_lock) $this->aicobr_unlock_entries();
// ============================================
// Logging
$logs = get_option('aicobr_logs', []);
$logs[] = [
'created_at' => current_time('mysql'),
'title' => $title,
'status' => 'success',
'ai_model' => $ai_model,
'image_model' => sanitize_text_field($data['image_model'] ?? 'gpt-image-1.5'),
'credits_used' => intval($data['credits_used'] ?? 0),
'execution_time' => $execution_time,
'post_id' => $updated_id,
'featured_image' => $featured_image_id ? wp_get_attachment_url($featured_image_id) : $featured_image_url,
'excerpt' => $excerpt,
'image_style' => $image_style,
'include_youtube'=> $include_youtube,
'youtube_video' => $youtube_video,
'seo_plugin' => $seo_plugin ?? '',
'seo_title' => $seo_title,
];
$logs = array_slice($logs, -100);
update_option('aicobr_logs', $logs);
aicobr_dbg("=== PUBLISH COMPLETE - Post ID: $updated_id ===");
// NOTE: Credits are deducted by generate.js on the license server BEFORE
// generation starts. Do NOT deduct again here — that caused double billing.
return rest_ensure_response([
'success' => true,
'post_id' => $updated_id,
'status' => 'success'
]);
}
/**
* Auto-detects the active SEO plugin and writes all SEO meta fields.
* Supports Yoast SEO, Rank Math, and SEOPress.
* Returns the name of the detected plugin, or 'none' if none found.
*/
private function apply_seo_meta($post_id, $seo) {
$title = $seo['title'] ?? '';
$desc = $seo['description'] ?? '';
$keyword = $seo['focus_keyword'] ?? '';
$og_title = $seo['og_title'] ?? $title;
$og_desc = $seo['og_description'] ?? $desc;
// Nothing to write — skip entirely
if (empty($title) && empty($desc) && empty($keyword)) {
return 'none';
}
// === YOAST SEO ===
if (class_exists('WPSEO_Options')) {
if (!empty($title)) update_post_meta($post_id, '_yoast_wpseo_title', $title);
if (!empty($desc)) update_post_meta($post_id, '_yoast_wpseo_metadesc', $desc);
if (!empty($keyword)) update_post_meta($post_id, '_yoast_wpseo_focuskw', $keyword);
if (!empty($og_title)) update_post_meta($post_id, '_yoast_wpseo_opengraph-title', $og_title);
if (!empty($og_desc)) update_post_meta($post_id, '_yoast_wpseo_opengraph-description',$og_desc);
return 'yoast';
}
// === RANK MATH ===
if (class_exists('RankMath')) {
if (!empty($title)) update_post_meta($post_id, 'rank_math_title', $title);
if (!empty($desc)) update_post_meta($post_id, 'rank_math_description', $desc);
if (!empty($keyword)) update_post_meta($post_id, 'rank_math_focus_keyword', $keyword);
if (!empty($og_title)) update_post_meta($post_id, 'rank_math_og_title', $og_title);
if (!empty($og_desc)) update_post_meta($post_id, 'rank_math_og_description', $og_desc);
return 'rankmath';
}
// === SEOPRESS ===
if (defined('SEOPRESS_VERSION')) {
if (!empty($title)) update_post_meta($post_id, '_seopress_titles_title', $title);
if (!empty($desc)) update_post_meta($post_id, '_seopress_titles_desc', $desc);
if (!empty($keyword)) update_post_meta($post_id, '_seopress_analysis_target_kw', $keyword);
if (!empty($og_title)) update_post_meta($post_id, '_seopress_social_fb_title', $og_title);
if (!empty($og_desc)) update_post_meta($post_id, '_seopress_social_fb_desc', $og_desc);
return 'seopress';
}
aicobr_dbg('AI Content Bridge: No supported SEO plugin detected. SEO meta not written.');
return 'none';
}
private function processGeneratedContent($content, $includeYoutube = false, $content_type = 'blog_post', $post_type = 'post') {
// Bundled, prefixed copy of Parsedown (renamed AICOBR_Parsedown) — cannot
// collide with another plugin's or theme's Parsedown at any version.
require_once __DIR__ . '/Parsedown.php';
$Parsedown = new AICOBR_Parsedown();
$Parsedown->setSafeMode(true);
$Parsedown->setBreaksEnabled(false); // Let Parsedown handle paragraph breaks naturally
$html = $Parsedown->text($content);
// ── Gutenberg block classes ────────────────────────────────────────
$html = str_replace(' ', '', $html);
$html = str_replace('', '', $html);
// ── Improve heading spacing — add margin classes ───────────────────
$html = preg_replace('//', '', $html);
$html = preg_replace('//', '', $html);
// ── Wrap lists in wp-block-list for Gutenberg compatibility ────────
$html = preg_replace('//', '', $html);
$html = preg_replace('//', '', $html);
// ── Clean up excessive blank lines ─────────────────────────────────
$html = preg_replace('/(<\/p>)\s*()/', "$1\n\n$2", $html);
$html = preg_replace('/\n{3,}/', "\n\n", $html);
// ── YouTube shortcode replacement ──────────────────────────────────
$html = preg_replace_callback(
'/\{%\s*youtube\s+(https?:\/\/(?:www\.)?youtube\.com\/watch\?v=[\w-]+|https?:\/\/youtu\.be\/[\w-]+)\s*%\}/i',
function($matches) {
$url = $matches[1];
$videoId = strpos($url, 'youtu.be') !== false
? basename(wp_parse_url($url, PHP_URL_PATH))
: (parse_str(wp_parse_url($url, PHP_URL_QUERY), $params) ? ($params['v'] ?? '') : '');
if (!$videoId) return $matches[0];
return '
';
},
$html
);
// ── ACB styled-content enhancement ─────────────────────────────────
// Wrap in .acb-content and upgrade FAQ/callouts/takeaways/tables per tier.
// Runs before wp_kses so the sanitiser (which now allows ) cleans
// the final markup. Email/quiz tiers are returned untouched by the formatter.
if (!class_exists('AICOBR_Content_Formatter')) {
require_once __DIR__ . '/AIContentFormatter.php';
}
$html = AICOBR_Content_Formatter::enhance($html, $content_type, $post_type);
// Defence-in-depth: Parsedown safe mode is not a full sanitiser, so run the
// output through wp_kses (allowing the YouTube embed iframe) before it is
// stored or rendered.
$allowed = wp_kses_allowed_html('post');
$allowed['iframe'] = [
'src' => true, 'width' => true, 'height' => true, 'frameborder' => true,
'allowfullscreen' => true, 'allow' => true, 'title' => true,
];
$html = wp_kses($html, $allowed);
return $html;
}
/**
* POST /ai-content/v1/register-free
* Proxies to the license server to register a free licence for this site.
* Keeps the licence-server URL server-side, so there's no hardcoded URL in
* the browser and no cross-origin call from the customer's WordPress install.
*/
public function register_free($request) {
$data = $request->get_json_params();
$email = sanitize_email($data['email'] ?? '');
if (empty($email) || !is_email($email)) {
return new WP_Error('invalid_email', 'Please enter a valid email address.', ['status' => 400]);
}
$license_server = self::get_server_url();
$response = wp_remote_post("{$license_server}/api/register-free", [
'timeout' => 15,
'headers' => ['Content-Type' => 'application/json'],
'body' => wp_json_encode([
'email' => $email,
'domain' => wp_parse_url(home_url(), PHP_URL_HOST),
]),
]);
if (is_wp_error($response)) {
return new WP_Error('server_unreachable', 'Could not reach the license server. Please try again.', ['status' => 502]);
}
$code = wp_remote_retrieve_response_code($response);
$body = json_decode(wp_remote_retrieve_body($response), true);
if ($code < 200 || $code >= 300 || empty($body['success'])) {
return new WP_Error(
'registration_failed',
$body['error'] ?? 'Registration failed. Please try again.',
['status' => ($code >= 400 && $code < 600) ? $code : 502]
);
}
self::maybe_store_generate_secret($body);
return rest_ensure_response($body);
}
/**
* GET /aicobr/v1/license-status?license_key=ACB-...
* AICOBR_WIZARD_POLL_2026_07_29 — proxies the licence server's polling
* endpoint so the onboarding wizard can detect email verification live
* and auto-complete without any manual key copy/paste.
*/
public function license_status($request) {
$license_key = sanitize_text_field($request->get_param('license_key') ?? '');
if (empty($license_key)) {
return new WP_Error('no_key', 'License key is required.', ['status' => 400]);
}
$license_server = self::get_server_url();
$response = wp_remote_get(
"{$license_server}/api/license-status?license_key=" . rawurlencode($license_key),
['timeout' => 10]
);
if (is_wp_error($response)) {
return new WP_Error('server_unreachable', 'Could not reach the license server.', ['status' => 502]);
}
$code = wp_remote_retrieve_response_code($response);
$body = json_decode(wp_remote_retrieve_body($response), true);
if ($code < 200 || $code >= 300 || empty($body['success'])) {
return new WP_Error(
'status_failed',
$body['error'] ?? 'Could not check licence status.',
['status' => ($code >= 400 && $code < 600) ? $code : 502]
);
}
return rest_ensure_response($body);
}
/**
* POST /aicobr/v1/wizard-event
* Fire-and-forget onboarding funnel telemetry (opened / inbox_reached /
* completed …). Best-effort: failures are swallowed — telemetry must never
* affect the user's setup flow.
*/
public function wizard_event($request) {
$data = $request->get_json_params();
$event = sanitize_key($data['event'] ?? '');
if (!empty($event)) {
$license_server = self::get_server_url();
wp_remote_post("{$license_server}/api/wizard-event", [
'timeout' => 5,
'blocking' => false,
'headers' => ['Content-Type' => 'application/json'],
'body' => wp_json_encode([
'event' => $event,
'domain' => wp_parse_url(home_url(), PHP_URL_HOST),
]),
]);
}
return rest_ensure_response(['ok' => true]);
}
/**
* POST /ai-content/v1/verify-license
* Proxies to the license server to validate the key.
* On success, persists the plan tier and returns credits.
*/
public function verify_license($request) {
$data = $request->get_json_params();
$license_key = sanitize_text_field($data['license_key'] ?? '');
$email = sanitize_email($data['email'] ?? '');
if (empty($license_key)) {
return new WP_Error('no_key', 'License key is required.', ['status' => 400]);
}
$license_server = self::get_server_url();
$response = wp_remote_post("{$license_server}/api/verify-license", [
'timeout' => 15,
'headers' => ['Content-Type' => 'application/json'],
'body' => wp_json_encode([
'license_key' => $license_key,
'email' => $email,
'domain' => wp_parse_url(home_url(), PHP_URL_HOST),
]),
]);
if (is_wp_error($response)) {
return new WP_Error('server_unreachable', 'Could not reach the license server. Please try again.', ['status' => 502]);
}
$code = wp_remote_retrieve_response_code($response);
$body = json_decode(wp_remote_retrieve_body($response), true);
if ($code !== 200 || empty($body['success'])) {
// AICOBR_DEACTIVATE_ON_REJECT_2026_07_29: the server definitively
// rejected this key (this branch is NOT reached for network
// failures — those return server_unreachable above). Persist the
// deactivation: previously email_verified/plan_tier could only be
// SET here and never cleared, so a site that saved a bad key kept
// showing "Email verified — licence active" forever, surviving
// reloads. Now stored state reflects the rejection, the Settings
// badge flips, and the Dashboard setup wall returns.
$settings = get_option('aicobr_settings', []);
$settings['license_key'] = $license_key; // the rejected key, so the hint shows what was tried
$settings['email_verified'] = false;
$settings['plan_tier'] = 'free';
update_option('aicobr_settings', $settings);
return new WP_Error(
'invalid_license',
$body['error'] ?? 'License verification failed. Please check your key and try again.',
['status' => 403]
);
}
self::maybe_store_generate_secret($body);
// Persist verified key + plan into settings.
// AICOBR_EMAIL_AUTHORITATIVE_2026_07_29: the licence server returns the
// account's real email; prefer it over whatever was typed in the form.
// The Settings email field previously accepted any value unchecked and
// stored it as if meaningful — now the server is the source of truth.
$settings = get_option('aicobr_settings', []);
$settings['license_key'] = $license_key;
$settings['email'] = sanitize_email($body['email'] ?? '') ?: $email;
// Normalise tier — strip anything like "(beta)" and map to known values
$raw_tier = strtolower(sanitize_text_field($body['plan'] ?? 'free'));
if (strpos($raw_tier, 'agency') !== false) $raw_tier = 'agency';
elseif (strpos($raw_tier, 'pro') !== false) $raw_tier = 'pro';
elseif (strpos($raw_tier, 'starter') !== false) $raw_tier = 'starter';
else $raw_tier = 'free';
$settings['plan_tier'] = $raw_tier;
$settings['email_verified'] = true;
update_option('aicobr_settings', $settings);
return rest_ensure_response([
'success' => true,
'plan' => $body['plan'] ?? 'free',
'credits_remaining' => $body['credits_remaining'] ?? null,
'expires_at' => $body['expires_at'] ?? null,
]);
}
/**
* POST /ai-content/v1/extract-style
* Sends a writing sample to the license server (which calls Claude)
* and returns a structured style profile object.
*/
/**
* GET /ai-content/v1/lms-data
* Returns detected LMS plugins and their course/lesson structure.
* Used by the ContentDiary LearnDash picker UI.
*/
public function get_lms_data() {
$data = [
'lifterlms' => false,
'learnpress' => false,
'courses' => [],
'debug' => [
'llms_class' => class_exists('LLMS'),
'llms_constant' => defined('LLMS_PLUGIN_FILE'),
'lp_class' => class_exists('LearnPress'),
'lp_constant' => defined('LP_PLUGIN_FILE'),
],
];
// ── LifterLMS ─────────────────────────────────────────────────────
// LifterLMS uses different class names across versions:
// v3.x: 'LifterLMS', v4.x+: 'LLMS', function-based: 'llms'
$lifterlms_active = class_exists('LLMS')
|| class_exists('LifterLMS')
|| function_exists('llms')
|| defined('LLMS_PLUGIN_FILE');
if ($lifterlms_active) {
$data['lifterlms'] = true;
$courses = get_posts([
'post_type' => 'course',
'posts_per_page' => 100,
'post_status' => 'publish',
'orderby' => 'title',
'order' => 'ASC',
]);
foreach ($courses as $course) {
// LifterLMS: llms_section is not a real post type — fetch lessons
// directly via _llms_parent_course meta
$lessons = get_posts([
'post_type' => 'lesson',
'posts_per_page' => 100,
'post_status' => 'publish',
'orderby' => 'menu_order',
'order' => 'ASC',
'meta_query' => [[
'key' => '_llms_parent_course',
'value' => $course->ID,
]],
]);
$lessons_data = [];
foreach ($lessons as $lesson) {
$lessons_data[] = [
'id' => $lesson->ID,
'title' => $lesson->post_title,
];
}
$data['courses'][] = [
'id' => $course->ID,
'title' => $course->post_title,
'lms' => 'lifterlms',
'lessons' => $lessons_data,
];
}
}
// ── LearnPress ───────────────────────────────────────────────────────
$lp_active = class_exists('LearnPress') || defined('LP_PLUGIN_FILE') || post_type_exists('lp_course');
if ($lp_active) {
$data['learnpress'] = true;
$courses = get_posts([
'post_type' => 'lp_course',
'posts_per_page' => 100,
'post_status' => 'publish',
'orderby' => 'title',
'order' => 'ASC',
]);
foreach ($courses as $course) {
// LearnPress stores curriculum as JSON meta on the course
// Parse it to get sections with their lesson/quiz items
$curriculum_json = get_post_meta($course->ID, '_lp_course_json', true);
$sections_data = [];
if (!empty($curriculum_json)) {
$curriculum = json_decode($curriculum_json, true);
if (!empty($curriculum['sections'])) {
foreach ($curriculum['sections'] as $section) {
$section_lessons = [];
foreach ($section['items'] ?? [] as $item) {
$post = get_post($item['id']);
if ($post) {
$section_lessons[] = [
'id' => $post->ID,
'title' => $post->post_title,
'type' => $item['type'],
];
}
}
$sections_data[] = [
'id' => $section['id'],
'title' => $section['title'] ?? 'Section',
'lessons' => $section_lessons,
];
}
}
}
// Flatten to lessons list for the picker (section name as prefix)
$lessons_data = [];
foreach ($sections_data as $section) {
foreach ($section['lessons'] as $lesson) {
$lessons_data[] = [
'id' => $lesson['id'],
'title' => $lesson['title'],
'section' => $section['title'],
'section_id' => $section['id'],
'type' => $lesson['type'],
];
}
}
// Also get standalone lessons attached to this course
// (lessons linked via _lp_course meta but not yet in curriculum)
$standalone = get_posts([
'post_type' => 'lp_lesson',
'posts_per_page' => 100,
'post_status' => 'publish',
'meta_query' => [[
'key' => '_lp_course',
'value' => $course->ID,
]],
]);
$existing_ids = array_column($lessons_data, 'id');
foreach ($standalone as $lesson) {
if (!in_array($lesson->ID, $existing_ids)) {
$lessons_data[] = [
'id' => $lesson->ID,
'title' => $lesson->post_title,
];
}
}
$data['courses'][] = [
'id' => $course->ID,
'title' => $course->post_title,
'lms' => 'learnpress',
'lessons' => $lessons_data,
'sections' => $sections_data,
];
}
}
return rest_ensure_response($data);
}
/**
* Populate a LearnPress 4.x quiz with questions and answers.
* LP4 stores everything in custom tables — not post meta or post_parent.
*
* Tables used:
* learnpress_quiz_questions — links question posts to quiz post
* learnpress_question_answers — stores answer options per question
* learnpress_section_items — links quiz to course section
* learnpress_sections — course sections
*/
public function populate_learnpress_quiz($quiz_id, $quiz_data, $course_id = 0) {
global $wpdb;
if (empty($quiz_data['questions'])) return;
// Set quiz intro as excerpt
if (!empty($quiz_data['quiz_intro'])) {
wp_update_post(['ID' => $quiz_id, 'post_excerpt' => sanitize_text_field($quiz_data['quiz_intro'])]);
}
// Default quiz meta settings
$quiz_meta = [
'_lp_duration' => '0 minute',
'_lp_instant_check' => 'no',
'_lp_negative_marking' => 'no',
'_lp_review' => 'yes',
'_lp_show_correct_review' => 'yes',
'_lp_passing_grade' => '80',
'_lp_retake_count' => '0',
'_lp_pagination' => '1',
];
foreach ($quiz_meta as $key => $val) {
update_post_meta($quiz_id, $key, $val);
}
$tbl_quiz_q = $wpdb->prefix . 'learnpress_quiz_questions';
$tbl_answers = $wpdb->prefix . 'learnpress_question_answers';
$q_order = 0;
foreach ($quiz_data['questions'] as $q) {
$q_order++;
$q_type = $q['type'] ?? 'multiple_choice';
// Map to LearnPress question type slugs
if ($q_type === 'true_false') {
$lp_type = 'true_or_false';
} elseif ($q_type === 'open') {
$lp_type = 'fill_in_blanks';
} else {
$lp_type = 'multi_choice';
}
// Create the question as an lp_question post
$q_id = wp_insert_post([
'post_title' => sanitize_text_field($q['text']),
'post_content' => '',
'post_type' => 'lp_question',
'post_status' => 'publish',
'post_parent' => 0, // LP4 uses its own tables, not WP post_parent
'menu_order' => 0,
]);
if (is_wp_error($q_id) || !$q_id) {
aicobr_dbg('[ACB] LP question create failed: ' . (is_wp_error($q_id) ? $q_id->get_error_message() : 'unknown'));
continue;
}
// Set question type and explanation as post meta
update_post_meta($q_id, '_lp_type', $lp_type);
update_post_meta($q_id, '_lp_explanation', sanitize_text_field($q['explanation'] ?? ''));
// Link question to quiz via learnpress_quiz_questions table
$wpdb->insert($tbl_quiz_q, [
'quiz_id' => $quiz_id,
'question_id' => $q_id,
'question_order' => $q_order,
], ['%d', '%d', '%d']);
// Insert answer options into learnpress_question_answers
if ($lp_type === 'multi_choice' || $lp_type === 'true_or_false') {
$a_order = 0;
foreach ($q['answers'] ?? [] as $ans) {
$a_order++;
// value must be unique varchar(32) — use a short hash
$value = substr(md5($q_id . '_' . $a_order), 0, 10);
$wpdb->insert($tbl_answers, [
'question_id' => $q_id,
'title' => sanitize_text_field($ans['text']),
'value' => $value,
'order' => $a_order,
'is_true' => !empty($ans['correct']) ? 'yes' : '',
], ['%d', '%s', '%s', '%d', '%s']);
}
} elseif ($lp_type === 'fill_in_blanks') {
// Open question — store model answer in meta
update_post_meta($q_id, '_lp_hint', sanitize_text_field($q['model_answer'] ?? ''));
}
aicobr_dbg("[ACB] LP question $q_order created: post $q_id ($lp_type) linked to quiz $quiz_id");
}
// Link quiz to course section if course_id provided
if ($course_id) {
$tbl_sections = $wpdb->prefix . 'learnpress_sections';
$tbl_section_items = $wpdb->prefix . 'learnpress_section_items';
// Find existing section for this course, or create one
$section_id = $wpdb->get_var($wpdb->prepare(
"SELECT section_id FROM $tbl_sections WHERE section_course_id = %d ORDER BY section_order ASC LIMIT 1",
$course_id
));
if (!$section_id) {
$wpdb->insert($tbl_sections, [
'section_name' => 'Generated Content',
'section_course_id' => $course_id,
'section_order' => 1,
'section_description' => '',
], ['%s', '%d', '%d', '%s']);
$section_id = $wpdb->insert_id;
}
if ($section_id) {
// Get next item order in this section
$next_order = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COALESCE(MAX(item_order), 0) + 1 FROM $tbl_section_items WHERE section_id = %d",
$section_id
));
$wpdb->insert($tbl_section_items, [
'section_id' => $section_id,
'item_id' => $quiz_id,
'item_order' => $next_order,
'item_type' => 'lp_quiz',
], ['%d', '%d', '%d', '%s']);
aicobr_dbg("[ACB] LP quiz $quiz_id added to section $section_id (course $course_id)");
}
}
// Clear LP caches
wp_cache_delete($quiz_id, 'lp_quiz');
if ($course_id) wp_cache_delete($course_id, 'lp_course');
if (function_exists('learn_press_reset_auto_draft')) learn_press_reset_auto_draft($course_id);
aicobr_dbg('[ACB] LP quiz ' . $quiz_id . ': ' . count($quiz_data['questions']) . ' questions populated successfully');
}
public function populate_lifterlms_quiz($quiz_id, $quiz_data, $course_id = 0) {
if (empty($quiz_data['questions'])) return;
if (!empty($quiz_data['quiz_intro'])) {
wp_update_post(['ID' => $quiz_id, 'post_excerpt' => sanitize_text_field($quiz_data['quiz_intro'])]);
}
$q_list = [];
$order = 0;
foreach ($quiz_data['questions'] as $q) {
$order++;
$q_type = $q['type'] ?? 'multiple_choice';
$llms_type = ($q_type === 'true_false') ? 'true_false'
: (($q_type === 'open') ? 'short_answer' : 'choice');
$q_id = wp_insert_post([
'post_title' => sanitize_text_field($q['text']),
'post_type' => 'llms_question',
'post_status' => 'publish',
'post_parent' => $quiz_id,
'menu_order' => $order,
]);
if (is_wp_error($q_id)) {
aicobr_dbg('[ACB] LLMS question insert failed: ' . $q_id->get_error_message());
continue;
}
update_post_meta($q_id, '_llms_question_type', $llms_type);
update_post_meta($q_id, '_llms_quiz', $quiz_id);
update_post_meta($q_id, '_llms_question_explanation', sanitize_text_field($q['explanation'] ?? ''));
if ($course_id) update_post_meta($q_id, '_llms_parent_course', $course_id);
// Write each choice as its own postmeta row: _llms_choice_{id} => serialised array.
// This is the internal format LLMS_Question_Choice::save() produces and
// LLMS_Question::get_choices() reads (meta_key LIKE '_llms_choice_%').
// We replicate it directly because LLMS_Question_Choice may not be loaded
// in the REST context even when LLMS is active (post types register on init
// but class autoloading can lag behind).
foreach ($q['answers'] ?? [] as $idx => $ans) {
$choice_id = uniqid();
$choice_data = [
'id' => $choice_id,
'choice' => sanitize_text_field($ans['text']),
'choice_type' => 'text',
'correct' => !empty($ans['correct']),
'marker' => chr(65 + $idx), // A, B, C, D…
];
update_post_meta($q_id, '_llms_choice_' . $choice_id, $choice_data);
}
// _llms_questions on the quiz is an array of {id, points} maps
$q_list[] = ['id' => $q_id, 'points' => 1];
}
// Rebuild the question list from the questions that ACTUALLY exist in the DB,
// parented to this quiz. Building it from $q_id values returned by
// wp_insert_post can include a phantom ID if an insert reserved an ID but the
// post didn't persist (e.g. an auto-draft collision) — and a single bad ID in
// _llms_questions makes the Course Builder's question loader throw
// "An error occurred while trying to load the questions". Querying the real
// posts guarantees every listed ID resolves to a genuine llms_question.
$real_q_ids = get_posts([
'post_type' => 'llms_question',
'post_parent' => $quiz_id,
'post_status' => 'publish',
'posts_per_page' => -1,
'fields' => 'ids',
'orderby' => 'menu_order',
'order' => 'ASC',
]);
$q_list = array_map(function ($id) { return ['id' => (int) $id, 'points' => 1]; }, $real_q_ids);
// Register all questions on the quiz so LLMS_Quiz::get_questions() finds them
update_post_meta($quiz_id, '_llms_questions', $q_list);
wp_cache_delete($quiz_id, 'llms_quiz');
aicobr_dbg('[ACB] LLMS quiz ' . $quiz_id . ': ' . count($q_list) . ' questions registered (from DB)');
}
public function get_tec_status() {
$active = class_exists('Tribe__Events__Main') || post_type_exists('tribe_events');
return rest_ensure_response(['active' => $active]);
}
public function get_mailpoet_lists() {
if (!class_exists('\MailPoet\API\API')) {
return rest_ensure_response(['active' => false, 'lists' => []]);
}
try {
$api = \MailPoet\API\API::MP('v1');
$lists = $api->getLists();
$out = [];
foreach ($lists as $l) {
$out[] = ['id' => $l['id'], 'name' => $l['name'], 'subscribers' => $l['subscribers_count'] ?? 0];
}
return rest_ensure_response(['active' => true, 'lists' => $out]);
} catch (\Exception $e) {
return rest_ensure_response(['active' => false, 'lists' => [], 'error' => $e->getMessage()]);
}
}
public function get_newsletter_lists() {
// Newsletter plugin by The Newsletter Team (TNP).
$tnp_active = class_exists('Newsletter') || class_exists('TNP') || defined('NEWSLETTER_VERSION');
if (!$tnp_active) {
return rest_ensure_response(['active' => false, 'lists' => []]);
}
global $wpdb;
$lists = [];
// Subscriber-count helper: list membership is the list_N column on the
// {$wpdb->prefix}newsletter table; 'C' = confirmed.
$count_for = function ($i) use ($wpdb) {
$col = 'list_' . (int) $i;
$table = $wpdb->prefix . 'newsletter';
// Guard against the column not existing on minimal installs.
$exists = $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = %s AND column_name = %s",
$table, $col
));
if (!$exists) return 0;
return (int) $wpdb->get_var(
$wpdb->prepare("SELECT COUNT(*) FROM `{$table}` WHERE `{$col}` = 1 AND status = %s", 'C')
);
};
// Preferred: ask TNP itself. Newsletter::instance()->get_list_options()
// returns the configured lists keyed by id regardless of how the current
// version stores them internally (9.x keeps them in the main settings
// option, not in separate newsletter_list_N options like older versions).
if (class_exists('Newsletter') && method_exists('Newsletter', 'instance')) {
$tnp = Newsletter::instance();
// Newer cores expose get_lists() returning objects with ->id/->name.
if (method_exists($tnp, 'get_lists')) {
foreach ((array) $tnp->get_lists() as $l) {
$id = is_object($l) ? (isset($l->id) ? (int) $l->id : 0) : (isset($l['id']) ? (int) $l['id'] : 0);
$name = is_object($l) ? (isset($l->name) ? $l->name : '') : (isset($l['name']) ? $l['name'] : '');
if ($id && $name !== '') {
$lists[] = ['id' => $id, 'name' => $name, 'subscribers' => $count_for($id)];
}
}
}
// Fallback: get_list_options() returns [id => ['name'=>..., ...]].
if (empty($lists) && method_exists($tnp, 'get_list_options')) {
foreach ((array) $tnp->get_list_options() as $id => $opt) {
$name = is_array($opt) ? ($opt['name'] ?? '') : (string) $opt;
if ((int) $id && $name !== '') {
$lists[] = ['id' => (int) $id, 'name' => $name, 'subscribers' => $count_for($id)];
}
}
}
}
// Last-resort fallbacks for older cores / unusual storage:
// (a) the single 'newsletter_main' (or 'newsletter') settings option with
// list_N_name keys, then (b) the legacy per-list newsletter_list_N
// options. Note the correct constant is NEWSLETTER_LISTS_MAX (plural);
// the old code read NEWSLETTER_LIST_MAX, which never matched.
$max_lists = defined('NEWSLETTER_LISTS_MAX') ? (int) NEWSLETTER_LISTS_MAX
: (defined('NEWSLETTER_LIST_MAX') ? (int) NEWSLETTER_LIST_MAX : 40);
if (empty($lists)) {
$main = get_option('newsletter_main');
if (!is_array($main)) $main = get_option('newsletter');
if (is_array($main)) {
for ($i = 1; $i <= $max_lists; $i++) {
$name = $main['list_' . $i . '_name'] ?? ($main['list_' . $i] ?? '');
if (is_string($name) && trim($name) !== '') {
$lists[] = ['id' => $i, 'name' => $name, 'subscribers' => $count_for($i)];
}
}
}
}
if (empty($lists)) {
for ($i = 1; $i <= $max_lists; $i++) {
$list_option = get_option('newsletter_list_' . $i);
if (!empty($list_option['name'])) {
$lists[] = ['id' => $i, 'name' => $list_option['name'], 'subscribers' => $count_for($i)];
}
}
}
// 'active' reflects only whether TNP is installed — NOT whether lists were
// found. The UI decides what to show; an installed-but-no-lists state must
// still surface so the user can be told to create a list, rather than the
// integration silently vanishing.
return rest_ensure_response(['active' => true, 'lists' => $lists]);
}
public function extract_style($request) {
$data = $request->get_json_params();
$sample = wp_kses_post($data['sample'] ?? '');
if (strlen(trim($sample)) < 100) {
return new WP_Error('sample_too_short', 'Writing sample must be at least 100 characters.', ['status' => 400]);
}
$settings = get_option('aicobr_settings', []);
$license_key = $settings['license_key'] ?? '';
if (empty($license_key)) {
return new WP_Error('no_license', 'No license key configured. Please complete setup first.', ['status' => 403]);
}
$license_server = self::get_server_url();
$generate_secret = self::get_secret();
$response = wp_remote_post("{$license_server}/api/extract-style", [
'timeout' => 30,
'headers' => [
'Content-Type' => 'application/json',
'x-generate-secret' => $generate_secret,
],
'body' => wp_json_encode([
'license_key' => $license_key,
'sample' => $sample,
]),
]);
if (is_wp_error($response)) {
return new WP_Error('server_unreachable', 'Could not reach the style extraction service.', ['status' => 502]);
}
$code = wp_remote_retrieve_response_code($response);
$body = json_decode(wp_remote_retrieve_body($response), true);
if ($code !== 200 || empty($body['success'])) {
return new WP_Error(
'extraction_failed',
$body['error'] ?? 'Style extraction failed. Please try again.',
['status' => 500]
);
}
return rest_ensure_response([
'success' => true,
'profile' => $body['profile'],
]);
}
/* Self-hosted auto-updates removed for WordPress.org distribution.
[ACB_UPDATER_REMOVED_2026_07_08] Updates are served by WordPress.org,
matched on the plugin slug. Do NOT re-add a custom update channel while
this plugin is hosted in the .org directory (it violates the guidelines). */
}
new AICOBR_Plugin();