'',
'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 '
';
}
/** "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 '- ';
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 '
';
$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 = '';
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 '';
echo '';
echo '
';
echo '
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Fixed SVG.
echo '
';
echo '';
echo '';
echo '' . esc_html__('Answer', 'ai-flash-tune') . '
' . esc_html__('Based only on the numbers above. Check before you change anything.', 'ai-flash-tune') . '
';
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 . '>';
$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);
}