'', 'funnel' => '', 'package' => '', 'cart' => '', 'card' => '', 'chart' => '', 'list-checks' => '', 'bell' => '', 'sliders' => '', 'help' => '', 'calendar' => '', 'sparkles' => '', 'trend-up' => '', 'trend-down' => '', 'minus' => '', 'chevron-down' => '', 'chevron-right' => '', 'arrow-left' => '', 'check' => '', 'x' => '', 'info' => '', 'alert' => '', 'shield' => '', 'mail' => '', 'link' => '', 'download' => '', 'external' => '', 'eye' => '', 'user' => '', 'monitor' => '', 'phone' => '', 'clock' => '', 'refresh' => '', 'copy' => '', 'globe' => '', 'message' => '', 'pencil' => '', 'lock' => '', 'zap' => '', 'columns' => '', 'file' => '', 'search' => '', 'undo' => '', 'gauge' => '', 'store' => '', ]; /** * Inline SVG icon. * * @param string $name Key of self::ICONS. * @param string $class Extra CSS classes. * @return string Safe HTML. */ public static function icon($name, $class = '') { $body = self::ICONS[$name] ?? self::ICONS['info']; return ''; } /** Icon names for JavaScript-built markup. */ public static function icon_map() { return array_map(static function ($name) { return self::icon($name); }, array_combine(array_keys(self::ICONS), array_keys(self::ICONS))); } // ------------------------------------------------------------------ // Report period // ------------------------------------------------------------------ /** * The report period chosen in the date toolbar. * Query arguments: range=7|30|90|custom, start_date, end_date (Y-m-d). * * @param int $default_days * @return array{start:string,end:string,range:string,days:int,label:string} */ public static function period($default_days = 30) { $today = wp_date('Y-m-d'); // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only report filters. $range = isset($_GET['range']) ? sanitize_key(wp_unslash($_GET['range'])) : ''; $start = isset($_GET['start_date']) ? sanitize_text_field(wp_unslash($_GET['start_date'])) : ''; $end = isset($_GET['end_date']) ? sanitize_text_field(wp_unslash($_GET['end_date'])) : ''; $legacy_days = isset($_GET['days']) ? absint($_GET['days']) : 0; // phpcs:enable WordPress.Security.NonceVerification.Recommended $valid = static function ($d) { return (bool) preg_match('/^\d{4}-\d{2}-\d{2}$/', $d) && strtotime($d) !== false; }; if ($range === '' && $legacy_days && in_array($legacy_days, [7, 30, 90], true)) { $range = (string) $legacy_days; } if ($range === '' && $valid($start) && $valid($end)) { $range = 'custom'; } if ($range === 'custom' && $valid($start) && $valid($end)) { if ($start > $end) { [$start, $end] = [$end, $start]; } $days = (int) round((strtotime($end) - strtotime($start)) / DAY_IN_SECONDS) + 1; return [ 'start' => $start, 'end' => $end, 'range' => 'custom', 'days' => $days, 'label' => wp_date(get_option('date_format'), strtotime($start)) . ' – ' . wp_date(get_option('date_format'), strtotime($end)), ]; } $days = in_array((int) $range, [7, 30, 90], true) ? (int) $range : (int) $default_days; return [ 'start' => wp_date('Y-m-d', strtotime($today . ' -' . ($days - 1) . ' days')), 'end' => $today, 'range' => (string) $days, 'days' => $days, /* translators: %d: number of days */ 'label' => sprintf(_n('Last %d day', 'Last %d days', $days, 'ai-flash-tune'), $days), ]; } /** * Date toolbar: 7 / 30 / 90 days, or a custom range. * * @param array $period self::period(). * @param array $keep Query arguments to keep (page, tab, device, ...). * @param string $right_html Controls shown on the right (safe HTML). */ public static function period_toolbar($period, $keep, $right_html = '') { $base = add_query_arg($keep, admin_url('admin.php')); echo '
'; echo '
'; echo ''; foreach ([7, 30, 90] as $days) { $active = $period['range'] === (string) $days; printf( '%4$s', esc_url(add_query_arg('range', $days, $base)), $active ? 'is-active' : '', $active ? ' aria-current="true"' : '', /* translators: %d: number of days, shown as a short period label like "30 days" */ esc_html(sprintf(__('%d days', 'ai-flash-tune'), $days)) ); } echo ''; echo '
'; echo '' . self::icon('calendar') . '' . esc_html($period['range'] === 'custom' ? $period['label'] : __('Custom', 'ai-flash-tune')) . '' . self::icon('chevron-down') . ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Fixed SVG icons. echo '
'; foreach ($keep as $key => $value) { echo ''; } echo ''; echo ''; echo ''; echo ''; echo '
'; echo '' . esc_html(self::short_range($period['start'], $period['end'])) . ''; echo '
'; if ($right_html !== '') { echo '
' . $right_html . '
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Built by callers from escaped parts. } echo '
'; } /** "Aug 27 – Sep 25, 2026" (the year only once when both dates share it). */ public static function short_range($start, $end) { $s = strtotime($start); $e = strtotime($end); /* translators: short date without year, see https://www.php.net/manual/datetime.format.php */ $no_year = __('M j', 'ai-flash-tune'); /* translators: short date with year, see https://www.php.net/manual/datetime.format.php */ $with_year = __('M j, Y', 'ai-flash-tune'); return (wp_date('Y', $s) === wp_date('Y', $e) ? wp_date($no_year, $s) : wp_date($with_year, $s)) . ' – ' . wp_date($with_year, $e); } /** * Segmented links, e.g. All / Desktop / Mobile. * * @param array $options value => label * @param string $current * @param string $param Query argument. * @param array $keep Query arguments to keep. * @param string $label Accessible group name. * @return string Safe HTML. */ public static function segmented($options, $current, $param, $keep, $label) { $base = add_query_arg($keep, admin_url('admin.php')); $html = ''; foreach ($options as $value => $text) { $active = (string) $value === (string) $current; $html .= '' . esc_html($text) . ''; } return $html . ''; } // ------------------------------------------------------------------ // Small parts // ------------------------------------------------------------------ /** * Change against the previous period, as a small coloured note. * * @param float|null $change Percent change (or null when there is nothing to compare). * @param bool $up_good Whether an increase is good news. * @return string Safe HTML. */ public static function delta($change, $up_good = true) { if ($change === null) { return '' . self::icon('minus') . esc_html__('no earlier data', 'ai-flash-tune') . ''; } $change = round((float) $change, 1); if (abs($change) < 0.05) { return '' . self::icon('minus') . esc_html__('no change', 'ai-flash-tune') . ''; } $good = ($change > 0) === $up_good; return '' . self::icon($change > 0 ? 'trend-up' : 'trend-down') /* translators: %s: percentage change, e.g. "+12%" */ . esc_html(sprintf(__('%s vs previous period', 'ai-flash-tune'), ($change > 0 ? '+' : '') . number_format_i18n($change, abs($change) < 10 ? 1 : 0) . '%')) . ''; } /** * One stat tile. * * @param string $label * @param string $value_html Safe HTML (numbers are escaped by the caller; prices come from wc_price()). * @param string $note_html Safe HTML. * @param string $tone '', 'leak' or 'good'. Money left in carts ('leak') is a * number to act on, not an error, so only 'good' colours * the figure; red is kept for things that are broken. */ public static function stat($label, $value_html, $note_html = '', $tone = '') { $tone = $tone === 'good' ? $tone : ''; echo '
'; echo '' . esc_html($label) . ''; echo '' . wp_kses_post($value_html) . ''; if ($note_html !== '') { echo '' . wp_kses($note_html, self::allowed_svg_html()) . ''; } echo '
'; } /** * Status badge. * * @param string $text * @param string $tone neutral|good|warn|leak|accent * @return string Safe HTML. */ public static function badge($text, $tone = 'neutral') { return '' . esc_html($text) . ''; } /** * Empty state inside a panel. * * @param string $icon * @param string $title * @param string $text * @param string $action_html Safe HTML. */ public static function empty_state($icon, $title, $text, $action_html = '') { echo '
'; echo '' . self::icon($icon) . ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Fixed SVG. echo '

