time(), 'ok' => false, 'message' => citami_t('API key mancante', 'API key missing')); update_option(CITAMI_HEALTH_OPT, $result, false); return $result; } // 2.75.0 — ?lang= so the plan's list comes back in the WordPress language. $response = wp_remote_get(citami_get_endpoint() . '/api/plugin/health?lang=' . (function_exists('citami_lang') ? citami_lang() : 'en'), array( 'timeout' => 10, 'headers' => array( 'Authorization' => 'Bearer ' . $key, 'User-Agent' => 'CitamiConnector/' . CITAMI_PLUGIN_VERSION . '; ' . home_url('/'), ), )); if (is_wp_error($response)) { $result = array('ts' => time(), 'ok' => false, 'message' => $response->get_error_message()); } else { $code = (int) wp_remote_retrieve_response_code($response); if ($code >= 200 && $code < 300) { $body = json_decode(wp_remote_retrieve_body($response), true); $plan = (is_array($body) && !empty($body['plan'])) ? (string) $body['plan'] : ''; $message = $plan === 'free' ? citami_t('OK — account gratuito', 'OK — free account') : ($plan !== '' ? sprintf(citami_t('OK — piano %s', 'OK — %s plan'), ucfirst($plan)) : 'OK'); $result = array('ts' => time(), 'ok' => true, 'message' => $message, 'usage' => citami_usage_from_body($body), 'tier' => citami_tier_from_body($body), 'plan' => function_exists('citami_plan_from_body') ? citami_plan_from_body($body) : null, 'nudge' => function_exists('citami_nudge_from_body') ? citami_nudge_from_body($body) : null); } else { $message = 'HTTP ' . $code; if ($code === 401 || $code === 403) { $message .= ' — ' . citami_t('API key non valida o ruotata', 'API key invalid or rotated'); } elseif ($code === 404) { $message .= ' — ' . citami_t('endpoint errato?', 'wrong endpoint?'); } $result = array('ts' => time(), 'ok' => false, 'message' => $message); } } update_option(CITAMI_HEALTH_OPT, $result, false); return $result; } /* ---------------------------------------------------------------- */ /* 2.47 — monthly quotas (alt text, AEO drafts) from the health ping */ /* ---------------------------------------------------------------- */ define('CITAMI_USAGE_NEAR_SHARE', 0.8); /** Sanitises the `usage` block of /api/plugin/health; null when absent. */ function citami_usage_from_body($body) { if (!is_array($body) || !isset($body['usage']) || !is_array($body['usage'])) { return null; } $out = array(); foreach (array('alt_text', 'aeo_draft') as $k) { $c = isset($body['usage'][$k]) && is_array($body['usage'][$k]) ? $body['usage'][$k] : null; if ($c === null || !isset($c['used'], $c['quota'])) { return null; } $out[$k] = array('used' => max(0, (int) $c['used']), 'quota' => max(0, (int) $c['quota'])); } $resets = isset($body['usage']['resets_at']) ? strtotime((string) $body['usage']['resets_at']) : false; $out['resets_at'] = $resets ? (int) $resets : (int) strtotime('first day of next month midnight'); return $out; } /** ok | near (80 %+) | exhausted. */ function citami_usage_level($used, $quota) { if ($quota <= 0 || $used >= $quota) return 'exhausted'; if ($used >= $quota * CITAMI_USAGE_NEAR_SHARE) return 'near'; return 'ok'; } /** The last usage the platform told us, or null (older platform, never pinged). */ function citami_usage() { // 2.56.1 — no key, no quotas: a removed key used to keep showing the last plan's counters. if (citami_get_api_key() === '') { return null; } $health = get_option(CITAMI_HEALTH_OPT, array()); if (!is_array($health) || empty($health['usage']) || !is_array($health['usage'])) { return null; } $u = $health['usage']; foreach (array('alt_text', 'aeo_draft') as $k) { if (!isset($u[$k]['used'], $u[$k]['quota'])) { return null; } $u[$k]['level'] = citami_usage_level((int) $u[$k]['used'], (int) $u[$k]['quota']); } return $u; } /** "12/300" for one counter, or '' when unknown. */ function citami_usage_fraction($kind) { $u = citami_usage(); return $u && isset($u[$kind]) ? sprintf('%d/%d', (int) $u[$kind]['used'], (int) $u[$kind]['quota']) : ''; } /** One line for Stato / Overview: "Alt text 12/300 · Bozze AI 4/150 · si azzerano il 1 ottobre". */ function citami_usage_summary() { $u = citami_usage(); if ($u === null) { return ''; } $labels = array('alt_text' => citami_t('Alt text', 'Alt texts'), 'aeo_draft' => citami_t('Bozze AI', 'AI drafts')); $parts = array(); foreach ($labels as $k => $label) { $c = $u[$k]; $suffix = $c['level'] === 'exhausted' ? ' (' . citami_t('finiti', 'used up') . ')' : ($c['level'] === 'near' ? ' (' . citami_t('quasi al limite', 'close to the limit') . ')' : ''); $parts[] = sprintf('%s %d/%d%s', $label, (int) $c['used'], (int) $c['quota'], $suffix); } // 2.56.1 — the platform resets at 00:00 UTC on the 1st: shown in UTC // (a site behind UTC read "30 September"), and a reset already past // (data from before the month turned) rolls to the next one. $reset = (int) $u['resets_at']; if ($reset <= time()) $reset = (int) strtotime(gmdate('Y-m-01 00:00:00', strtotime('first day of next month', time())) . ' UTC'); return implode(' · ', $parts) . ' · ' . sprintf(citami_t('si azzerano il %s', 'reset on %s'), wp_date('j F', $reset, new DateTimeZone('UTC'))); } /** Worst level across the counters: drives the colour. */ function citami_usage_worst_level() { $u = citami_usage(); if ($u === null) return 'ok'; $rank = array('ok' => 0, 'near' => 1, 'exhausted' => 2); $worst = 'ok'; foreach (array('alt_text', 'aeo_draft') as $k) { if ($rank[$u[$k]['level']] > $rank[$worst]) $worst = $u[$k]['level']; } return $worst; } /* ---------------------------------------------------------------- */ /* 2.46 — one connection state for the pill, the Overview and Stato */ /* ---------------------------------------------------------------- */ define('CITAMI_CRON_LAST_RUN_OPT', 'citami_cron_last_run'); /** WP-Cron silent for longer than this = "the dashboard will stop hearing from us". */ define('CITAMI_CRON_STALL_SECONDS', 2 * HOUR_IN_SECONDS); /** Called by every cron handler: proof that WP-Cron actually runs on this site. */ function citami_cron_heartbeat() { update_option(CITAMI_CRON_LAST_RUN_OPT, time(), false); } /** Seconds since WP-Cron last ran one of our jobs, or null when it never did. */ function citami_cron_silence() { $last = (int) get_option(CITAMI_CRON_LAST_RUN_OPT, 0); return $last > 0 ? max(0, time() - $last) : null; } /** * The one answer to "is this site connected?", with the reason and the * next step. The dashboard reads the health ping the cron sends every * 25 minutes; when WP-Cron does not run (no visits, DISABLE_WP_CRON * without a system cron) the plugin still says "connected" while the * dashboard stops hearing from it. That contradiction is a state here. * * none — no API key * pending — key set, first handshake not done yet (pinged now) * failed — last handshake failed (message says why) * cron — handshake fine, but WP-Cron silent for 2 h+ * ok — connected and talking */ function citami_connection_state() { $key = citami_get_api_key(); if ($key === '') { return array('state' => 'none', 'class' => 'warn', 'label' => citami_t('Non collegato', 'Not connected'), 'hint' => citami_t('Collega il sito: crea un account gratuito e incolla la API key (dashboard → Impostazioni → Plugin).', 'Connect the site: create a free account and paste the API key (dashboard → Settings → Plugin).'), 'href' => function_exists('citami_setup_url') ? citami_setup_url(1) : admin_url('admin.php?page=citami-connector&tab=general')); } $health = get_option(CITAMI_HEALTH_OPT, array()); if (!is_array($health) || empty($health['ts'])) { $health = citami_health_ping(); } $ts = isset($health['ts']) ? (int) $health['ts'] : 0; $fmt = (string) get_option('time_format'); $when = $ts > 0 ? wp_date($fmt !== '' ? $fmt : 'H:i', $ts) : ''; $general = function_exists('citami_admin_url') ? citami_admin_url('general') : admin_url('admin.php?page=citami-connector&tab=general'); if (empty($health['ok'])) { $msg = isset($health['message']) ? (string) $health['message'] : ''; return array('state' => 'failed', 'class' => 'bad', 'label' => citami_t('Collegamento non riuscito', 'Connection failed'), 'hint' => trim($msg . ' — ' . sprintf(citami_t('Controlla chiave ed endpoint in %s → Impostazioni, poi premi «Verifica connessione».', 'Check key and endpoint under %s → Settings, then press "Check connection".'), citami_brand_name()), ' —'), 'href' => $general); } $silence = citami_cron_silence(); $cron_off = defined('DISABLE_WP_CRON') && DISABLE_WP_CRON; if ($silence !== null ? $silence > CITAMI_CRON_STALL_SECONDS : ($ts > 0 && time() - $ts > CITAMI_CRON_STALL_SECONDS)) { $hours = $silence !== null ? (int) floor($silence / HOUR_IN_SECONDS) : (int) floor((time() - $ts) / HOUR_IN_SECONDS); return array('state' => 'cron', 'class' => 'warn', 'label' => citami_t('Collegato · cron fermo', 'Connected · cron stalled'), 'hint' => sprintf( $cron_off ? citami_t('DISABLE_WP_CRON è attivo e nessun cron di sistema ha chiamato wp-cron.php da %d ore: la dashboard smette di ricevere il segnale e segna il sito come non connesso. Aggiungi un cron di sistema che chiami wp-cron.php ogni 5 minuti.', 'DISABLE_WP_CRON is on and no system cron has called wp-cron.php for %d hours: the dashboard stops receiving the signal and marks the site as not connected. Add a system cron calling wp-cron.php every 5 minutes.') : citami_t('Il cron di WordPress non gira da %d ore (parte solo con le visite): la dashboard smette di ricevere il segnale e segna il sito come non connesso. Imposta un cron di sistema che chiami wp-cron.php ogni 5 minuti.', 'WordPress cron has not run for %d hours (it only fires on visits): the dashboard stops receiving the signal and marks the site as not connected. Set up a system cron calling wp-cron.php every 5 minutes.'), $hours), 'href' => $general); } return array('state' => 'ok', 'class' => 'ok', 'label' => citami_t('Collegato a Citami', 'Connected to Citami'), 'hint' => $when !== '' ? sprintf(citami_t('Ultimo controllo alle %s.', 'Last check at %s.'), $when) : '', 'href' => $general); } /** * Re-handshake immediato quando key o endpoint cambiano dal form * impostazioni. updated_option scatta solo a valore cambiato; * added_option copre il primo salvataggio. Il ping stesso scrive * CITAMI_HEALTH_OPT, che non è tra le opzioni osservate → nessuna * ricorsione. */ function citami_health_on_option_change($option) { if ($option === 'citami_api_key' || $option === 'citami_endpoint') { citami_health_ping(); } } add_action('updated_option', 'citami_health_on_option_change', 10, 1); add_action('added_option', 'citami_health_on_option_change', 10, 1); /** * Returns the Citami endpoint (default: https://citami.ai). */ /** * 2.56.1 — edit link that never comes back empty: get_edit_post_link() * returns null for a post the user cannot edit or that no longer exists, * which printed href="" (and a PHP 8.1 deprecation) in several lists. * Falls back to the public URL, then to ''. */ function citami_edit_link($post_id) { $post_id = is_object($post_id) && isset($post_id->ID) ? (int) $post_id->ID : (int) $post_id; $url = get_edit_post_link($post_id, 'raw'); if (!$url) $url = get_permalink($post_id); return $url ? (string) $url : ''; } /** * 2.56.1 — where a new user gets an account and the API key. The * register page sends them straight to Settings → Plugin afterwards. */ function citami_signup_url($campaign = 'setup') { $base = function_exists('citami_brand_site') ? citami_brand_site() : citami_default_endpoint(); return citami_lang_url($base . '/register?redirect=' . rawurlencode('/dashboard/settings/plugin') . '&utm_source=wordpress&utm_medium=plugin&utm_campaign=' . rawurlencode(sanitize_key($campaign))); } /** * 2.73.3 — UI-LANG: a link to the CiteProof dashboard carries the language * of this WordPress (?lang=it / de / en), so the dashboard opens in it; the * platform stores the choice in a cookie. The Citami build points at the * Italian sites and needs nothing. */ function citami_lang_url($url) { $url = (string) $url; if (!function_exists('citami_brand') || citami_brand() !== 'citeproof' || !function_exists('citami_lang')) return $url; return $url . (strpos($url, '?') === false ? '?' : '&') . 'lang=' . citami_lang(); } /** Key page in the dashboard, for users who already have an account. */ function citami_key_url() { $base = function_exists('citami_brand_site') ? citami_brand_site() : citami_default_endpoint(); return citami_lang_url($base . '/dashboard/settings/plugin'); } /** One line under every API key field: where to get one, with or without an account. */ function citami_get_key_html($campaign = 'setup') { // 2.74.0 — one click first (connect.php); the pasted key stays as the fallback. return (function_exists('citami_connect_button_html') ? citami_connect_button_html() : '') // 2.77.0 — QA: two purple buttons side by side ("Connect", "Create an account") read as two // different paths; the one-click button already creates the account. The key is the quiet fallback. . '
' . esc_html(citami_t('Hai già una chiave? La trovi in', 'Already have a key? It is in')) . ' ' . '' . esc_html(citami_t('Impostazioni → Plugin', 'Settings → Plugin')) . '' . ' (' . esc_html(citami_t('account non ancora creato?', 'no account yet?')) . ' ' . esc_html(citami_t('Crea un account gratuito', 'Create a free account')) . ').
'; } /** * 2.56.1 — country and phone prefix suggested in the forms, from the site * language (en_US → US / +1, de_DE → DE / +49). Placeholders only: nothing * is saved unless the user types it. */ function citami_locale_country() { $locale = function_exists('get_locale') ? (string) get_locale() : ''; if (preg_match('/^[a-z]{2,3}_([A-Z]{2})/', $locale, $m)) return $m[1]; $lang = strtolower(substr($locale, 0, 2)); $by_lang = array('it' => 'IT', 'de' => 'DE', 'fr' => 'FR', 'es' => 'ES', 'nl' => 'NL', 'pt' => 'PT'); return isset($by_lang[$lang]) ? $by_lang[$lang] : 'US'; } function citami_locale_phone_hint() { $prefix = array('IT' => '+39', 'US' => '+1', 'CA' => '+1', 'GB' => '+44', 'IE' => '+353', 'AU' => '+61', 'NZ' => '+64', 'DE' => '+49', 'AT' => '+43', 'CH' => '+41', 'FR' => '+33', 'ES' => '+34', 'NL' => '+31', 'BE' => '+32', 'PT' => '+351'); $c = citami_locale_country(); return (isset($prefix[$c]) ? $prefix[$c] : '+') . ' …'; } /** 2.56.1 — a post type as WordPress names it in the site language ("Pagine"), not its slug. */ function citami_post_type_label($type) { $obj = function_exists('get_post_type_object') ? get_post_type_object((string) $type) : null; if ($obj && isset($obj->labels->name) && $obj->labels->name !== '') return (string) $obj->labels->name; return ucfirst((string) $type); } /** Schema type choice as shown in a select: "None" reads as a word, the rest are schema.org names. */ function citami_schema_choice_label($c) { return $c === 'None' ? citami_t('Nessuno', 'None') : (string) $c; } /** * 2.57 — FREE-TIER: the account behind the key, as the platform says it * ("free" = signed up, nothing bought; "paid" = any plan, agent or trial). * '' when the platform is older and does not say: treated as paid, no * upgrade prompts. */ function citami_tier_from_body($body) { $t = is_array($body) && isset($body['tier']) ? (string) $body['tier'] : ''; return in_array($t, array('free', 'paid'), true) ? $t : ''; } /** none (no key) | free | paid. */ function citami_account_tier() { if (citami_get_api_key() === '') return 'none'; $health = get_option(CITAMI_HEALTH_OPT, array()); return is_array($health) && isset($health['tier']) && $health['tier'] === 'free' ? 'free' : 'paid'; } function citami_is_free_account() { return citami_account_tier() === 'free'; } /** Plans page in the dashboard (billing), tagged with where the click came from. */ function citami_upgrade_url($campaign = 'plugin') { $base = function_exists('citami_brand_site') ? citami_brand_site() : citami_default_endpoint(); return citami_lang_url($base . '/dashboard/settings/billing?utm_source=wordpress&utm_medium=plugin&utm_campaign=' . rawurlencode(sanitize_key($campaign))); } /** * What a paid plan adds for one counter, in one line — shown next to a * free account's quota when it runs low or out. The paid numbers are the * platform's (lib/plugin/entitlements.ts PAID_PLUGIN_QUOTAS). */ function citami_upgrade_line($kind) { $paid = array('aeo_draft' => 150, 'alt_text' => 300, 'page_ai' => 200); $n = isset($paid[$kind]) ? $paid[$kind] : 0; $what = array( 'aeo_draft' => citami_t('%d bozze AI al mese', '%d AI drafts a month'), 'alt_text' => citami_t('%d descrizioni di immagini al mese', '%d image descriptions a month'), 'page_ai' => citami_t('%d riscritture AI al mese', '%d AI rewrites a month'), ); if ($kind === 'questions') { return citami_t('Con un piano: più domande tracciate, audit su 5 motori AI e correzioni automatiche.', 'With a plan: more tracked questions, audits on 5 AI engines and automatic fixes.'); } if ($kind === 'fixes') { return citami_t('Con un piano la piattaforma prepara dopo ogni audit le correzioni per il sito (dati strutturati, meta tag, llms.txt, robots.txt): le approvi qui.', 'With a plan the platform prepares fixes for the site after every audit (structured data, meta tags, llms.txt, robots.txt): you approve them here.'); } if (!$n || !isset($what[$kind])) return ''; return sprintf(citami_t('Con un piano: %s, audit su 5 motori AI e correzioni automatiche.', 'With a plan: %s, audits on 5 AI engines and automatic fixes.'), sprintf($what[$kind], $n)); } /** The upgrade link as HTML (a free account only; '' otherwise). */ function citami_upgrade_link_html($campaign, $label = '') { if (!citami_is_free_account()) return ''; return '' . esc_html($label !== '' ? $label : citami_t('Passa a un piano', 'See the plans')) . ''; } /** A notice array as HTML: message, then the upgrade link when it carries one. */ function citami_notice_text_html(array $notice) { $html = esc_html(isset($notice['message']) ? (string) $notice['message'] : ''); if (!empty($notice['upgrade']) && citami_is_free_account()) { $line = citami_upgrade_line((string) $notice['upgrade']); $html .= ($line !== '' ? ' ' . esc_html($line) : '') . ' ' . citami_upgrade_link_html('quota_' . sanitize_key((string) $notice['upgrade'])); } return $html; } function citami_get_endpoint() { $endpoint = get_option('citami_endpoint', citami_default_endpoint()); return rtrim(is_string($endpoint) && $endpoint ? $endpoint : citami_default_endpoint(), '/'); } /** * Returns true when "review-only" mode is enabled (default: ON). * * In review-only mode, fixes are still queued in citami_fix_queue (so the * admin has a complete audit trail) but NOT auto-applied to wp_head / * llms.txt / robots.txt. This is the secure default — the admin opts * in to auto-application by toggling the setting off. */ function citami_is_review_only() { $val = get_option(CITAMI_REVIEW_ONLY_OPT, '1'); return $val === '1' || $val === 1 || $val === true; } /** * Decides whether a single inbound apply-fix call should bypass * review-only mode. Two signals, header takes precedence: * * X-Citami-Auto-Apply: 1 (per-call, server-controlled) * CITAMI_PRELOADED_AUTO_APPLY_MODE === 'enabled' (install-time) * * The header path is what makes auto-apply work on a v2.1.x install * that the customer hasn't re-downloaded yet — Citami's auto-push * cron stamps it on every queued push when the user has opted in. * The constant path is the belt-and-braces for cases where the * customer disables the WP-side review_only setting AND wants the * server to know about it. * * Both signals only OPT INTO auto-apply; nothing here can turn it * off if review_only is already false (the existing default-OFF * customers keep applying immediately as before). */ function citami_should_force_apply(WP_REST_Request $request) { $header = $request->get_header('x-citami-auto-apply'); if ($header === '1') { return true; } if (defined('CITAMI_PRELOADED_AUTO_APPLY_MODE')) { $mode = CITAMI_PRELOADED_AUTO_APPLY_MODE; if (is_string($mode) && $mode === 'enabled') { return true; } } return false; } /** * 2.66 — "Pagine da escludere" (dashboard → applicazione automatica): on * these paths nothing from the platform's fixes is printed (schema and * meta blocks, the Yoast/Rank Math merge) and the FAQ page is not * created or rewritten. The list arrives with the sync pull. Same matcher * as lib/auto-apply/exclude-paths.ts: * "/checkout" → that page only; "/checkout/*" → it and everything below. */ define('CITAMI_EXCLUDE_PATHS_OPT', 'citami_exclude_paths'); function citami_excluded_paths() { $list = get_option(CITAMI_EXCLUDE_PATHS_OPT, array()); return is_array($list) ? array_values(array_filter($list, 'is_string')) : array(); } function citami_path_is_excluded($path, $patterns = null) { $patterns = is_array($patterns) ? $patterns : citami_excluded_paths(); if (empty($patterns)) return false; $p = (string) wp_parse_url((string) $path, PHP_URL_PATH); $p = rawurldecode($p === '' ? '/' : $p); foreach ($patterns as $pattern) { $pattern = trim((string) $pattern); if ($pattern === '') continue; $parts = explode('*', rtrim($pattern, '/')); $body = implode('.*', array_map(function ($x) { return preg_quote($x, '#'); }, $parts)); $body = preg_replace('#/\.\*$#', '(?:/.*)?', $body); if (preg_match('#^' . $body . '/?$#i', $p)) return true; } return false; } /** The current front-end request is on an excluded page. */ function citami_fix_output_excluded() { if (is_admin() || !isset($_SERVER['REQUEST_URI'])) return false; return citami_path_is_excluded(sanitize_text_field(wp_unslash((string) $_SERVER['REQUEST_URI']))); } /** * 2.65 — mirror of lib/audit/schema-completeness.ts: what is missing for * this JSON-LD to go live (placeholders left by the generator, required * properties per @type). Empty array = complete. Takes the JSON string * returned by citami_validate_jsonld(). */ function citami_schema_missing($safe_json) { $data = json_decode((string) $safe_json, true); if (!is_array($data)) return array('JSON-LD'); $missing = array(); citami_schema_scan_placeholders($data, '', $missing); if (isset($data['@graph']) && is_array($data['@graph'])) { $nodes = $data['@graph']; } elseif (isset($data[0])) { $nodes = $data; } else { $nodes = array($data); } foreach ($nodes as $node) { if (is_array($node)) citami_schema_check_node($node, $missing); } return array_values(array_unique($missing)); } function citami_schema_placeholder_re() { $w = 'nome|telefono|indirizzo|prezzo|email|e-mail|cap|logo|url|sito|azienda|ragione sociale|partita iva|orari|via|numero|your|name|phone|address|price|city|zip|postal|company|website|insert|inserisci|inserire'; return '/DA[_ ]?COMPILARE|DA[_ ]?INSERIRE|TO[_ ]?BE[_ ]?FILLED|\bTODO\b|\bTBD\b|\bXXX+\b|\blorem ipsum\b|\bplaceholder\b|\{\{[^}]*\}\}|\[[^\]]*\b(?:' . $w . ')\b[^\]]*\]|<[^<>]*\b(?:' . $w . ')\b[^<>]*>|^\[[^\]]+\]$|\bexample\.(?:com|org|net|it)\b|\b(?:your|tuo|tua|vostro|vostra) (?:company|phone|address|name|email|business|azienda|telefono|indirizzo|nome|logo|sito)\b|\b0{6,}\b|\b(?:123[- ]?456[- ]?789|555[- ]?0\d{3})\b/iu'; } function citami_schema_scan_placeholders($v, $path, array &$out) { if (is_string($v)) { $t = trim($v); if ($t === '') { $out[] = 'empty: ' . ($path === '' ? 'value' : $path); } elseif (preg_match(citami_schema_placeholder_re(), $t, $m)) { $out[] = 'placeholder: ' . $m[0]; } return; } if (is_array($v)) { foreach ($v as $k => $x) { if ($k === '@context') continue; citami_schema_scan_placeholders($x, $path === '' ? (string) $k : $path . '.' . $k, $out); } } } function citami_schema_filled($v) { if ($v === null) return false; if (is_string($v)) return trim($v) !== ''; if (is_int($v) || is_float($v)) return true; if (is_array($v)) { foreach ($v as $k => $x) { if ($k === '@id' && is_string($x)) return true; if (is_string($k) && strpos($k, '@') === 0) continue; if (citami_schema_filled($x)) return true; } return false; } return (bool) $v; } function citami_schema_first($v) { return (is_array($v) && isset($v[0])) ? $v[0] : $v; } function citami_schema_is_local_business($type) { static $set = null; if ($set === null) { $set = array_flip(array_map('strtolower', array( 'LocalBusiness', 'Store', 'OnlineStore', 'Restaurant', 'FoodEstablishment', 'Bakery', 'BarOrPub', 'CafeOrCoffeeShop', 'IceCreamShop', 'Winery', 'Brewery', 'FastFoodRestaurant', 'ProfessionalService', 'LegalService', 'Attorney', 'Notary', 'AccountingService', 'FinancialService', 'InsuranceAgency', 'RealEstateAgent', 'TravelAgency', 'EmploymentAgency', 'BeautySalon', 'HairSalon', 'NailSalon', 'DaySpa', 'HealthAndBeautyBusiness', 'TattooParlor', 'Dentist', 'Physician', 'MedicalClinic', 'Hospital', 'Pharmacy', 'Optician', 'MedicalBusiness', 'VeterinaryCare', 'Hotel', 'Motel', 'Resort', 'BedAndBreakfast', 'Hostel', 'LodgingBusiness', 'Campground', 'AutoRepair', 'AutoDealer', 'AutomotiveBusiness', 'GasStation', 'HomeAndConstructionBusiness', 'Electrician', 'Plumber', 'HVACBusiness', 'RoofingContractor', 'GeneralContractor', 'HousePainter', 'Locksmith', 'MovingCompany', 'SportsActivityLocation', 'ExerciseGym', 'HealthClub', 'SportsClub', 'ChildCare', 'DryCleaningOrLaundry', 'EntertainmentBusiness', 'LibrarySystem', 'ShoppingCenter', 'ClothingStore', 'ElectronicsStore', 'FurnitureStore', 'GroceryStore', 'HardwareStore', 'JewelryStore', 'BookStore', 'Florist', 'PetStore', 'SportingGoodsStore', 'ToyStore', 'TireShop', 'MobilePhoneStore', 'OfficeEquipmentStore', 'WholesaleStore', 'SelfStorage', 'RecyclingCenter', 'RadioStation', 'TelevisionStation', 'TouristInformationCenter' ))); } $t = strtolower(trim((string) $type)); if ($t === '') return false; return strpos($t, 'business') !== false || isset($set[$t]); } function citami_schema_check_node(array $node, array &$out) { $types = array(); if (isset($node['@type'])) { $types = is_array($node['@type']) ? array_values(array_filter($node['@type'], 'is_string')) : array((string) $node['@type']); } $label = isset($types[0]) ? $types[0] : 'Thing'; $lower = array_map('strtolower', $types); $need = function ($prop) use ($node, $label, &$out) { if (!isset($node[$prop]) || !citami_schema_filled($node[$prop])) $out[] = $label . '.' . $prop; }; $local = false; $identity = false; foreach ($types as $t) { if (citami_schema_is_local_business($t)) $local = true; if (stripos($t, 'organization') !== false || in_array(strtolower($t), array('corporation', 'ngo'), true)) $identity = true; } if ($local) { $need('name'); $need('telephone'); $addr = isset($node['address']) ? citami_schema_first($node['address']) : null; if (!is_array($addr)) { $out[] = $label . '.address'; } else { foreach (array('streetAddress', 'addressLocality', 'postalCode', 'addressCountry') as $k) { if (!isset($addr[$k]) || !citami_schema_filled($addr[$k])) $out[] = $label . '.address.' . $k; } } } elseif ($identity) { $need('name'); $need('url'); } if (in_array('faqpage', $lower, true)) { $items = isset($node['mainEntity']) ? $node['mainEntity'] : array(); if (is_array($items) && !isset($items[0]) && !empty($items)) $items = array($items); if (!is_array($items) || empty($items)) $out[] = 'FAQPage.mainEntity'; else { foreach (array_values($items) as $i => $q) { if (!is_array($q) || !isset($q['name']) || !citami_schema_filled($q['name'])) $out[] = 'FAQPage.mainEntity[' . $i . '].name'; $ans = (is_array($q) && isset($q['acceptedAnswer'])) ? citami_schema_first($q['acceptedAnswer']) : null; if (!is_array($ans) || !isset($ans['text']) || !citami_schema_filled($ans['text'])) $out[] = 'FAQPage.mainEntity[' . $i . '].acceptedAnswer.text'; } } } if (in_array('product', $lower, true)) { $need('name'); $offer = isset($node['offers']) ? citami_schema_first($node['offers']) : null; if (!is_array($offer)) $out[] = 'Product.offers'; else { $has_price = (isset($offer['price']) && citami_schema_filled($offer['price'])) || (isset($offer['lowPrice']) && citami_schema_filled($offer['lowPrice'])); if (!$has_price) $out[] = 'Product.offers.price'; if (!isset($offer['priceCurrency']) || !citami_schema_filled($offer['priceCurrency'])) $out[] = 'Product.offers.priceCurrency'; } } if (array_intersect(array('article', 'blogposting', 'newsarticle'), $lower)) { $need('headline'); $need('author'); } if (in_array('person', $lower, true)) $need('name'); if (in_array('website', $lower, true)) { $need('name'); $need('url'); } if (in_array('event', $lower, true)) { $need('name'); $need('startDate'); $need('location'); } if (in_array('breadcrumblist', $lower, true)) { $items = isset($node['itemListElement']) && is_array($node['itemListElement']) ? $node['itemListElement'] : array(); if (empty($items)) $out[] = 'BreadcrumbList.itemListElement'; foreach (array_values($items) as $i => $it) { $name_ok = is_array($it) && ((isset($it['name']) && citami_schema_filled($it['name'])) || (isset($it['item']['name']) && citami_schema_filled($it['item']['name']))); if (!$name_ok) $out[] = 'BreadcrumbList.itemListElement[' . $i . '].name'; } } } /** * Per-site rate limit on inbound fix applications. Prevents a compromised * Citami backend (or a bug in the fix-generator) from flooding wp_head * with hundreds of injections in seconds. * * Returns true if the call may proceed, false if rate-limited. */ function citami_check_apply_rate() { $last = (int) get_option(CITAMI_FIX_APPLY_LAST_OPT, 0); $now = time(); if ($last > 0 && ($now - $last) < CITAMI_FIX_APPLY_INTERVAL) { return false; } update_option(CITAMI_FIX_APPLY_LAST_OPT, $now, false); return true; } /** * Checks the X-Citami-Key header against the stored key. * Used to authenticate inbound calls from Citami (fix application). */ function citami_check_permission(WP_REST_Request $request) { $expected = citami_get_api_key(); if (empty($expected)) { return new WP_Error( 'citami_not_configured', citami_brand_text('Citami API key non configurata.'), array('status' => 401) ); } $provided = $request->get_header('x-citami-key'); if (empty($provided) || !hash_equals($expected, $provided)) { return new WP_Error( 'citami_bad_key', 'API key non valida.', array('status' => 401) ); } return true; } /** * v2.4.0 — PAGE-TARGETED SCHEMA. Pre-v2.4 ogni JSON-LD veniva stampato * su TUTTE le pagine (wp_head senza guardia): Organization duplicato * ovunque, FAQPage sulla pagina sbagliata. Ora ogni blocco ha un * target derivato dal @type e viene stampato solo dove serve: * - AboutPage → pagina chi-siamo/about * - FAQPage → pagina FAQ * - tutto il resto → homepage * I payload con @graph vengono spacchettati: un unico fix puo' * consegnare Organization (home) + AboutPage (about) in un colpo solo. */ function citami_schema_target($decoded) { $type = isset($decoded['@type']) ? $decoded['@type'] : ''; if (is_array($type)) { $type = implode(' ', array_filter($type, 'is_string')); } if (!is_string($type)) $type = ''; if (stripos($type, 'AboutPage') !== false) return 'about'; if (stripos($type, 'FAQPage') !== false) return 'faq'; return 'home'; } /** Default hardcoded (pre-v2.11 behaviour) — sempre attivi. */ function citami_default_about_slugs() { return array( 'about', 'about-us', 'aboutus', 'about-me', 'chi-siamo', 'chi_siamo', 'chisiamo', 'la-nostra-storia', 'azienda', 'storia', 'il-nostro-team', 'our-story', 'who-we-are', 'company', // 2.73 — German sites. 'ueber-uns', 'uber-uns', 'ueberuns', 'unternehmen', 'wir-ueber-uns', 'team', 'unsere-geschichte', ); } function citami_default_faq_slugs() { return array( 'faq', 'faqs', 'domande-frequenti', 'domande', 'frequently-asked-questions', 'help', 'aiuto', // 2.73 — German sites. 'haeufige-fragen', 'haufige-fragen', 'fragen-und-antworten', 'hilfe', ); } /** * v2.11.0 — parse di una lista comma-separated di slug: trim, * lowercase, sanitize_title su ogni voce, vuoti scartati, dedup. */ function citami_parse_slug_list($value) { $out = array(); foreach (explode(',', (string) $value) as $part) { $slug = sanitize_title(strtolower(trim($part))); if ($slug === '') continue; $out[] = $slug; } return array_values(array_unique($out)); } /** Sanitize-callback delle opzioni slug: normalizza e ricompone. */ function citami_sanitize_slug_list($value) { return implode(', ', citami_parse_slug_list($value)); } /** * v2.11.1 — matcher titolo→keyword per l'auto-rilevamento slug. * Pre-2.11.1 bastava un substring match ovunque nel titolo: "What * clients say about us" (testimonial) diventava una pagina About e si * beccava lo schema AboutPage. Regole strette: * - match se il titolo normalizzato INIZIA con la keyword, oppure * - titolo corto (≤ 40 caratteri) che contiene la keyword come * PAROLA INTERA — ma solo per keyword "specifiche" ($contains): * 'about' da solo resta prefix-only, è troppo comune a metà frase * inglese (say about, more about, ...). */ function citami_title_matches_keyword($title, $keyword, $contains) { if (strpos($title, $keyword) === 0) { return true; } if (!$contains) { return false; } $len = function_exists('mb_strlen') ? mb_strlen($title) : strlen($title); if ($len > 40) { return false; } // Parola intera: la keyword non deve essere incollata a lettere/cifre. $re = '/(? array(), 'faq' => array()); // Keyword → regole: 'contains' = ammesso il match parola-intera su // titoli corti; false = solo prefisso. $about_keywords = array( 'chi siamo' => true, 'about' => false, ); $faq_keywords = array( 'faq' => true, 'domande frequenti' => true, ); $pages = get_pages(array('number' => 100)); // solo pubblicate (default) if (is_array($pages)) { foreach ($pages as $pg) { $title = strtolower(wp_strip_all_tags((string) $pg->post_title)); $slug = sanitize_title((string) $pg->post_name); if ($slug === '' || $title === '') continue; foreach ($about_keywords as $kw => $contains) { if (citami_title_matches_keyword($title, $kw, $contains)) { $detected['about'][] = $slug; break; } } foreach ($faq_keywords as $kw => $contains) { if (citami_title_matches_keyword($title, $kw, $contains)) { $detected['faq'][] = $slug; break; } } } } // Lookup diretto per gli slug default: get_page_by_path è una query // puntuale, indipendente dal numero di pagine del sito. foreach (citami_default_about_slugs() as $slug) { $pg = get_page_by_path($slug); if ($pg && isset($pg->post_status) && $pg->post_status === 'publish') { $detected['about'][] = sanitize_title((string) $pg->post_name); } } foreach (citami_default_faq_slugs() as $slug) { $pg = get_page_by_path($slug); if ($pg && isset($pg->post_status) && $pg->post_status === 'publish') { $detected['faq'][] = sanitize_title((string) $pg->post_name); } } $detected['about'] = array_values(array_unique($detected['about'])); $detected['faq'] = array_values(array_unique($detected['faq'])); set_transient(CITAMI_DETECTED_SLUGS_TRANSIENT, $detected, HOUR_IN_SECONDS); return $detected; } /** * Set completo degli slug about: default hardcoded ∪ opzione admin ∪ * auto-rilevati. Le opzioni AGGIUNGONO, mai sostituiscono: con opzioni * vuote il comportamento è identico a pre-v2.11. */ function citami_about_page_slugs() { $detected = citami_detect_page_slugs(); return array_values(array_unique(array_merge( citami_default_about_slugs(), citami_parse_slug_list(get_option(CITAMI_ABOUT_SLUGS_OPT, '')), $detected['about'] ))); } function citami_faq_page_slugs() { $detected = citami_detect_page_slugs(); return array_values(array_unique(array_merge( citami_default_faq_slugs(), citami_parse_slug_list(get_option(CITAMI_FAQ_SLUGS_OPT, '')), $detected['faq'] ))); } function citami_is_about_page() { if (!is_page()) return false; $post = get_post(); if (!$post) return false; $slug = (string) $post->post_name; return in_array($slug, citami_about_page_slugs(), true); } function citami_is_faq_page() { if (!is_page()) return false; $post = get_post(); if (!$post) return false; $slug = (string) $post->post_name; return in_array($slug, citami_faq_page_slugs(), true); } /** * Targets ammessi sulla request corrente. Stringa vuota '' = blocchi * legacy pre-v2.4 senza campo target: vengono trattati come 'home' * (corregge la stampa site-wide indiscriminata delle versioni vecchie). */ function citami_current_schema_targets() { if (is_front_page() || is_home()) return array('home', ''); if (citami_is_about_page()) return array('about'); if (citami_is_faq_page()) return array('faq'); return array(); } /** * Validates that $content is a JSON-LD payload with a Schema.org @context. * Returns the safely-re-encoded JSON string on success, or false on * failure. Re-encoding strips any unsafe characters that survived a naive * concatenation upstream and makes the output deterministic. */ function citami_validate_jsonld($content) { $content = trim((string) $content); if ($content === '') return false; // If a caller wraps the JSON in #is', $content, $m) || empty($m[1])) return false; if (count($m[1]) === 1) { $content = trim($m[1][0]); } else { $nodes = array(); foreach ($m[1] as $body) { $one = json_decode(trim($body), true); if (!is_array($one)) return false; if (isset($one['@graph']) && is_array($one['@graph'])) { foreach ($one['@graph'] as $n) $nodes[] = $n; } elseif (isset($one[0])) { foreach ($one as $n) $nodes[] = $n; } else { unset($one['@context']); $nodes[] = $one; } } $content = wp_json_encode(array('@context' => 'https://schema.org', '@graph' => $nodes)); if (!is_string($content)) return false; } } $decoded = json_decode($content, true); if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) { return false; } if (!is_array($decoded)) return false; // @context check — Schema.org markup must declare it. Accept either a // string ("https://schema.org") or an array of contexts. $ctx = isset($decoded['@context']) ? $decoded['@context'] : null; $has_schema_org = false; if (is_string($ctx) && stripos($ctx, 'schema.org') !== false) { $has_schema_org = true; } elseif (is_array($ctx)) { foreach ($ctx as $c) { if (is_string($c) && stripos($c, 'schema.org') !== false) { $has_schema_org = true; break; } } } if (!$has_schema_org) return false; // v2.11.1 — JSON_HEX_TAG: '<' e '>' diventano >, così un // testo libero del cliente che contiene "" (descrizione // offerta, nome, ...) non può chiudere il tag