ID)) return false; if (!in_array($post->post_status, ['publish', 'draft', 'private'], true)) return false; if ($post->post_type === 'attachment') return false; if (!in_array($post->post_type, self::enabled_post_types(), true)) return false; if (self::is_acb_post($post->ID)) return false; if (!self::has_refreshable_body($post)) return false; return true; } /** * Post types the refresh action appears on. Defaults to the post types the * user enabled in ACB settings (`inbound_post_types`), resolved the same * way the inbound-link scanner resolves them. Exposed as its own filter so * refresh visibility can be widened (e.g. to pages, or every public type) * without touching inbound scanning. * * NOTE (open question, spec §11 / status note): the default here is "the * types enabled in settings", which is `['post']` out of the box — so pages * and CPTs are NOT offered unless enabled or filtered in. Confirm whether * that is the intended default before release. */ public static function enabled_post_types() { $settings = get_option('aicobr_settings', []); $chosen = (is_array($settings) && !empty($settings['inbound_post_types']) && is_array($settings['inbound_post_types'])) ? $settings['inbound_post_types'] : ['post']; $valid = []; foreach ($chosen as $t) { $t = sanitize_key((string) $t); if ($t && post_type_exists($t)) $valid[] = $t; } if (!$valid) $valid = ['post']; $types = apply_filters('aicobr_refresh_post_types', array_values(array_unique($valid))); return is_array($types) ? array_values(array_unique(array_map('strval', $types))) : ['post']; } /** * "Not ACB-created" (spec §2). The generated-post meta, its pre-2.8.1 * legacy key, and the content-diary fallback: a post ACB has on a diary * entry is genuinely ours, so refreshing it is a regenerate, which already * exists. Mirrors AICOBR_Plugin::aicobr_is_generated_post() + * aicobr_post_in_diary(); kept local so this file stays self-contained. */ private static function is_acb_post($post_id) { if (get_post_meta($post_id, '_aicobr_generated', true) || get_post_meta($post_id, '_acb_generated', true)) { return true; } $entries = get_option('aicobr_entries', []); if (is_array($entries)) { $pid = (int) $post_id; foreach ($entries as $e) { if (isset($e['post_id']) && (int) $e['post_id'] === $pid) return true; } } return false; } /** * Is there anything worth refreshing? Page-builder posts keep their body * outside post_content, so extraction degrades rather than blocks (spec * §3.4) — always offer those. Otherwise require a small floor of body words * so a genuinely empty post (e.g. a CPT landing page whose content lives in * ACF/template, 0 words in post_content) doesn't offer a meaningless * refresh. Floor is filterable; 0 disables the check. */ private static function has_refreshable_body($post) { if (self::has_page_builder($post->ID)) return true; $min = (int) apply_filters('aicobr_refresh_min_words', 50); if ($min <= 0) return true; $words = class_exists('AICOBR_Refresh_Extract') ? AICOBR_Refresh_Extract::word_count($post->post_content) : str_word_count(wp_strip_all_tags((string) $post->post_content)); return $words >= $min; } /** Minimal page-builder detection, same keys acb-diagnose-content.php uses. */ private static function has_page_builder($post_id) { foreach (['_elementor_data', '_et_pb_use_builder', '_fl_builder_enabled'] as $key) { if (get_post_meta($post_id, $key, true)) return true; } return false; } /* ---- the row action link ------------------------------------------- */ public static function add_row_action($actions, $post) { if (self::should_offer($post)) { $url = wp_nonce_url( admin_url('admin-post.php?action=' . self::ACTION . '&post=' . $post->ID), self::ACTION . '_' . $post->ID ); $actions[self::ACTION] = sprintf( '%s', esc_url($url), esc_html__('Refresh with ACB', 'ai-content-bridge') ); } return $actions; } /* ---- the handler (nonce-protected admin request) ------------------- */ public static function handle() { $post_id = isset($_GET['post']) ? absint($_GET['post']) : 0; // Nonce first (dies on failure), then capability, then re-validate that // the post is actually one we offer — the link is public once rendered. check_admin_referer(self::ACTION . '_' . $post_id); if (!$post_id || !current_user_can('edit_post', $post_id)) { wp_die( esc_html__('You are not allowed to refresh this post.', 'ai-content-bridge'), '', ['response' => 403] ); } $post = get_post($post_id); if (!$post || !self::should_offer($post)) { wp_die( esc_html__('This post can’t be refreshed.', 'ai-content-bridge'), '', ['response' => 400] ); } if (!class_exists('AICOBR_Refresh_Extract')) { wp_die(esc_html__('The refresh engine isn’t available.', 'ai-content-bridge')); } $payload = AICOBR_Refresh_Extract::extract($post_id); if (is_wp_error($payload)) { wp_die(esc_html($payload->get_error_message()), '', ['response' => 500]); } // AICOBR_REFRESH_CLUSTER_2026_08 — attach a cluster suggestion so the // brief can offer to add this post to the cluster it already relates to. // Read-only; the actual, validated assignment happens server-side in // save_entries when the brief is saved. if (is_array($payload)) { $payload['suggested_cluster'] = self::suggest_cluster($post_id, (string) $post->post_content); $payload['known_pillars'] = self::known_pillars(); } // Payload into a short-lived, user-scoped transient; token into the URL. $token = wp_generate_password(24, false); // [A-Za-z0-9], URL-safe set_transient(self::TRANSIENT . $token, [ 'payload' => $payload, 'user_id' => get_current_user_id(), 'created' => time(), ], self::TTL); wp_safe_redirect(add_query_arg( ['page' => self::DASHBOARD_PAGE, 'acb_refresh' => $token], admin_url('admin.php') )); exit; } /* ---- handoff into the dashboard ------------------------------------ */ /** * On the ACB dashboard, if the URL carries a valid refresh token, inject the * payload once as `window.aicobrRefresh` and delete the transient. Step 5's * React modal reads window.aicobrRefresh on mount and opens in Refresh mode. */ public static function maybe_inject_payload() { if (!isset($_GET['acb_refresh'])) return; $screen = function_exists('get_current_screen') ? get_current_screen() : null; if (!$screen || $screen->id !== self::DASHBOARD_HOOK) return; $data = self::consume_token(wp_unslash($_GET['acb_refresh'])); if (!$data) return; printf( "\n", wp_json_encode($data['payload'], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_HEX_APOS) ); } /** * Read-and-burn a refresh token. Returns the stored record (with 'payload') * for the current user, or null. Public so step 5 can consume via REST if it * prefers that over the injected global. */ public static function consume_token($token) { $token = preg_replace('/[^A-Za-z0-9]/', '', (string) $token); if ($token === '') return null; $key = self::TRANSIENT . $token; $data = get_transient($key); if (!is_array($data) || !isset($data['payload'], $data['user_id'])) return null; if ((int) $data['user_id'] !== get_current_user_id()) return null; delete_transient($key); // single use return $data; } /* ---- cluster suggestion (AICOBR_REFRESH_CLUSTER_2026_08) ------------ */ /** * Distinct pillars ACB knows about: every cluster_pillar named across the * diary, deduped by post_id. This is the SAME definition of "a pillar" the * diary filter and the cluster report use — a page is a pillar only once a * brief names it — so a refresh can only suggest clusters that actually * exist in the product, never an arbitrary page. */ public static function known_pillars() { $out = []; foreach ((array) get_option('aicobr_entries', []) as $e) { $cp = is_array($e) ? ($e['cluster_pillar'] ?? null) : null; if (!is_array($cp)) continue; $pid = absint($cp['post_id'] ?? 0); if ($pid <= 0 || isset($out[$pid])) continue; $out[$pid] = [ 'post_id' => $pid, 'title' => (string) ($cp['title'] ?? get_the_title($pid)), 'url' => (string) ($cp['url'] ?? get_permalink($pid)), ]; } return array_values($out); } /** * Which known pillar(s) does this post relate to? Two signals, in order of * confidence: * 1. INHERITED — the post already has a diary entry naming a pillar. The * existing editorial decision wins over any fresh guess. * 2. LINKED — the post's body already links to a known pillar's URL * (found with the scanner, not a naive regex). * `confident` is true only when exactly one pillar is implicated, so the UI * can pre-select it; otherwise it offers a choice. Read-only. */ public static function suggest_cluster($post_id, $content) { $known = self::known_pillars(); if (!$known) return ['list' => [], 'confident' => false]; $pid = (int) $post_id; foreach ((array) get_option('aicobr_entries', []) as $e) { if (!is_array($e) || (int) ($e['post_id'] ?? 0) !== $pid) continue; $cp = $e['cluster_pillar'] ?? null; if (is_array($cp) && absint($cp['post_id'] ?? 0) > 0) { return ['list' => [[ 'post_id' => absint($cp['post_id']), 'title' => (string) ($cp['title'] ?? ''), 'url' => (string) ($cp['url'] ?? ''), 'source' => 'inherited', ]], 'confident' => true]; } } $matches = []; if (class_exists('AICOBR_Link_Scanner')) { foreach ($known as $p) { $url = (string) ($p['url'] ?? ''); if ($url !== '' && AICOBR_Link_Scanner::links_to((string) $content, $url)) { $p['source'] = 'linked'; $matches[] = $p; } } } return ['list' => $matches, 'confident' => count($matches) === 1]; } } AICOBR_Refresh_Action::init();