' . esc_html($title) . '

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

' . esc_html($text) . '

'; } if ($action_html !== '') { echo '
' . $action_html . '
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Built by callers from escaped parts. } echo '
'; } /** * Funnel chart: one row per step, bar width relative to the first step, * with the share kept from the previous step. * * @param array $steps list of [label, count, icon, link?] */ public static function funnel($steps) { $first = max(1, (int) ($steps[0][1] ?? 0)); echo '
    '; $previous = null; foreach ($steps as $i => $step) { [$label, $count] = $step; $count = (int) $count; $icon = $step[2] ?? 'chevron-right'; $link = $step[3] ?? ''; $width = max(0.5, min(100, $count / $first * 100)); $kept = ($previous !== null && $previous > 0) ? $count / $previous * 100 : null; $is_last = $i === count($steps) - 1; echo '
  1. '; echo '' . self::icon($icon) . ($link !== '' ? '' . esc_html($label) . '' : esc_html($label)) . ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Fixed SVG. echo ''; echo '' . esc_html(number_format_i18n($count)) . ''; if ($kept === null) { echo '' . ($i === 0 ? esc_html__('start', 'ai-flash-tune') : '—') . ''; } else { $lost = 100 - $kept; $tone = $is_last ? 'good' : ($lost >= 60 ? 'leak' : ($lost >= 35 ? 'warn' : 'neutral')); echo '' /* translators: %s: percentage of shoppers kept from the previous step */ . esc_html(sprintf(__('%s kept', 'ai-flash-tune'), number_format_i18n(min(100, $kept), $kept < 10 ? 1 : 0) . '%')) . ''; } echo '
  2. '; $previous = $count; } echo '
