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 '
',
/* 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(
'',
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(
'',
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(
'',
esc_html__('Nothing yet', 'aipcf-ai-provider-for-cloudflare'),
esc_html($empty)
);
return;
}
echo '| ' . esc_html__('Search', 'aipcf-ai-provider-for-cloudflare') . ' | ' . esc_html__('Times', 'aipcf-ai-provider-for-cloudflare') . ' | ';
if ($withMatch) {
echo '' . esc_html__('Closest post', 'aipcf-ai-provider-for-cloudflare') . ' | ' . esc_html__('Match', 'aipcf-ai-provider-for-cloudflare') . ' | ';
}
echo '
';
foreach ($rows as $row) {
echo '| ' . esc_html((string) $row['q']) . ' | ' . esc_html(number_format_i18n((int) $row['n'])) . ' | ';
if ($withMatch) {
$top = (int) $row['top_id'];
echo '' . ($top > 0 && get_post_status($top) ? '' . esc_html(get_the_title($top)) . '' : '—') . ' | ';
echo '' . ($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'])))
))) . ' | ';
}
echo '
';
}
echo '
';
}
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;
}
}