Search insights: what people searched for, what found nothing, and the content gaps. */ final class InsightsTab { private const PERIODS = [7, 30, 90]; public const SLUG = 'aipcf-search-insights'; public static function boot(): void { // After the Link assistant (priority 20, booted later), so the menu reads Settings, // Link assistant, Search insights. add_action('admin_menu', [self::class, 'registerMenu'], 21); add_action('admin_post_aipcf_insights_score', [self::class, 'handleScore']); add_action('admin_post_aipcf_insights_analyse', [self::class, 'handleAnalyse']); add_action('admin_post_aipcf_insights_clear', [self::class, 'handleClear']); } public static function registerMenu(): void { add_submenu_page( SettingsPage::PAGE_SLUG, __('Insights', 'aipcf-ai-provider-for-cloudflare') . ' ‹ ' . \WordPress\CloudflareWorkersAiProvider\Brand::NAME, __('Insights', 'aipcf-ai-provider-for-cloudflare'), 'manage_options', self::SLUG, [self::class, 'render'] ); } public static function pageUrl(int $days = 0): string { $url = admin_url('admin.php?page=' . self::SLUG); return $days > 0 ? add_query_arg('days', $days, $url) : $url; } private static function days(): int { $d = isset($_GET['days']) ? absint($_GET['days']) : 30; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- a report period, nothing is changed return in_array($d, self::PERIODS, true) ? $d : 30; } public static function render(): 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); } $on = Insights::enabled(); echo '
'; SettingsPage::header( 'dashicons-search', __('Search insights', 'aipcf-ai-provider-for-cloudflare'), __('What people search for on your site, and what your content does not answer yet.', 'aipcf-ai-provider-for-cloudflare'), $on ? null : ['is-off', __('Off', 'aipcf-ai-provider-for-cloudflare')], sprintf('%s', esc_url(FeaturesPage::url('insights') . '#aipcf-insights-enabled'), esc_html__('Settings', 'aipcf-ai-provider-for-cloudflare')) ); echo '
'; if (!$on) { printf( '

%s

%s

%s
', /* translators: %s: feature name, like "Search insights" */ esc_html(sprintf(__('%s is off', 'aipcf-ai-provider-for-cloudflare'), __('Search insights', 'aipcf-ai-provider-for-cloudflare'))), esc_html__('Switch it on in AIPCF > Features > Search insights. Content gaps need the content index.', 'aipcf-ai-provider-for-cloudflare'), esc_url(FeaturesPage::url('insights') . '#aipcf-insights-enabled'), esc_html__('Open Search insights', 'aipcf-ai-provider-for-cloudflare') ); return; } $days = self::days(); $r = Insights::report($days); $embeddings = EmbeddingSettings::enabled(); echo '

' . esc_html__('Searches', 'aipcf-ai-provider-for-cloudflare') . '

'; echo '
'; foreach (self::PERIODS as $d) { /* translators: %d: number of days */ $label = sprintf(_n('Last %d day', 'Last %d days', $d, 'aipcf-ai-provider-for-cloudflare'), $d); printf('%s', esc_url(self::pageUrl($d)), $d === $days ? ' aria-current="page"' : '', esc_html($label)); } echo '
'; $change = $r['previous'] > 0 ? (int) round(100 * ($r['total'] - $r['previous']) / $r['previous']) : null; echo '

'; self::count( /* translators: %s: number of searches */ _n('%s search', '%s searches', $r['total'], 'aipcf-ai-provider-for-cloudflare'), $r['total'] ); if ($change !== null) { /* translators: %s: signed percentage, like +12 or -5 */ echo '' . esc_html(sprintf(__('%s%% on the period before', 'aipcf-ai-provider-for-cloudflare'), ($change > 0 ? '+' : '') . $change)) . ''; } /* translators: %s: number of searches */ self::count(_n('%s by members', '%s by members', $r['members'], 'aipcf-ai-provider-for-cloudflare'), $r['members']); /* translators: %s: number of searches */ self::count(_n('%s from Search', '%s from Search', $r['semantic'], 'aipcf-ai-provider-for-cloudflare'), $r['semantic']); echo '

'; // Without Embeddings nothing can be compared, so neither the waiting count nor // "Compare now" is shown; the Content gaps panel says why. if ($embeddings && $r['unscored'] > 0) { printf( '

%s %s

', esc_html(sprintf( /* translators: %d: number of searches */ _n('%d search is waiting to be compared with your content (done hourly in the background).', '%d searches are waiting to be compared with your content (done hourly in the background).', $r['unscored'], 'aipcf-ai-provider-for-cloudflare'), $r['unscored'] )), esc_attr__('Comparing...', 'aipcf-ai-provider-for-cloudflare'), esc_url(wp_nonce_url(admin_url('admin-post.php?action=aipcf_insights_score&days=' . $days), 'aipcf_insights_score')), esc_html__('Compare now', 'aipcf-ai-provider-for-cloudflare') ); } echo '
'; self::table( __('Content gaps', 'aipcf-ai-provider-for-cloudflare'), __('Searches whose closest post is a weak match: topics people look for that the site does not cover well yet.', 'aipcf-ai-provider-for-cloudflare'), __('Searches whose closest post is a weak match appear here once they have been compared with your content (hourly in the background).', 'aipcf-ai-provider-for-cloudflare'), $r['gaps'], true, $embeddings ? '' : self::embeddingsNotice() ); self::analysis($days, count($r['gaps'])); self::table( __('Searches that found nothing', 'aipcf-ai-provider-for-cloudflare'), __('WordPress search returned no results.', 'aipcf-ai-provider-for-cloudflare'), __('Searches that return no results appear here as soon as someone makes one.', 'aipcf-ai-provider-for-cloudflare'), $r['empty'], false ); self::table( __('Top searches', 'aipcf-ai-provider-for-cloudflare'), '', __('The most frequent searches of the period appear here as soon as people search the site.', 'aipcf-ai-provider-for-cloudflare'), $r['top'], true ); $retention = (int) Settings::get('insights_retention_days'); printf( '

%s

%s

%s

', esc_html__('Logged searches', 'aipcf-ai-provider-for-cloudflare'), esc_html(sprintf( /* translators: %d: number of days */ _n('Searches are deleted after %d day, including after Search insights is switched off. To delete them now, use Delete logged searches.', 'Searches are deleted after %d days, including after Search insights is switched off. To delete them now, use Delete logged searches.', $retention, 'aipcf-ai-provider-for-cloudflare'), $retention )), esc_url(wp_nonce_url(admin_url('admin-post.php?action=aipcf_insights_clear&days=' . $days), 'aipcf_insights_clear')), esc_attr__('Delete every logged search and the "What to write next" analysis? This cannot be undone.', 'aipcf-ai-provider-for-cloudflare'), esc_attr__('Deleting...', 'aipcf-ai-provider-for-cloudflare'), esc_html__('Delete logged searches', 'aipcf-ai-provider-for-cloudflare') ); echo ''; } /** * One item of a counts line: "12 searches" from a "%s searches" pattern. */ private static function count(string $pattern, int $n): void { $parts = explode('%s', $pattern, 2); echo '' . esc_html($parts[0]) . '' . esc_html(number_format_i18n($n)) . '' . esc_html($parts[1] ?? '') . ''; } /** * The Content gaps notice while Embeddings are off: searches are still recorded, but they * cannot be compared with the content, so no gap can be found. */ private static function embeddingsNotice(): string { return sprintf( '

%s

%s

%s
', esc_html__('Content gaps need Embeddings', 'aipcf-ai-provider-for-cloudflare'), esc_html__('Switch the content index on in AIPCF > Settings > Content index to find content gaps.', 'aipcf-ai-provider-for-cloudflare'), esc_url(SettingsPage::tabUrl('embeddings')), esc_html__('Open Embeddings', 'aipcf-ai-provider-for-cloudflare') ); } /** * @param list> $rows * @param string $notice Escaped HTML shown under the description; it replaces the empty state. */ private static function table(string $title, string $desc, string $empty, array $rows, bool $withMatch, string $notice = ''): void { echo '

' . esc_html($title) . '

'; if ($desc !== '') { echo '

' . esc_html($desc) . '

'; } if ($notice !== '') { echo $notice; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built from escaped parts if ($rows === []) { echo '
'; return; } } if ($rows === []) { printf( '

%s

%s

', esc_html__('Nothing yet', 'aipcf-ai-provider-for-cloudflare'), esc_html($empty) ); return; } echo '
'; if ($withMatch) { echo ''; } echo ''; foreach ($rows as $row) { echo ''; if ($withMatch) { $top = (int) $row['top_id']; echo ''; echo ''; } echo ''; } echo '
' . esc_html__('Search', 'aipcf-ai-provider-for-cloudflare') . '' . esc_html__('Times', 'aipcf-ai-provider-for-cloudflare') . '' . esc_html__('Closest post', 'aipcf-ai-provider-for-cloudflare') . '' . esc_html__('Match', 'aipcf-ai-provider-for-cloudflare') . '
' . esc_html((string) $row['q']) . '' . esc_html(number_format_i18n((int) $row['n'])) . '' . ($top > 0 && get_post_status($top) ? '' . esc_html(get_the_title($top)) . '' : '—') . '' . ($row['score'] === null ? '—' : esc_html(sprintf( /* translators: %d: similarity as a percentage */ __('%d%%', 'aipcf-ai-provider-for-cloudflare'), (int) round(100 * max(0.0, min(1.0, (float) $row['score']))) ))) . '
'; } private static function analysis(int $days, int $gaps): void { $saved = get_option(Insights::REPORT_OPTION); echo '

' . esc_html__('What to write next', 'aipcf-ai-provider-for-cloudflare') . '

'; echo '

' . esc_html__('The AI model groups the content gaps into topics and suggests an article or a post to extend for each. It only sees the logged searches and the titles of your closest posts.', 'aipcf-ai-provider-for-cloudflare') . '

'; if (is_array($saved) && !empty($saved['text'])) { echo '

' . esc_html(sprintf( /* translators: 1: time since, like "3 hours", 2: number of gaps */ _n('From %2$d gap, %1$s ago.', 'From %2$d gaps, %1$s ago.', (int) $saved['gaps'], 'aipcf-ai-provider-for-cloudflare'), human_time_diff((int) $saved['at']), (int) $saved['gaps'] )) . '

'; echo '
' . self::linkRefs((string) $saved['text']) . '
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- linkRefs() escapes the text and builds escaped links } if ($gaps > 0) { $left = Budget::remaining(Insights::TASK); printf( '

%s %s

', esc_attr__('Analyzing...', 'aipcf-ai-provider-for-cloudflare'), esc_url(wp_nonce_url(admin_url('admin-post.php?action=aipcf_insights_analyse&days=' . $days), 'aipcf_insights_analyse')), esc_html(is_array($saved) ? __('Analyze again', 'aipcf-ai-provider-for-cloudflare') : __('Analyze the gaps', 'aipcf-ai-provider-for-cloudflare')), esc_html($left === null ? __('One request to your default model.', 'aipcf-ai-provider-for-cloudflare') /* translators: %s: number of neurons */ : sprintf(__('One request to your default model. %s neurons left today for Search insights.', 'aipcf-ai-provider-for-cloudflare'), number_format_i18n($left))) ); } echo '
'; } /** * Escaped text with [#id] turned into links to those posts, line breaks kept. */ private static function linkRefs(string $text): string { $html = nl2br(esc_html($text)); return (string) preg_replace_callback('/\[#(\d+)\]/', static function (array $m): string { $id = (int) $m[1]; return get_post_status($id) === 'publish' ? '' . esc_html(get_the_title($id)) . '' : ''; }, $html); } public static function handleScore(): void { self::guard('aipcf_insights_score'); $r = Insights::scoreSiteSearches(); if (is_wp_error($r)) { self::back('error', $r->get_error_message(), 'aipcf-insights-period'); } /* translators: %d: number of searches */ self::back('success', sprintf(_n('%d different search compared with your content.', '%d different searches compared with your content.', $r, 'aipcf-ai-provider-for-cloudflare'), $r), 'aipcf-insights-period'); } public static function handleAnalyse(): void { self::guard('aipcf_insights_analyse'); $r = Insights::analyse(self::days()); self::back(is_wp_error($r) ? 'error' : 'success', is_wp_error($r) ? $r->get_error_message() : __('Analysis done. It is below, under "What to write next".', 'aipcf-ai-provider-for-cloudflare'), 'aipcf-insights-next'); } public static function handleClear(): void { self::guard('aipcf_insights_clear'); Insights::clear(); self::back('success', __('Every logged search was deleted.', 'aipcf-ai-provider-for-cloudflare'), 'aipcf-insights-logged'); } private static function guard(string $action): 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($action); } private static function back(string $type, string $message, string $anchor = ''): void { set_transient('aipcf_notice_' . get_current_user_id(), ['type' => $type, 'message' => $message], 60); wp_safe_redirect(self::pageUrl(self::days()) . ($anchor !== '' ? '#' . $anchor : '')); exit; } }