'; } /** * Visits per day as bars (left scale) and orders per day as a line (right scale). * * @param array $days list of ['date' => 'Y-m-d', 'visits' => int, 'orders' => int] */ public static function daily_chart($days) { $days = array_values((array) $days); $n = count($days); if (!$n) { return; } $w = 640; $h = 220; $l = 36; $r = 30; $t = 14; $b = 30; $pw = $w - $l - $r; $ph = $h - $t - $b; $nice = static function ($max) { foreach ([4, 5, 10, 20, 25, 50, 100, 200, 250, 500, 1000, 2000, 5000, 10000] as $step) { if ($max <= $step * 4) { return [$step * 4, $step]; } } $step = (int) ceil($max / 4); return [$step * 4, $step]; }; [$v_max, $v_step] = $nice(max(1, max(array_column($days, 'visits')))); $o_top = max(1, max(array_column($days, 'orders'))); [$o_max, $o_step] = $o_top <= 4 ? [4, 1] : $nice($o_top); $slot = $pw / $n; $bar = max(1.5, $slot * 0.62); $y = static function ($v, $max) use ($t, $ph) { return $t + $ph - ($v / $max) * $ph; }; $label_every = $n <= 7 ? 1 : ($n <= 31 ? 5 : 15); /* translators: short date without year, see https://www.php.net/manual/datetime.format.php */ $fmt = __('M j', 'ai-flash-tune'); $svg = ''; for ($v = 0; $v <= $v_max; $v += $v_step) { $yy = round($y($v, $v_max), 1); $svg .= ''; $svg .= '' . esc_html(number_format_i18n($v)) . ''; } for ($o = 0; $o <= $o_max; $o += $o_step) { $svg .= '' . esc_html(number_format_i18n($o)) . ''; } $points = []; foreach ($days as $i => $day) { $x = $l + $i * $slot + ($slot - $bar) / 2; $bh = ($day['visits'] / $v_max) * $ph; $title = sprintf( /* translators: 1: date, 2: visits, 3: orders */ __('%1$s: %2$s visits, %3$s orders', 'ai-flash-tune'), wp_date($fmt, strtotime($day['date'])), number_format_i18n($day['visits']), number_format_i18n($day['orders']) ); $svg .= '' . esc_html($title) . ''; $points[] = round($l + $i * $slot + $slot / 2, 1) . ',' . round($y($day['orders'], $o_max), 1); if ($i % $label_every === 0 || $i === $n - 1) { $svg .= '' . esc_html(wp_date($fmt, strtotime($day['date']))) . ''; } } $svg .= ''; foreach ($points as $p) { [$px, $py] = explode(',', $p); $svg .= ''; } $svg .= ''; echo '
' . $svg . '

