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();