// Today-Hero story loader. // // Reads ~/.fai/today/active.yaml when present, validates the // schema, and hands the parsed bilingual story back to the // store page. On any failure (file missing, schema mismatch, // banned-words hit, parse error) the caller falls back to the // compiled-in const story so KRITIS / fresh installs always // have something to render. // // See docs/today-pipeline.md for the full design rationale and // tools/today/ for the operator-facing CLI. import 'dart:io'; import 'package:flutter/material.dart'; import 'package:path/path.dart' as p; import 'package:yaml/yaml.dart'; /// Editorial action attached to a Today story. The set is kept /// closed and ABI-stable — adding a new variant without bumping /// the schema would let an old Studio render an unknown action /// as a no-op button. /// /// `filterCategory` and `runQuery` both consume the optional /// [TodayStoryData.ctaPayload] — a wire-string the Store page /// dispatches into its filter / search box. Keeping the action /// surface inside the Store means a hero story can teach about a /// section without forcing a page-route change. enum TodayCta { none, openSettings, filterCategory, runQuery, showSuggestedSources, } /// Bilingual story rendered by the Today-Hero card. Either /// loaded from disk or supplied as a const fallback. @immutable class TodayStoryData { final String badgeEn; final String badgeDe; final String titleEn; final String titleDe; final String bodyEn; final String bodyDe; final String ctaLabelEn; final String ctaLabelDe; final IconData icon; final TodayCta cta; /// Wire payload used by [TodayCta.filterCategory] (category id /// to apply to the Store grid) and [TodayCta.runQuery] (free-text /// to drop into the search box). Empty for other variants. final String ctaPayload; const TodayStoryData({ required this.badgeEn, required this.badgeDe, required this.titleEn, required this.titleDe, required this.bodyEn, required this.bodyDe, required this.ctaLabelEn, required this.ctaLabelDe, required this.icon, required this.cta, this.ctaPayload = '', }); } /// Static loader. Pure I/O + parsing, no widget concerns. The /// store page calls `loadOrFallback` once per fetch and feeds /// the result into `_StoreTodayHero`. class TodayStoryLoader { /// Resolves to `~/.fai/today/active.yaml` on every supported /// host. The pipeline writes here from [tools/today/accept.sh]. static String activePath() { final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'] ?? ''; return p.join(home, '.fai', 'today', 'active.yaml'); } /// Reads and validates the active story. Returns `fallback` /// when the file is missing, malformed, or fails schema / /// banned-words checks. Loud failures stay on disk in the /// pipeline tooling — Studio quietly falls back so the UI /// never crashes on bad operator input. static TodayStoryData loadOrFallback(TodayStoryData fallback) { try { final f = File(activePath()); if (!f.existsSync()) return fallback; final raw = f.readAsStringSync(); final story = _parse(raw); return story ?? fallback; } catch (_) { return fallback; } } static TodayStoryData? _parse(String raw) { final dynamic doc = loadYaml(raw); if (doc is! YamlMap) return null; if (doc['schema']?.toString() != 'today/v1') return null; String s(String key) => (doc[key] ?? '').toString().trim(); if (_bannedWords.hasMatch( '${s('title_en')} ${s('title_de')} ${s('body_en')} ${s('body_de')}', )) { return null; } final required = [ 'badge_en', 'badge_de', 'title_en', 'title_de', 'body_en', 'body_de', 'icon', 'cta', ]; for (final k in required) { if (s(k).isEmpty) return null; } final cta = _ctaFor(s('cta')); if (cta == null) return null; if (cta != TodayCta.none) { if (s('cta_label_en').isEmpty || s('cta_label_de').isEmpty) { return null; } } final icon = _iconFor(s('icon')); if (icon == null) return null; return TodayStoryData( badgeEn: s('badge_en'), badgeDe: s('badge_de'), titleEn: s('title_en'), titleDe: s('title_de'), bodyEn: s('body_en'), bodyDe: s('body_de'), ctaLabelEn: s('cta_label_en'), ctaLabelDe: s('cta_label_de'), icon: icon, cta: cta, ctaPayload: s('cta_payload'), ); } /// Same banned-word list as `tools/today/accept.sh`. Kept in /// sync by hand — a candidate that bypassed the shell gate /// (e.g. a hand-edited active.yaml) still won't reach the UI. static final RegExp _bannedWords = RegExp( r'\b(' r'viral|killer|powerful|just\s+works|revolutionary|game[- ]changing|' r'seamless|next[- ]generation|cutting[- ]edge|world[- ]class|unprecedented' r')\b', caseSensitive: false, ); static TodayCta? _ctaFor(String s) { switch (s) { case 'none': return TodayCta.none; case 'openSettings': return TodayCta.openSettings; case 'filterCategory': return TodayCta.filterCategory; case 'runQuery': return TodayCta.runQuery; case 'showSuggestedSources': return TodayCta.showSuggestedSources; default: return null; } } /// Whitelist of icons the prompt template is allowed to /// produce. Keeping the set explicit means an LLM hallucinated /// icon name fails the load (and we fall back) rather than /// rendering a random glyph. static IconData? _iconFor(String name) { switch (name) { case 'hub_outlined': return Icons.hub_outlined; case 'account_tree_outlined': return Icons.account_tree_outlined; case 'shield_outlined': return Icons.shield_outlined; case 'extension': return Icons.extension; case 'bolt': return Icons.bolt; case 'menu_book_outlined': return Icons.menu_book_outlined; case 'security': return Icons.security; case 'auto_awesome': return Icons.auto_awesome; case 'rocket_launch': return Icons.rocket_launch; case 'timeline_outlined': return Icons.timeline_outlined; default: return null; } } }