' . esc_html__('Visits', 'ai-flash-tune') . '' . esc_html__('Orders', 'ai-flash-tune') . '

'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Built from escaped parts. } // ------------------------------------------------------------------ // Ask AI // ------------------------------------------------------------------ /** Languages an AI answer can be written in (label is sent to the AI). */ public static function ai_languages() { return [ 'en_US' => 'English (US)', 'en_GB' => 'English (UK)', 'af' => 'Afrikaans', 'ar' => 'Arabic', 'az' => 'Azerbaijani', 'be' => 'Belarusian', 'bg_BG' => 'Bulgarian', 'bn_BD' => 'Bengali', 'bs_BA' => 'Bosnian', 'ca' => 'Catalan', 'ceb' => 'Cebuano', 'cs_CZ' => 'Czech', 'cy' => 'Welsh', 'da_DK' => 'Danish', 'de_DE' => 'German', 'de_CH' => 'German (Switzerland)', 'el' => 'Greek', 'es_ES' => 'Spanish (Spain)', 'es_MX' => 'Spanish (Mexico)', 'es_CO' => 'Spanish (Colombia)', 'es_AR' => 'Spanish (Argentina)', 'es_CL' => 'Spanish (Chile)', 'et' => 'Estonian', 'eu' => 'Basque', 'fa_IR' => 'Persian', 'fi' => 'Finnish', 'fr_FR' => 'French (France)', 'fr_CA' => 'French (Canada)', 'fr_BE' => 'French (Belgium)', 'ga' => 'Irish', 'gl_ES' => 'Galician', 'gu' => 'Gujarati', 'he_IL' => 'Hebrew', 'hi_IN' => 'Hindi', 'hr' => 'Croatian', 'hu_HU' => 'Hungarian', 'hy' => 'Armenian', 'id_ID' => 'Indonesian', 'is_IS' => 'Icelandic', 'it_IT' => 'Italian', 'ja' => 'Japanese', 'ka_GE' => 'Georgian', 'km' => 'Khmer', 'kn' => 'Kannada', 'ko_KR' => 'Korean', 'lt_LT' => 'Lithuanian', 'lv' => 'Latvian', 'mk_MK' => 'Macedonian', 'ml_IN' => 'Malayalam', 'mn' => 'Mongolian', 'mr' => 'Marathi', 'ms_MY' => 'Malay', 'my_MM' => 'Burmese', 'nb_NO' => 'Norwegian (Bokmål)', 'nl_NL' => 'Dutch', 'nl_BE' => 'Dutch (Belgium)', 'pa_IN' => 'Punjabi', 'pl_PL' => 'Polish', 'pt_PT' => 'Portuguese (Portugal)', 'pt_BR' => 'Portuguese (Brazil)', 'ro_RO' => 'Romanian', 'ru_RU' => 'Russian', 'si_LK' => 'Sinhala', 'sk_SK' => 'Slovak', 'sl_SI' => 'Slovenian', 'sq' => 'Albanian', 'sr_RS' => 'Serbian', 'sv_SE' => 'Swedish', 'sw' => 'Swahili', 'ta_IN' => 'Tamil', 'te' => 'Telugu', 'th' => 'Thai', 'tl' => 'Tagalog', 'tr_TR' => 'Turkish', 'uk' => 'Ukrainian', 'ur' => 'Urdu', 'uz_UZ' => 'Uzbek', 'vi' => 'Vietnamese', 'zh_CN' => 'Chinese (Simplified)', 'zh_TW' => 'Chinese (Traditional)', 'zh_HK' => 'Chinese (Hong Kong)', ]; } /** * Response-language select, preselected from the site language. * * @param string $id Element id (scripts read it by id). */ public static function ai_language_select($id) { $languages = self::ai_languages(); $locale = get_locale(); $selected = $languages[$locale] ?? ''; if ($selected === '') { foreach ($languages as $code => $name) { if (substr($code, 0, 2) === substr($locale, 0, 2)) { $selected = $name; break; } } } $selected = $selected !== '' ? $selected : 'English (US)'; echo ''; } /** * "Ask AI" panel with preset questions, used with the answer panel below it. * Element ids and the ai-flash-tune-ai-preset-card class are what the admin * script listens to. * * @param string $title * @param string $description * @param array $presets list of [preset key, title, description, question sent to the AI] */ public static function ask_ai_panel($title, $description, $presets) { echo '
'; echo '
' . self::icon('sparkles') . '
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Fixed SVG. echo '

