0, 'alt_text' => 0]; $metaKey = self::metaDescriptionKey(); if ($metaKey !== '') { $out['meta_description'] = (int) $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- COUNT/LIMIT 1 over postmeta joins; no WP_Query equivalent, result is per-request "SELECT COUNT(p.ID) FROM {$wpdb->posts} p LEFT JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = %s LEFT JOIN {$wpdb->postmeta} f ON f.post_id = p.ID AND f.meta_key = %s WHERE p.post_status = 'publish' AND p.post_type IN ('post','page') AND (m.meta_value IS NULL OR m.meta_value = '') AND f.meta_id IS NULL", $metaKey, self::FAILED_META )); } $out['alt_text'] = (int) $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- COUNT/LIMIT 1 over postmeta joins; no WP_Query equivalent, result is per-request "SELECT COUNT(p.ID) FROM {$wpdb->posts} p LEFT JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = '_wp_attachment_image_alt' LEFT JOIN {$wpdb->postmeta} f ON f.post_id = p.ID AND f.meta_key = %s LEFT JOIN {$wpdb->postmeta} d ON d.post_id = p.ID AND d.meta_key = %s WHERE p.post_type = 'attachment' AND p.post_mime_type LIKE %s AND (m.meta_value IS NULL OR m.meta_value = '') AND f.meta_id IS NULL AND d.meta_id IS NULL", self::FAILED_META, self::DECORATIVE_META, $wpdb->esc_like('image/') . '%' )); return $out; } /** * Runs one batch. Returns false when it did not run (switched off, or another * batch holds the lock). */ public static function tick(bool $manual = false): bool { if (!$manual && !self::enabled()) { return false; } if (!Lock::acquire(self::LOCK)) { return false; } $settings = get_plugin_settings(); $batch = max(1, (int) ($settings['backfill_batch'] ?? 10)); $limit = max(0, (int) ($settings['backfill_daily_neurons'] ?? 3000)); ActivityLog::background(!$manual); // the scheduled run is the plugin's, whoever it runs as $previousUser = self::impersonateAdmin(); try { self::runBatch($batch, $limit); } finally { if ($previousUser !== null) { wp_set_current_user($previousUser); } ActivityLog::background(false); Lock::release(self::LOCK); } return true; } private static function runBatch(int $batch, int $limit): void { $state = self::state(); $items = []; $mark = Recorder::neurons(); $stopped = ''; $state['last_run'] = time(); $state['last_error'] = ''; $state['last_message'] = ''; update_option(self::STATE_OPTION, $state, false); try { self::fillBatch($state, $items, $stopped, $batch, $limit); } finally { // Also when an item throws: what the batch already changed is still logged. update_option(self::STATE_OPTION, $state, false); self::logBatch($items, $stopped, Recorder::neurons() - $mark); } } /** * @param array $state the backfill state, saved by the caller * @param list> $items */ private static function fillBatch(array &$state, array &$items, string &$stopped, int $batch, int $limit): void { foreach (self::tasks() as $task) { for ($i = 0; $i < $batch; $i++) { if ($limit > 0 && UsageTracker::todayBackfillNeurons() >= $limit) { $state['last_message'] = sprintf( /* translators: 1: feature name, 2: the daily limit in neurons */ __('The %1$s daily limit (%2$s neurons) is used up. It resumes at midnight UTC.', 'aipcf-ai-provider-for-cloudflare'), __('Backfill', 'aipcf-ai-provider-for-cloudflare'), number_format($limit) ); $stopped = $state['last_message']; break 2; } if (UsageTracker::overBudget()) { $state['last_message'] = __('The daily budget is used up. Backfill resumes at midnight UTC.', 'aipcf-ai-provider-for-cloudflare'); $stopped = $state['last_message']; break 2; } $id = $task === 'alt_text' ? self::nextAttachment() : self::nextPost(); if ($id === 0) { break; } TaskContext::pin('backfill-' . str_replace('_', '-', $task)); self::$change = null; $ok = $task === 'alt_text' ? self::fillAltText($id) : self::fillMetaDescription($id); TaskContext::clear(); $item = self::item($task, $id); if ($ok === true || $ok === self::DECORATIVE) { delete_post_meta($id, self::RETRY_META); $state['done'][$task] = ($state['done'][$task] ?? 0) + 1; $items[] = $item + [ 'result' => $ok === self::DECORATIVE ? 'decorative' : 'filled', 'reason' => $ok === self::DECORATIVE ? __('The image only decorates the page, so its alt text stays empty.', 'aipcf-ai-provider-for-cloudflare') : '', ]; if ($ok === self::DECORATIVE) { /* translators: %d: attachment ID */ $state['last_message'] = sprintf(__('Image #%d is decorative, so its alt text stays empty.', 'aipcf-ai-provider-for-cloudflare'), $id); } elseif ($task === 'alt_text') { /* translators: %d: attachment ID */ $state['last_message'] = sprintf(__('Alt text written for image #%d.', 'aipcf-ai-provider-for-cloudflare'), $id); } else { /* translators: %d: post ID */ $state['last_message'] = sprintf(__('Meta description written for post #%d.', 'aipcf-ai-provider-for-cloudflare'), $id); } } elseif ($ok instanceof \WP_Error) { // Temporary (network, rate limit, outage): leave the item queued and // stop, since the next item would most likely fail the same way. $message = $ok->get_error_message(); $tries = (int) get_post_meta($id, self::RETRY_META, true) + 1; if ($ok->get_error_code() === 'aipcf_retry' && $tries >= self::MAX_RETRIES) { delete_post_meta($id, self::RETRY_META); /* translators: 1: error message, 2: number of attempts */ $reason = sprintf(_n('%1$s. Failed after %2$d attempt.', '%1$s. Failed after %2$d attempts.', $tries, 'aipcf-ai-provider-for-cloudflare'), rtrim($message, '. '), $tries); $items[] = $item + ['result' => 'skipped', 'reason' => $reason]; self::markFailed($state, $task, $id, $reason); } else { $items[] = $item + ['result' => 'retry', 'reason' => $message]; if ($ok->get_error_code() === 'aipcf_retry') { update_post_meta($id, self::RETRY_META, $tries); } /* translators: 1: post or attachment ID, 2: error message */ $state['last_error'] = sprintf(__('#%1$d: %2$s. It is tried again on the next run.', 'aipcf-ai-provider-for-cloudflare'), $id, rtrim($message, '. ')); self::log($state['last_error']); } update_option(self::STATE_OPTION, $state, false); break 2; } else { $reason = is_string($ok) ? $ok : __('Failed.', 'aipcf-ai-provider-for-cloudflare'); $items[] = $item + ['result' => 'skipped', 'reason' => $reason]; self::markFailed($state, $task, $id, $reason); } // Saved per item so a PHP timeout mid-batch keeps the counts. update_option(self::STATE_OPTION, $state, false); } } } /** * One activity entry per batch that touched something, with every item's old and new value. * * @param list> $items */ private static function logBatch(array $items, string $stopped, float $neurons): void { if ($items === []) { return; // an hourly run with nothing to fill is not news } $count = static fn(array $results): int => count(array_filter($items, static fn(array $i): bool => in_array($i['result'], $results, true))); $ok = $count(['filled', 'decorative']); $skipped = $count(['skipped']); $retry = $count(['retry']); $message = $retry > 0 /* translators: 1: number of items, 2: filled count, 3: skipped count, 4: count to retry */ ? sprintf(_n('%1$d item: filled %2$d, skipped %3$d, will retry %4$d.', '%1$d items: filled %2$d, skipped %3$d, will retry %4$d.', count($items), 'aipcf-ai-provider-for-cloudflare'), count($items), $ok, $skipped, $retry) /* translators: 1: number of items, 2: filled count, 3: skipped count */ : sprintf(_n('%1$d item: filled %2$d, skipped %3$d.', '%1$d items: filled %2$d, skipped %3$d.', count($items), 'aipcf-ai-provider-for-cloudflare'), count($items), $ok, $skipped); if ($stopped !== '') { $message .= ' ' . $stopped; } $status = $skipped + $retry === 0 ? 'ok' : ($ok === 0 && $skipped > 0 && $retry === 0 ? 'failed' : 'warning'); // A run that only hit the same temporary error as the last one (an outage, a bad // token) is not logged again every hour: the first one says it. if ($retry === count($items) && !Recorder::pending()) { $last = ActivityLog::newest('backfill'); if ($last !== null && $last['message'] === $message && $last['items_ok'] === 0 && strtotime($last['ts'] . ' UTC') > time() - DAY_IN_SECONDS) { $lastItems = ActivityLog::items([$last['id']])[$last['id']] ?? []; if (array_column($lastItems, 'reason') === array_column($items, 'reason')) { return; } } } Recorder::record([ 'tool' => 'backfill', 'action' => 'backfill_batch', 'status' => $status, 'message' => $message, 'items_ok' => $ok, 'items_failed' => $skipped + $retry, 'neurons' => $neurons, ], $items); } /** * The activity row for an item: what it is, which field, and what the fill wrote. * * @return array */ private static function item(string $task, int $id): array { $field = $task === 'alt_text' ? '_wp_attachment_image_alt' : self::metaDescriptionKey(); $old = $field !== '' ? (string) get_post_meta($id, $field, true) : ''; $row = [ 'object_type' => $task === 'alt_text' ? 'attachment' : 'post', 'object_id' => $id, 'field' => $field, 'old' => $old, 'new' => $old, ]; if (self::$change !== null) { $row['field'] = self::$change['field']; $row['old'] = self::$change['old']; $row['new'] = self::$change['new']; } return $row; } private static function markFailed(array &$state, string $task, int $id, string $reason): void { update_post_meta($id, self::FAILED_META, $reason); $state['failed'][$task] = ($state['failed'][$task] ?? 0) + 1; $state['last_error'] = sprintf('#%d: %s', $id, $reason); self::log($state['last_error']); } private static function fillMetaDescription(int $postId) { $key = self::metaDescriptionKey(); if ($key === '') { return new \WP_Error('aipcf_env', __('No supported SEO plugin (Yoast, Rank Math, AIOSEO, SEOPress) is active.', 'aipcf-ai-provider-for-cloudflare')); } if (!class_exists('\\WordPress\\AiClient\\AiClient')) { return new \WP_Error('aipcf_env', __('The WordPress AI Client is not available. It needs WordPress 7.0 or later.', 'aipcf-ai-provider-for-cloudflare')); } $post = get_post($postId); if (!$post instanceof \WP_Post) { return __('Post not found.', 'aipcf-ai-provider-for-cloudflare'); } $content = wp_strip_all_tags(strip_shortcodes((string) $post->post_content)); $content = trim((string) preg_replace('/\s+/', ' ', $content)); if (mb_strlen($content) < 40) { return __('Content is too short to describe.', 'aipcf-ai-provider-for-cloudflare'); } $content = mb_substr($content, 0, 6000); $system = \WordPress\CloudflareWorkersAiProvider\Prompts::render('meta_description'); $user = "Title: " . get_the_title($post) . "\n\nContent:\n" . $content; try { $registry = \WordPress\AiClient\AiClient::defaultRegistry(); $result = \WordPress\AiClient\AiClient::prompt($user) ->usingProvider('cloudflare-workers-ai') ->usingSystemInstruction($system) ->usingMaxTokens(120) ->generateTextResult(); $text = trim($result->toText()); } catch (\Throwable $e) { return new \WP_Error('aipcf_retry', $e->getMessage()); } $text = sanitize_text_field(trim($text, " \"'`\n")); $text = (string) preg_replace('/^(meta description:)\s*/i', '', $text); if ($text === '') { return __('The model returned an empty description.', 'aipcf-ai-provider-for-cloudflare'); } if (mb_strlen($text) > 200) { $cut = mb_substr($text, 0, 197); // Cut at the last word boundary unless that loses too much (mb_strrpos is not polyfilled by core). $atWord = (string) preg_replace('/\s+\S*$/u', '', $cut); $text = (mb_strlen($atWord) > 120 ? $atWord : $cut) . '...'; } self::$change = ['field' => $key, 'old' => (string) get_post_meta($postId, $key, true), 'new' => $text]; update_post_meta($postId, $key, $text); do_action('aipcf_backfill_meta_description', $postId, $text); return true; } private static function fillAltText(int $attachmentId) { if (!class_exists('\\WordPress\\AiClient\\AiClient')) { return new \WP_Error('aipcf_env', __('The WordPress AI Client is not available. It needs WordPress 7.0 or later.', 'aipcf-ai-provider-for-cloudflare')); } $mime = (string) get_post_mime_type($attachmentId); if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp', 'image/gif'], true)) { return sprintf( /* translators: %s: MIME type */ __('Unsupported image type %s (the vision model reads JPEG, PNG, WebP and GIF).', 'aipcf-ai-provider-for-cloudflare'), $mime !== '' ? $mime : __('unknown', 'aipcf-ai-provider-for-cloudflare') ); } // Local file when present; otherwise the public URL (offloaded media, e.g. R2/S3). $path = self::attachmentImagePath($attachmentId); if ($path === '') { $path = (string) (wp_get_attachment_image_url($attachmentId, 'large') ?: wp_get_attachment_url($attachmentId)); } if ($path === '') { return __('The image file was not found and it has no URL.', 'aipcf-ai-provider-for-cloudflare'); } $vision = \WordPress\CloudflareWorkersAiProvider\vision_model_id(); $title = get_the_title($attachmentId); $hint = $title !== '' && !preg_match('/^(img|dsc|screenshot|image)[-_ ]?\\d*$/i', $title) ? ' The file is titled "' . $title . '".' : ''; $prompt = \WordPress\CloudflareWorkersAiProvider\Prompts::render('alt_text', ['title' => $hint]); $prompt = (string) apply_filters('aipcf_backfill_alt_prompt', $prompt, $attachmentId); try { $registry = \WordPress\AiClient\AiClient::defaultRegistry(); $model = $registry->getProviderModel('cloudflare-workers-ai', $vision); $message = new \WordPress\AiClient\Messages\DTO\Message( \WordPress\AiClient\Messages\Enums\MessageRoleEnum::user(), [ new \WordPress\AiClient\Messages\DTO\MessagePart($prompt), new \WordPress\AiClient\Messages\DTO\MessagePart(new \WordPress\AiClient\Files\DTO\File($path)), ] ); $result = \WordPress\AiClient\AiClient::prompt([$message]) ->usingModel($model) ->usingMaxTokens(80) ->generateTextResult(); $text = trim($result->toText()); } catch (\Throwable $e) { return new \WP_Error('aipcf_retry', $e->getMessage()); } if (stripos($text, 'DECORATIVE_ALT') !== false) { $old = (string) get_post_meta($attachmentId, '_wp_attachment_image_alt', true); self::$change = ['field' => '_wp_attachment_image_alt', 'old' => $old, 'new' => $old]; update_post_meta($attachmentId, self::DECORATIVE_META, 1); return self::DECORATIVE; } $text = sanitize_text_field(trim($text, " \"'`\n")); $text = (string) preg_replace('/^(alt text:|alt:)\\s*/i', '', $text); if ($text === '') { return __('The model returned empty alt text.', 'aipcf-ai-provider-for-cloudflare'); } if (mb_strlen($text) > 200) { $text = mb_substr($text, 0, 197) . '...'; } self::$change = ['field' => '_wp_attachment_image_alt', 'old' => (string) get_post_meta($attachmentId, '_wp_attachment_image_alt', true), 'new' => $text]; update_post_meta($attachmentId, '_wp_attachment_image_alt', $text); do_action('aipcf_backfill_alt_text', $attachmentId, $text); return true; } private static function attachmentImagePath(int $attachmentId): string { $original = (string) get_attached_file($attachmentId); $large = image_get_intermediate_size($attachmentId, 'large'); if (is_array($large) && !empty($large['path'])) { $dir = wp_get_upload_dir(); $p = trailingslashit((string) $dir['basedir']) . $large['path']; if (file_exists($p)) { return $p; } } return $original !== '' && file_exists($original) ? $original : ''; } public static function metaDescriptionKey(): string { $key = ''; $class = '\\WordPress\\AI\\Abilities\\Meta_Description\\SEO_Integration'; // The AI plugin's class is optional; call it only when it is loaded. $detect = [$class, 'detect_active_plugin']; $getKey = [$class, 'get_meta_key']; if (class_exists($class) && is_callable($detect)) { $slug = call_user_func($detect); if ($slug !== null && is_callable($getKey)) { $key = (string) call_user_func($getKey, $slug); } } if ($key === '') { // Same map the AI plugin uses, so both agree when it is present. $plugins = [ 'wordpress-seo/wp-seo.php' => '_yoast_wpseo_metadesc', 'wordpress-seo-premium/wp-seo-premium.php' => '_yoast_wpseo_metadesc', 'seo-by-rank-math/rank-math.php' => 'rank_math_description', 'seo-by-rank-math-pro/rank-math-pro.php' => 'rank_math_description', 'all-in-one-seo-pack/all_in_one_seo_pack.php' => '_aioseo_description', 'wp-seopress/seopress.php' => '_seopress_titles_desc', ]; if (!function_exists('is_plugin_active')) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } foreach ($plugins as $file => $metaKey) { if (is_plugin_active($file)) { $key = $metaKey; break; } } } return (string) apply_filters('aipcf_backfill_meta_description_key', $key); } private static function nextPost(): int { global $wpdb; $key = self::metaDescriptionKey(); if ($key === '') { return 0; } return (int) $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- COUNT/LIMIT 1 over postmeta joins; no WP_Query equivalent, result is per-request "SELECT p.ID FROM {$wpdb->posts} p LEFT JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = %s LEFT JOIN {$wpdb->postmeta} f ON f.post_id = p.ID AND f.meta_key = %s WHERE p.post_status = 'publish' AND p.post_type IN ('post','page') AND (m.meta_value IS NULL OR m.meta_value = '') AND f.meta_id IS NULL ORDER BY p.post_date DESC LIMIT 1", $key, self::FAILED_META )); } private static function nextAttachment(): int { global $wpdb; return (int) $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- COUNT/LIMIT 1 over postmeta joins; no WP_Query equivalent, result is per-request "SELECT p.ID FROM {$wpdb->posts} p LEFT JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = '_wp_attachment_image_alt' LEFT JOIN {$wpdb->postmeta} f ON f.post_id = p.ID AND f.meta_key = %s LEFT JOIN {$wpdb->postmeta} d ON d.post_id = p.ID AND d.meta_key = %s WHERE p.post_type = 'attachment' AND p.post_mime_type LIKE %s AND (m.meta_value IS NULL OR m.meta_value = '') AND f.meta_id IS NULL AND d.meta_id IS NULL ORDER BY p.post_date DESC LIMIT 1", self::FAILED_META, self::DECORATIVE_META, $wpdb->esc_like('image/') . '%' )); } /** * Returns the user ID to restore afterwards, or null when nothing was switched. */ private static function impersonateAdmin(): ?int { $current = get_current_user_id(); if ($current > 0) { return null; } $admins = get_users(['role' => 'administrator', 'number' => 1, 'orderby' => 'ID', 'order' => 'ASC', 'fields' => 'ID']); if ($admins === []) { return null; } wp_set_current_user((int) $admins[0]); return $current; } public static function handleRunNow(): void { if (!current_user_can('manage_options')) { wp_die(esc_html__('You do not have permission to do this.', 'aipcf-ai-provider-for-cloudflare'), 403); } check_admin_referer('aipcf_backfill_run'); $before = self::state(); if (!self::tick(true)) { set_transient('aipcf_notice_' . get_current_user_id(), ['type' => 'warning', 'message' => __('Another backfill batch is running right now. Try again in a minute.', 'aipcf-ai-provider-for-cloudflare')], 60); wp_safe_redirect(self::panelUrl()); exit; } $after = self::state(); $done = array_sum((array) $after['done']) - array_sum((array) $before['done']); $failed = array_sum((array) $after['failed']) - array_sum((array) $before['failed']); $left = self::remaining(); if ($done + $failed === 0 && $after['last_message'] === '') { $message = array_sum($left) === 0 ? __('Nothing to fill: every post has a meta description and every image has alt text.', 'aipcf-ai-provider-for-cloudflare') : __('The batch did not run. Check that the connection works and that a task is selected under "What to fill".', 'aipcf-ai-provider-for-cloudflare'); $type = 'warning'; } else { $message = implode(' ', [ /* translators: 1: filled count, 2: skipped count */ sprintf(__('Filled %1$d, skipped %2$d.', 'aipcf-ai-provider-for-cloudflare'), $done, $failed), /* translators: %s: number of posts */ sprintf(_n('%s post still has no meta description.', '%s posts still have no meta description.', (int) $left['meta_description'], 'aipcf-ai-provider-for-cloudflare'), number_format($left['meta_description'])), /* translators: %s: number of images */ sprintf(_n('%s image still has no alt text.', '%s images still have no alt text.', (int) $left['alt_text'], 'aipcf-ai-provider-for-cloudflare'), number_format($left['alt_text'])), ]); if ($after['last_message'] !== '') { $message .= ' ' . $after['last_message']; } $type = $failed > 0 && $done === 0 ? 'error' : 'success'; } set_transient('aipcf_notice_' . get_current_user_id(), ['type' => $type, 'message' => $message], 60); wp_safe_redirect(self::panelUrl()); exit; } public static function handleReset(): void { if (!current_user_can('manage_options')) { wp_die(esc_html__('You do not have permission to do this.', 'aipcf-ai-provider-for-cloudflare'), 403); } check_admin_referer('aipcf_backfill_reset'); global $wpdb; $queued = (int) $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- a count across two meta keys, right before they are deleted "SELECT COUNT(DISTINCT post_id) FROM {$wpdb->postmeta} WHERE meta_key IN (%s, %s)", self::FAILED_META, self::DECORATIVE_META )); delete_post_meta_by_key(self::FAILED_META); delete_post_meta_by_key(self::DECORATIVE_META); delete_post_meta_by_key(self::RETRY_META); $state = self::state(); $state['failed'] = []; $state['last_error'] = ''; update_option(self::STATE_OPTION, $state, false); set_transient('aipcf_notice_' . get_current_user_id(), [ 'type' => 'success', 'message' => $queued > 0 /* translators: %d: number of posts and images */ ? sprintf(_n('%d item queued again.', '%d items queued again.', $queued, 'aipcf-ai-provider-for-cloudflare'), $queued) : __('No skipped items to retry.', 'aipcf-ai-provider-for-cloudflare'), ], 60); wp_safe_redirect(self::panelUrl()); exit; } /** * The Backfill tab, scrolled to the status and its buttons. */ private static function panelUrl(): string { return \WordPress\CloudflareWorkersAiProvider\Admin\SettingsPage::tabUrl('backfill') . '#aipcf-backfill-status'; } private static function emptyState(): array { return ['last_run' => 0, 'done' => [], 'failed' => [], 'last_message' => '', 'last_error' => '']; } private static function log(string $message): void { if (defined('WP_DEBUG') && WP_DEBUG) { error_log('[aipcf backfill] ' . $message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } } }