$v) {
$url .= '&' . rawurlencode((string) $k) . '=' . rawurlencode((string) $v);
}
return $url;
}
/** True on the plugin's own admin page. */
function citami_is_admin_page() {
return isset($_GET['page']) && (string) $_GET['page'] === CITAMI_ADMIN_SLUG; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only: are we on our own screen
}
/** Menu icon: a speech mark, the brand's glyph. WordPress recolours SVG data URIs with the admin scheme. */
function citami_admin_menu_icon() {
$svg = '';
return 'data:image/svg+xml;base64,' . base64_encode($svg);
}
/** Inline stroke icons for the navigation and cards (16×16, currentColor). */
function citami_ui_icon($name) {
$paths = array(
'overview' => '',
'general' => '',
'seo' => '',
'brand' => '',
'bots' => '',
'redirects'=> '',
'local' => '',
'gsc' => '',
'articles' => '',
'links' => '',
'import' => '',
'pending' => '',
'setup' => '',
'check' => '',
'external' => '',
'arrow' => '',
'spark' => '',
'pages' => '',
);
if (!isset($paths[$name])) {
return '';
}
return '';
}
/** The side navigation: id, label, icon, optional badge. Overview first. */
function citami_ui_nav_items($pending_count = 0) {
// 2.61 — grouped by what the owner does: every week, the site as the AI
// engines see it, then setup. The group label is printed before its first item.
$work = citami_t('Ogni settimana', 'Every week');
$ai = citami_t('Il sito per le AI', 'The site for AI');
$setup = citami_t('Configurazione', 'Setup');
$items = array(
array('id' => 'overview', 'label' => citami_t('Panoramica', 'Overview'), 'icon' => 'overview', 'group' => $work),
array('id' => 'pages', 'label' => citami_t('Pagine', 'Pages'), 'icon' => 'pages', 'group' => $work),
array('id' => 'gsc', 'label' => 'Search Console', 'icon' => 'gsc', 'group' => $work),
array('id' => 'links', 'label' => citami_t('Link interni', 'Internal links'), 'icon' => 'links', 'group' => $work),
array('id' => 'articles', 'label' => citami_t('Articoli', 'Articles'), 'icon' => 'articles', 'group' => $work),
array('id' => 'brand', 'label' => citami_t('Brand', 'Brand'), 'icon' => 'brand', 'group' => $ai),
array('id' => 'bots', 'label' => citami_t('Bot AI', 'AI bots'), 'icon' => 'bots', 'group' => $ai),
array('id' => 'redirects', 'label' => citami_t('Redirect', 'Redirects'), 'icon' => 'redirects', 'group' => $ai),
array('id' => 'pending', 'label' => citami_t('Fix in coda', 'Pending fixes'), 'icon' => 'pending', 'badge' => (int) $pending_count, 'group' => $ai),
array('id' => 'seo', 'label' => 'SEO', 'icon' => 'seo', 'group' => $setup),
array('id' => 'local', 'label' => citami_t('Sedi', 'Locations'), 'icon' => 'local', 'group' => $setup),
array('id' => 'import', 'label' => citami_t('Importa', 'Import'), 'icon' => 'import', 'group' => $setup),
array('id' => 'general', 'label' => citami_t('Impostazioni', 'Settings'), 'icon' => 'general', 'group' => $setup),
);
return $items;
}
/** The tab ids the page accepts (nav items plus the wizard). */
function citami_ui_tab_ids() {
$ids = array();
foreach (citami_ui_nav_items() as $it) {
$ids[] = $it['id'];
}
$ids[] = 'setup';
return $ids;
}
/** AI crawler hits per day for the last N days, oldest first (search engines and spoofed hits excluded). */
function citami_ui_daily_hits($days = 14) {
$days = max(1, (int) $days);
$log = get_option(CITAMI_BOTS_LOG_OPT, array());
$skip = function_exists('citami_widget_search_only_bots') ? citami_widget_search_only_bots() : array();
$out = array();
$today = strtotime(gmdate('Y-m-d 00:00:00'));
for ($i = $days - 1; $i >= 0; $i--) {
$out[gmdate('Y-m-d', $today - $i * DAY_IN_SECONDS)] = 0;
}
foreach ((array) $log as $e) {
if (!is_array($e) || !isset($e['ts'], $e['bot'])) {
continue;
}
if (in_array($e['bot'], $skip, true) || (isset($e['verified']) && $e['verified'] === false)) {
continue;
}
$d = gmdate('Y-m-d', (int) $e['ts']);
if (isset($out[$d])) {
$out[$d]++;
}
}
$series = array();
foreach ($out as $d => $n) {
$series[] = array('d' => $d, 'hits' => $n);
}
return $series;
}
/** What to do now: the four things that make a site citable, each with its state and where to do it. */
function citami_ui_checklist() {
$key = citami_get_api_key() !== '';
$sheet = function_exists('citami_truth_sheet') ? citami_truth_sheet() : array();
$has_sheet = is_array($sheet) && !empty($sheet['name']);
$about = function_exists('citami_truth_about_check') ? citami_truth_about_check() : array('found' => false, 'score' => 0);
$about_ok = !empty($about['found']) && (int) $about['score'] >= 60;
$policy = function_exists('citami_bots_policy') ? citami_bots_policy() : array('rules' => array());
$bots_set = !empty($policy['rules']);
$items = array(
array('id' => 'sheet', 'done' => $has_sheet, 'href' => $has_sheet ? citami_admin_url('brand') : citami_setup_url(2),
'label' => citami_t('Scheda dei fatti compilata', 'Fact sheet filled in'),
'hint' => citami_t('Nome, tipo, città, telefono: da qui parte lo schema dell\'organizzazione.', 'Name, type, city, phone: the organisation schema starts here.')),
array('id' => 'about', 'done' => $about_ok, 'href' => $has_sheet ? citami_setup_url(3) : citami_setup_url(2),
'label' => citami_t('Pagina Chi siamo completa', 'About page complete'),
'hint' => !empty($about['found']) ? sprintf(citami_t('Punteggio %d/100.', 'Score %d/100.'), (int) $about['score']) : citami_t('La pagina che ogni motore legge per capire chi sei.', 'The page every engine reads to learn who you are.')),
array('id' => 'bots', 'done' => $bots_set, 'href' => citami_admin_url('bots'),
'label' => citami_t('Crawler AI impostati', 'AI crawlers set'),
'hint' => citami_t('Consigliato: ricerca e assistenti sì, solo addestramento no.', 'Recommended: search and assistants yes, training-only no.')),
array('id' => 'connect', 'done' => $key, 'href' => $key ? citami_admin_url('general') : citami_setup_url(1),
'label' => citami_t('Collegato a Citami', 'Connected to Citami'),
'hint' => citami_t('Audit, fix e prova mensile arrivano dalla dashboard.', 'Audits, fixes and the monthly proof come from the dashboard.')),
);
$done = 0;
foreach ($items as $it) {
if ($it['done']) {
$done++;
}
}
return array('items' => $items, 'done' => $done, 'total' => count($items));
}
/** Everything the Overview shows. */
function citami_ui_overview_data() {
$w = function_exists('citami_widget_data') ? citami_widget_data() : array();
$daily = citami_ui_daily_hits(14);
$total14 = 0;
foreach ($daily as $d) {
$total14 += (int) $d['hits'];
}
$health = get_option(CITAMI_HEALTH_OPT, array());
return array(
'widget' => $w,
'daily' => $daily,
'hits14' => $total14,
'live14' => function_exists('citami_bots_live_reads') ? citami_bots_live_reads(14) : array('total' => 0, 'by' => array()),
'checklist' => citami_ui_checklist(),
'connected' => citami_get_api_key() !== '',
'health_ok' => is_array($health) && !empty($health['ok']),
'health' => is_array($health) ? $health : array(),
'setup_done'=> function_exists('citami_setup_is_done') ? citami_setup_is_done() : true,
);
}
/** Ring gauge markup (CSS/JS animate it). $value null = no data. */
function citami_ui_ring($value, $label, $sub = '') {
$v = $value === null ? 0 : max(0, min(100, (int) $value));
$tone = $value === null ? 'na' : ($v >= 80 ? 'good' : ($v >= 55 ? 'warn' : 'bad'));
ob_start();
?>
' . wp_nonce_field('citami_overview', '_citami_overview_nonce', true, false) . '' . $extra;
}
/**
* Creates a 301 for the invented URLs the owner ticked (2.58: only those —
* the list shows each "from → to" before anything is created). null keeps
* the old "every URL with a suggested target" behaviour for callers that
* have no selection.
*/
function citami_overview_create_redirects($selected = null) {
$done = 0;
$skipped = 0;
$existing = array_map(function ($r) { return (string) $r['source']; }, citami_redirects_all());
foreach (function_exists('citami_404_ai_entries') ? citami_404_ai_entries(20) : array() as $h) {
if (in_array((string) $h['path'], $existing, true)) {
continue;
}
if (is_array($selected) && !in_array((string) $h['path'], $selected, true)) {
continue;
}
if (empty($h['suggested'])) {
$skipped++;
continue;
}
$r = citami_redirect_add(array('source' => $h['path'], 'target' => $h['suggested'], 'code' => 301, 'match' => 'exact'));
if (is_wp_error($r)) {
$skipped++;
continue;
}
$done++;
if (function_exists('citami_activity_log')) {
citami_activity_log('redirect', sprintf(citami_t('Redirect creato: %1$s → %2$s', 'Redirect created: %1$s → %2$s'), $h['path'], $h['suggested']), 1);
}
}
return array('done' => $done, 'skipped' => $skipped);
}
function citami_handle_overview_action() {
if (!current_user_can('manage_options')) {
wp_die('Permesso negato.', '', array('response' => 403));
}
if (!wp_verify_nonce(isset($_POST['_citami_overview_nonce']) ? sanitize_text_field(wp_unslash($_POST['_citami_overview_nonce'])) : '', 'citami_overview')) {
wp_die('Nonce non valido.', '', array('response' => 403));
}
$do = isset($_POST['do']) ? sanitize_key((string) $_POST['do']) : '';
$ids = array_values(array_filter(array_map('intval', explode(',', isset($_POST['ids']) ? sanitize_text_field(wp_unslash($_POST['ids'])) : ''))));
if ($do === 'redirects') {
$sel = array();
foreach ((array) (isset($_POST['sel']) ? wp_unslash($_POST['sel']) : array()) as $path) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each item sanitized below
$path = sanitize_text_field((string) $path);
if ($path !== '') $sel[] = $path;
}
if (empty($sel)) {
$notice = array('type' => 'warning', 'message' => citami_t('Nessun redirect selezionato: non ho creato niente.', 'No redirect selected: nothing was created.'));
} else {
$r = citami_overview_create_redirects($sel);
$notice = array('type' => $r['done'] > 0 ? 'success' : 'warning', 'message' => sprintf(citami_t('%1$d redirect creati: li trovi, e li puoi disattivare, nella scheda Redirect.', '%1$d redirects created: you find them, and can switch them off, in the Redirects tab.'), $r['done']));
}
} elseif ($do === 'indexnow') {
$urls = array();
foreach ($ids as $id) {
$u = get_permalink($id);
if ($u) $urls[] = $u;
}
$r = function_exists('citami_indexnow_submit_now') && !empty($urls) ? citami_indexnow_submit_now($urls) : array('added' => 0, 'report' => array());
$notice = array('type' => $r['added'] > 0 ? 'success' : 'warning', 'message' => sprintf(citami_t('%d URL inviati a IndexNow.', '%d URLs sent to IndexNow.'), (int) $r['added']));
} elseif ($do === 'pin') {
$before = array_map('intval', (array) get_option('citami_llms_pinned', array()));
$pinned = array_slice(array_values(array_unique(array_merge($before, $ids))), 0, 30);
update_option('citami_llms_pinned', $pinned, false);
// 2.60 — count only the pages this click added (pinning twice is not "N more").
$added = count(array_diff($pinned, $before));
if (function_exists('citami_activity_log') && $added > 0) {
citami_activity_log('llms', sprintf(citami_t('%d pagine messe in evidenza in llms.txt', '%d pages pinned in llms.txt'), $added), 1);
}
$notice = $added > 0
? array('type' => 'success', 'message' => sprintf(citami_t('%d pagine ora in cima a llms.txt, sezione "Pagine importanti". Le vedi (e le togli) in SEO → llms.txt.', '%d pages now at the top of llms.txt, "Important pages" section. You see (and remove) them in SEO → llms.txt.'), $added))
: array('type' => 'info', 'message' => citami_t('Queste pagine erano già in cima a llms.txt: nessuna aggiunta.', 'These pages were already at the top of llms.txt: nothing added.'));
} else {
$notice = array('type' => 'error', 'message' => citami_t('Azione sconosciuta.', 'Unknown action.'));
}
set_transient('citami_action_notice', $notice, 30);
wp_safe_redirect(citami_admin_url('overview'));
exit;
}
add_action('admin_post_citami_overview_action', 'citami_handle_overview_action');
function citami_render_overview_tab() {
$d = citami_ui_overview_data();
$w = $d['widget'];
$a = isset($w['activity']) ? $w['activity'] : array('days' => 7, 'hits' => 0, 'pages' => array(), 'bots' => array());
$s = isset($w['summary']) ? $w['summary'] : array();
$mis = isset($s['misconceptions_open']) && $s['misconceptions_open'] !== null ? (int) $s['misconceptions_open'] : null;
$unread = isset($w['unread']) ? $w['unread'] : array('total' => 0, 'unread' => 0, 'sample' => array());
$hall = isset($w['hallucinated']) ? $w['hallucinated'] : array('count' => 0, 'list' => array());
$cl = $d['checklist'];
$spark = implode(',', array_map(function ($x) { return (int) $x['hits']; }, $d['daily']));
$spark_labels = implode(',', array_map(function ($x) { return $x['d']; }, $d['daily']));
$bot_label = function ($slug) { return function_exists('citami_widget_bot_label') ? citami_widget_bot_label($slug) : $slug; };
?>
0): ?>
AEO ·
$n): ?>
'assistant', 'days' => 14)) : citami_admin_url('bots');
echo wp_kses(sprintf(
/* translators: 1: URL of the filtered log, 2: number of reads */
citami_t('Di queste, %2$s sono letture per rispondere a qualcuno: un assistente AI ha aperto la pagina mentre scriveva una risposta.', 'Of these, %2$s are reads to answer someone: an AI assistant opened the page while writing an answer.'),
esc_url($live_url),
number_format_i18n((int) $d['live14']['total'])
), array('b' => array(), 'a' => array('href' => true)));
?>
array(
citami_t('Cosa fare su ogni pagina', 'What to do on each page'),
citami_t('Per ogni pagina il plugin incrocia Search Console, le risposte delle AI e le visite dei bot, e ti dice una cosa sola da fare.', 'For each page the plugin crosses Search Console, AI answers and bot visits, and tells you one thing to do.'),
array(
citami_t('Le pagine che Google mostra ma nessuno clicca, con titolo e descrizione già riscritti', 'Pages Google shows but nobody clicks, with title and description already rewritten'),
citami_t('Le domande a cui le AI rispondono senza citarti, e la risposta da aggiungere', 'Questions the AI answers without citing you, and the answer to add'),
citami_t('Le pagine che perdono clic, con la bozza di aggiornamento da approvare', 'Pages losing clicks, with an update draft to approve'),
),
),
'gsc' => array(
citami_t('Search Console dentro WordPress', 'Search Console inside WordPress'),
citami_t('Clic, impressioni e posizioni del sito, e tre liste di azioni pronte.', 'Clicks, impressions and positions of the site, and three lists of ready actions.'),
array(
citami_t('Title e description da riscrivere, con la bozza già scritta', 'Titles and descriptions to rewrite, with the draft already written'),
citami_t('Le domande cercate su Google da aggiungere come FAQ', 'Questions searched on Google to add as FAQ'),
citami_t('Le stesse domande tracciate nelle risposte di ChatGPT, Perplexity e Gemini', 'The same questions tracked in ChatGPT, Perplexity and Gemini answers'),
),
),
'articles' => array(
citami_t('Articoli pronti da pubblicare', 'Articles ready to publish'),
citami_t('Gli articoli scritti sulla piattaforma arrivano qui: un clic e sono in bozza su questo sito.', 'Articles written on the platform land here: one click and they are a draft on this site.'),
array(
citami_t('Scritti partendo dalle pagine che oggi vengono citate per quella domanda', 'Written from the pages that get cited for that question today'),
citami_t('Con immagini, descrizione, FAQ e dati strutturati', 'With images, description, FAQ and structured data'),
citami_t('Misurati dopo la pubblicazione: vedi se le AI iniziano a citarli', 'Measured after publishing: you see whether the AI starts citing them'),
),
),
'pending' => array(
citami_t('Correzioni dall\'audit', 'Fixes from the audit'),
citami_t('Dopo ogni audit la piattaforma prepara le correzioni per il sito; qui le approvi o le rifiuti.', 'After each audit the platform prepares fixes for the site; here you approve or reject them.'),
array(
citami_t('Dati strutturati, meta tag, llms.txt e robots.txt', 'Structured data, meta tags, llms.txt and robots.txt'),
citami_t('Niente cambia senza la tua approvazione, e tutto si può revocare', 'Nothing changes without your approval, and everything can be revoked'),
),
),
);
return isset($copy[$feature]) ? $copy[$feature] : null;
}
function citami_connect_card_html($feature) {
$c = citami_connect_card_copy($feature);
if ($c === null) {
return '';
}
$items = '';
foreach ($c[2] as $b) {
$items .= '
' . esc_html($b) . '
';
}
return ''
. '' . esc_html(sprintf(citami_t('Con un account %s', 'With a %s account'), citami_brand_name())) . ''
. '
' . esc_html(citami_t('Tutto il resto del plugin (schema, /ai-info, crawler AI, redirect, link interni, punteggio AEO) funziona anche senza account.', 'Everything else in the plugin (schema, /ai-info, AI crawlers, redirects, internal links, AEO score) works without an account too.')) . '