' . esc_html($title) . '

' . esc_html($description) . '

'; echo '
'; foreach ($presets as [$key, $label, $desc, $question]) { echo ''; } echo '
'; echo '
'; self::ai_language_select('ai-flash-tune-ai-language'); echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Fixed SVG. echo '
'; echo ''; echo '
'; echo ''; } // ------------------------------------------------------------------ // AI and built-in answers // ------------------------------------------------------------------ /** * Sections an answer can have, in display order. The admin script * (aifltRenderAiAnswer) uses the same list so answers look the same whether * they are shown on page load or after a new request. * * @return array */ public static function answer_sections() { return [ 'summary' => ['label' => __('Summary', 'ai-flash-tune'), 'mode' => 'text', 'keys' => ['summary', 'executiveSummary', 'executive_summary', 'simpleExplanation', 'simple_explanation', 'patternSummary', 'diagnosis', 'overview', 'thisWeek', 'this_week']], 'first' => ['label' => __('Do this first', 'ai-flash-tune'), 'mode' => 'text', 'keys' => ['nextBestAction', 'next_best_action', 'primaryAction', 'primary_action']], 'why' => ['label' => __('Why this first', 'ai-flash-tune'), 'mode' => 'text', 'keys' => ['whyFirst', 'why_first']], 'metrics' => ['label' => __('Key numbers', 'ai-flash-tune'), 'mode' => 'kv', 'keys' => ['keyMetrics', 'key_metrics', 'metrics']], 'evidence' => ['label' => __('What the numbers show', 'ai-flash-tune'), 'mode' => 'list', 'keys' => ['evidenceUsed', 'evidence_used', 'evidenceSnapshot', 'evidence_snapshot', 'evidence', 'supportingEvidence', 'supporting_evidence']], 'opportunity' => ['label' => __('Biggest opportunity', 'ai-flash-tune'), 'mode' => 'list', 'keys' => ['biggestVisibleOpportunity', 'biggest_visible_opportunity', 'biggestOpportunity', 'mainOpportunity']], 'causes' => ['label' => __('Likely causes to check', 'ai-flash-tune'), 'mode' => 'list', 'keys' => ['likelyCausesToVerify', 'likely_causes_to_verify', 'likelyReasons', 'likely_reasons', 'causesToVerify', 'causes_to_verify']], 'risks' => ['label' => __('Risks', 'ai-flash-tune'), 'mode' => 'list', 'keys' => ['risks', 'riskFlags', 'risk_flags']], 'fixes' => ['label' => __('Fixes, in order', 'ai-flash-tune'), 'mode' => 'steps', 'keys' => ['rankedSafeFixes', 'ranked_safe_fixes', 'priorityActions', 'priority_actions', 'safeFixes', 'safe_fixes', 'safeRecommendations', 'recommendations']], 'measure' => ['label' => __('How to measure it', 'ai-flash-tune'), 'mode' => 'list', 'keys' => ['measurementPlan', 'measurement_plan']], 'undo' => ['label' => __('If it makes things worse', 'ai-flash-tune'), 'mode' => 'list', 'keys' => ['rollbackSafety', 'rollback_safety', 'rollbackPlan', 'rollback_plan']], 'next' => ['label' => __('Next steps', 'ai-flash-tune'), 'mode' => 'steps', 'keys' => ['nextActions', 'next_actions', 'nextSteps', 'next_steps', 'whatToDoFirst', 'what_to_do_first', 'recommendedSafeNextStep', 'recommended_safe_next_step']], 'checks' => ['label' => __('Next checks', 'ai-flash-tune'), 'mode' => 'steps', 'keys' => ['safeNextChecks', 'safe_next_checks', 'safeRecoveryChecks', 'recommendedChecks', 'whatToCheckNext', 'what_to_check_next', 'safeChecks', 'safe_checks']], 'watch' => ['label' => __('What to watch', 'ai-flash-tune'), 'mode' => 'list', 'keys' => ['watchNextWeek', 'watch_next_week', 'whatToWatch', 'what_to_watch']], 'not_yet' => ['label' => __('Do not change yet', 'ai-flash-tune'), 'mode' => 'warn', 'keys' => ['whatNotToChangeYet', 'what_not_to_change_yet', 'whatNotToDoFirst', 'what_not_to_do_first', 'doNotChangeYet']], 'export' => ['label' => __('Export safety', 'ai-flash-tune'), 'mode' => 'list', 'keys' => ['exportSafety', 'export_safety']], ]; } /** Keys shown as badges at the top of an answer, not as sections. */ private const ANSWER_META_KEYS = ['reportTitle', 'report_title', 'title', 'reportPeriod', 'report_period', 'confidence', 'dataConfidence', 'data_confidence', 'dataReadiness', 'data_readiness', 'readiness', 'impact', 'estimatedEffort', 'estimated_effort', 'riskLevel', 'risk_level']; /** * Decode an answer that may be JSON, fenced JSON or JSON wrapped in an * output/content field. * * @return array|null */ public static function decode_answer($content) { $content = trim(html_entity_decode((string) $content, ENT_QUOTES | ENT_HTML5, 'UTF-8')); if ($content === '') { return null; } if (preg_match('/^```(?:json|js|javascript)?\s*(.*?)\s*```$/is', $content, $m)) { $content = trim($m[1]); } $decoded = json_decode($content, true); if (!is_array($decoded)) { return null; } foreach (['output', 'content', 'message', 'result', 'response', 'report'] as $key) { if (isset($decoded[$key]) && is_array($decoded[$key])) { return $decoded[$key]; } if (isset($decoded[$key]) && is_string($decoded[$key])) { $inner = self::decode_answer($decoded[$key]); if (is_array($inner)) { return $inner; } } } return $decoded; } private static function answer_label($key) { $key = preg_replace('/([a-z])([A-Z])/', '$1 $2', (string) $key); return ucfirst(strtolower(trim(str_replace(['_', '-'], ' ', $key)))); } private static function answer_text($value) { if (is_array($value)) { $parts = []; foreach ($value as $k => $v) { $text = self::answer_text($v); if ($text !== '') { $parts[] = is_string($k) ? self::answer_label($k) . ': ' . $text : $text; } } return implode('; ', $parts); } $text = html_entity_decode(wp_strip_all_tags((string) $value), ENT_QUOTES | ENT_HTML5, 'UTF-8'); return trim(preg_replace('/\*\*(.*?)\*\*/', '$1', $text)); } private static function answer_value($value, $mode) { if ($value === null || $value === '' || $value === []) { return ''; } if (!is_array($value)) { return '

