"""Idiom story catalog. Edit idiom-lessons.json to add or remove lessons.""" from __future__ import annotations import json import os import re from functools import lru_cache from typing import Any, Dict, List, Optional, Tuple _CATALOG_FILE = os.path.join(os.path.dirname(__file__), "idiom-lessons.json") _COMPACT_RE = re.compile(r"^(l[1-6])\.(\d{3})$", re.I) @lru_cache(maxsize=1) def load_catalog() -> Dict[str, Any]: with open(_CATALOG_FILE, "r", encoding="utf-8") as handle: data = json.load(handle) return data if isinstance(data, dict) else {} def _stories() -> List[Dict[str, Any]]: rows = load_catalog().get("stories") or [] return [row for row in rows if isinstance(row, dict)] @lru_cache(maxsize=1) def _index() -> Dict[str, Dict[str, Any]]: index: Dict[str, Dict[str, Any]] = {} for story in _stories(): sid = str(story.get("id") or "").strip().lower() if sid and sid not in index: index[sid] = story return index def parse_lesson_id(lesson_id: Any) -> Tuple[str, str]: key = str(lesson_id or "").strip().lower() match = _COMPACT_RE.match(key) if match: return match.group(1).lower(), match.group(2) return "", "" def find_story(lesson_id: Any) -> Optional[Dict[str, Any]]: key = str(lesson_id or "").strip().lower() if not key: return None level, sid = parse_lesson_id(key) if sid: return _index().get(sid) return _index().get(key) def bind_story(story: Dict[str, Any], lesson_id: Any = "") -> Dict[str, Any]: row = dict(story) level, _sid = parse_lesson_id(lesson_id) row["levelId"] = level or "l1" return row def canonical_lesson_id(lesson_id: Any) -> str: story = find_story(lesson_id) if story is None: return str(lesson_id or "").strip() bound = bind_story(story, lesson_id) return f"{bound.get('levelId')}.{story.get('id')}" def default_en_passes() -> List[Dict[str, Any]]: raw = ((load_catalog().get("defaults") or {}).get("enPasses")) or [] out: List[Dict[str, Any]] = [] for item in raw: row = normalize_pass(item) if row: out.append(row) return out or [ {"speed": 0.8, "repeat": 1}, {"speed": 0.9, "repeat": 2}, {"speed": 1.0, "repeat": 5}, ] def normalize_pass(raw: Any) -> Optional[Dict[str, Any]]: if not isinstance(raw, dict): return None try: speed = float(raw.get("speed", 1.0)) except (TypeError, ValueError): speed = 1.0 try: repeat = int(raw.get("repeat", 1)) except (TypeError, ValueError): repeat = 1 speed = min(2.0, max(0.5, speed)) repeat = min(10, max(1, repeat)) return {"speed": speed, "repeat": repeat} def sentence_count(story: Dict[str, Any]) -> int: try: count = int(story.get("sentenceCount") or ((load_catalog().get("defaults") or {}).get("sentenceCount")) or 30) except (TypeError, ValueError): count = 30 return min(200, max(1, count)) def extensive_paths(story: Dict[str, Any], lesson_id: Any = "") -> Tuple[str, str]: bound = bind_story(story, lesson_id) patterns = load_catalog().get("pathPatterns") or {} pattern = str(patterns.get("repeater") or "") if pattern: return ( _fill(pattern, bound, "cn", "01"), _fill(pattern, bound, "en", "01"), ) return ( _fill(str(patterns.get("repeaterCn") or ""), bound, "cn", "01"), _fill(str(patterns.get("repeaterEn") or ""), bound, "en", "01"), ) def shadowing_paths( story: Dict[str, Any], language: str = "en", count: Optional[int] = None, lesson_id: Any = "", ) -> List[str]: bound = bind_story(story, lesson_id) lang = "cn" if str(language or "").strip().lower() == "cn" else "en" total = sentence_count(bound) if count is None else min(200, max(1, int(count))) width = 3 if total > 99 else 2 pattern = str((load_catalog().get("pathPatterns") or {}).get("shadowing") or "") return [ _fill(pattern, bound, lang, f"{index:0{width}d}") for index in range(1, total + 1) ] def _fill(pattern: str, story: Dict[str, Any], lang: str, nn: str) -> str: return ( pattern.replace("{level}", str(story.get("levelId") or "l1")) .replace("{story}", str(story.get("id") or "")) .replace("{lang}", lang) .replace("{nn}", nn) )