' . esc_html(self::answer_text($value)) . '

'; } $is_list = array_keys($value) === range(0, count($value) - 1); $all_scalar = !array_filter($value, 'is_array'); if (!$is_list && $all_scalar) { $html = '
'; foreach ($value as $k => $v) { if ($v === null || $v === '') { continue; } $html .= '
' . esc_html(self::answer_label($k)) . '
' . esc_html(self::answer_text($v)) . '
'; } return $html . '
'; } // Metrics given as [{label, value}, ...] read best as pairs too. if ($mode === 'kv' && $is_list && !array_filter($value, static function ($i) { return !is_array($i) || !isset($i['value']); })) { $html = '
'; foreach ($value as $item) { $html .= '
' . esc_html(self::answer_text($item['label'] ?? $item['name'] ?? $item['metric'] ?? '')) . '
' . esc_html(self::answer_text($item['value'])) . '
'; } return $html . '
'; } $tag = $mode === 'steps' ? 'ol class="aiflt-answer-steps"' : 'ul'; $html = '<' . $tag . '>'; foreach ($value as $k => $item) { if ($item === null || $item === '' || $item === []) { continue; } if (!is_array($item)) { $html .= '
  • ' . (is_string($k) ? '' . esc_html(self::answer_label($k)) . ': ' : '') . esc_html(self::answer_text($item)) . '
  • '; continue; } $title = is_string($k) ? self::answer_label($k) : ''; foreach (['title', 'heading', 'label', 'recommendation', 'action', 'check', 'name'] as $title_key) { if (!empty($item[$title_key]) && is_scalar($item[$title_key])) { $title = (string) $item[$title_key]; unset($item[$title_key]); break; } } $details = []; foreach ($item as $dk => $dv) { $text = self::answer_text($dv); if ($text !== '') { $details[] = '' . esc_html(self::answer_label($dk)) . ': ' . esc_html($text); } } $html .= '
  • ' . ($title !== '' ? '' . esc_html(self::answer_text($title)) . '' . ($details ? '
    ' : '') : '') . implode('
    ', $details) . '
  • '; } return $html . ($mode === 'steps' ? '' : ''); } /** * Render an AI or built-in answer (JSON or Markdown-like text) as the * shared answer layout. Returns safe HTML. * * @param string $content * @param string[] $skip Section ids to leave out (e.g. 'metrics' when the * page already shows those numbers as stat tiles). */ public static function answer($content, $skip = []) { $data = self::decode_answer($content); if (!is_array($data)) { return self::answer_markdown((string) $content); } $html = '
    '; $meta = ''; $period = $data['reportPeriod'] ?? $data['report_period'] ?? ''; if ($period !== '' && !is_array($period)) { $meta .= self::badge(self::answer_text($period), 'accent'); } $confidence = $data['dataConfidence'] ?? $data['data_confidence'] ?? $data['confidence'] ?? ''; $confidence_level = is_array($confidence) ? ($confidence['level'] ?? '') : $confidence; if ($confidence_level !== '') { /* translators: %s: confidence level such as "low" */ $meta .= self::badge(sprintf(__('Confidence: %s', 'ai-flash-tune'), self::answer_text($confidence_level)), 'warn'); } $scales = [ /* translators: %s: impact such as "high" */ [['impact'], __('Impact: %s', 'ai-flash-tune'), 'good'], /* translators: %s: effort such as "low" */ [['estimatedEffort', 'estimated_effort'], __('Effort: %s', 'ai-flash-tune'), 'neutral'], /* translators: %s: risk such as "low" */ [['riskLevel', 'risk_level'], __('Risk: %s', 'ai-flash-tune'), 'neutral'], ]; foreach ($scales as [$keys, $format, $tone]) { foreach ($keys as $key) { if (isset($data[$key]) && !is_array($data[$key]) && $data[$key] !== '') { $meta .= self::badge(sprintf($format, self::answer_text($data[$key])), $tone); break; } } } if ($meta !== '') { $html .= '
    ' . $meta . '
    '; } if (is_array($confidence) && !empty($confidence['summary'])) { $html .= '

    ' . esc_html(self::answer_text($confidence['summary'])) . '

    '; } $used = self::ANSWER_META_KEYS; foreach (self::answer_sections() as $id => $section) { foreach ($section['keys'] as $key) { if (!array_key_exists($key, $data)) { continue; } $used[] = $key; if (in_array($id, (array) $skip, true)) { break; } $body = self::answer_value($data[$key], $section['mode']); if ($body !== '') { $html .= '

    ' . esc_html($section['label']) . '

    ' . $body . '
    '; } break; } } $extra = array_diff_key($data, array_flip($used)); $extra = array_filter($extra, static function ($v) { return $v !== null && $v !== '' && $v !== []; }); if ($extra) { $html .= '
    ' . esc_html__('More details', 'ai-flash-tune') . '' . self::answer_value($extra, 'list') . '
    '; } return $html . '
    '; } private static function answer_markdown($text) { $text = trim(str_replace(['\r\n', '\n'], "\n", html_entity_decode(wp_strip_all_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8'))); if ($text === '') { return ''; } $inline = static function ($t) { return preg_replace('/\*\*(.+?)\*\*/', '$1', esc_html($t)); }; $html = '
    '; $open = false; $list = ''; $close_list = static function () use (&$html, &$list) { if ($list !== '') { $html .= ''; $list = ''; } }; foreach (preg_split('/\r\n|\r|\n/', $text) as $line) { $line = trim($line); if ($line === '') { $close_list(); continue; } if (preg_match('/^#{1,6}\s*(.+)$/', $line, $m)) { $close_list(); if ($open) { $html .= ''; } $title = trim(str_replace('**', '', rtrim($m[1], ':'))); $warn = (bool) preg_match('/not\s+(to\s+)?change|avoid|do\s+not/i', $title); $html .= '

    ' . esc_html($title) . '

    '; $open = true; continue; } if (!$open) { $html .= '
    '; $open = true; } if (preg_match('/^(?:[-•*]|\d+\.)\s+(.+)$/u', $line, $m)) { if ($list === '') { $list = 'ul'; $html .= '
      '; } $html .= '
    • ' . $inline($m[1]) . '
    • '; continue; } $close_list(); $html .= '

      ' . $inline($line) . '

      '; } $close_list(); if ($open) { $html .= '
    '; } return $html . '
    '; } /** HTML allowed in notes that may contain an icon. */ public static function allowed_svg_html() { $svg = ['class' => true, 'viewbox' => true, 'width' => true, 'height' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true, 'stroke-linejoin' => true, 'aria-hidden' => true, 'focusable' => true]; $shape = ['d' => true, 'cx' => true, 'cy' => true, 'r' => true, 'x' => true, 'y' => true, 'width' => true, 'height' => true, 'rx' => true, 'points' => true, 'x1' => true, 'x2' => true, 'y1' => true, 'y2' => true]; return array_merge(wp_kses_allowed_html('post'), [ 'svg' => $svg, 'path' => $shape, 'circle' => $shape, 'rect' => $shape, 'polyline' => $shape, 'line' => $shape, 'span' => ['class' => true], ]); } } /** * Shortcut for AIFLT_UI::icon(). */ function aiflt_icon($name, $class = '') { return AIFLT_UI::icon($name, $class); }