#!/usr/bin/env python3 """ AWARE — Aviation Weighted Active Recall Engine A cross-platform CLI study tool with spaced repetition and text-to-speech. Features: - 5-bucket weighted repetition (New/Missed -> Learning -> Known) - Cross-platform TTS (Linux: espeak-ng, macOS: say, Windows: SAPI) - Toggle voice on/off with 'v' at any time - Multi-deck support: place .json files in the decks/ folder - Per-deck progress saved to ~/.aware/progress/ - Category filtering, custom session lengths, missed-only review - Optional config override: ~/.aware/config.json - CAS message display and extended detail fields Deck JSON schema (minimal): { "title": "My Study Deck", "questions": [ {"question": "What is X?", "answer": "X is Y."} ] } Full schema (with categories and refs): { "title": "My Study Deck", "categories": [ { "name": "Topic A", "questions": [ {"id": 1, "question": "...", "answer": "...", "ref": "..."} ] } ] } """ import json import hashlib import os import random import re import shutil import subprocess import sys import time from pathlib import Path # ── Configuration ────────────────────────────────────────────────────────── SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) DECKS_DIR = os.path.join(SCRIPT_DIR, "decks") PROGRESS_DIR = os.path.expanduser("~/.aware/progress") BUCKET_WEIGHTS = {1: 8, 2: 5, 3: 3, 4: 2, 5: 1} BUCKET_LABELS = {1: "New / No Clue", 2: "Some", 3: "Half", 4: "Most", 5: "Nailed It"} BUCKET_COLORS = {1: "RED", 2: "RED", 3: "YELLOW", 4: "GREEN", 5: "GREEN"} # Grade input mapping: user input -> bucket assignment # n and 1-3 set absolute bucket positions; y promotes by one GRADE_TO_BUCKET = {"n": 1, "1": 2, "2": 3, "3": 4} CONFIG_FILE = os.path.expanduser("~/.aware/config.json") GLOSSARY_FILE = os.path.join(SCRIPT_DIR, "tts_glossary.json") # ── Platform-specific TTS defaults ───────────────────────────────────────── # # Each backend is a dict with: # cmd - executable name or path # check - args to verify the tool exists (run with subprocess) # build_args- function(text) -> full arg list for Popen # label - human-readable name for status messages # install - install hint shown when tool is missing def _linux_tts(): return { "cmd": "espeak-ng", "check": ["espeak-ng", "--version"], "build_args": lambda text: [ "espeak-ng", "-v", "en-us", "-s", "160", "--", text ], "label": "espeak-ng", "install": "sudo apt install espeak-ng", } def _macos_tts(): return { "cmd": "say", "check": ["which", "say"], "build_args": lambda text: [ "say", "-v", "Alex", "-r", "180", text ], "label": "macOS say", "install": "say is built into macOS — check your PATH", } def _windows_tts(): # PowerShell's System.Speech is synchronous in-process but async from # Python's perspective since we launch it as a subprocess. Killing the # PowerShell process stops speech immediately. def _build(text): # Escape single quotes for PowerShell string literal escaped = text.replace("'", "''") ps_script = ( "Add-Type -AssemblyName System.Speech; " "$s = New-Object System.Speech.Synthesis.SpeechSynthesizer; " f"$s.Speak('{escaped}')" ) return ["powershell", "-NoProfile", "-Command", ps_script] return { "cmd": "powershell", "check": ["powershell", "-NoProfile", "-Command", "echo ok"], "build_args": _build, "label": "Windows SAPI", "install": "PowerShell should be available on Windows by default", } def _detect_tts_backend(): """Pick the right TTS backend for the current platform.""" if sys.platform == "darwin": return _macos_tts() elif sys.platform == "win32": return _windows_tts() else: return _linux_tts() # ── ANSI Colors ──────────────────────────────────────────────────────────── class C: RESET = "\033[0m" BOLD = "\033[1m" DIM = "\033[2m" GREEN = "\033[32m" YELLOW = "\033[33m" CYAN = "\033[36m" RED = "\033[31m" MAGENTA = "\033[35m" WHITE = "\033[97m" @classmethod def init(cls): """Disable color codes on Windows without ANSI support.""" if sys.platform == "win32": try: os.system("") # enables ANSI on Win10+ except Exception: # Fallback: strip all color for attr in ("RESET", "BOLD", "DIM", "GREEN", "YELLOW", "CYAN", "RED", "MAGENTA", "WHITE"): setattr(cls, attr, "") # ── TTS Config Override ─────────────────────────────────────────────────── def _load_tts_config(): """ Load optional user config from ~/.aware/config.json. Supported keys: tts_command - executable path/name (e.g. "piper", "espeak-ng") tts_args - list of args; use {text} as placeholder for spoken text e.g. ["--voice", "en_US", "{text}"] If present, overrides the autodetected platform default. """ if not os.path.exists(CONFIG_FILE): return None try: with open(CONFIG_FILE, "r") as f: cfg = json.load(f) if "tts_command" not in cfg: return None cmd = cfg["tts_command"] args_template = cfg.get("tts_args", ["{text}"]) return { "cmd": cmd, "check": [cmd, "--version"], "build_args": lambda text, _t=args_template, _c=cmd: ( [_c] + [a.replace("{text}", text) for a in _t] ), "label": f"custom ({cmd})", "install": f"Configured via {CONFIG_FILE}", } except Exception: return None # ── TTS Engine ───────────────────────────────────────────────────────────── class TTS: """ Cross-platform text-to-speech. Autodetects: Linux -> espeak-ng macOS -> say Windows -> PowerShell System.Speech (SAPI) Override via ~/.aware/config.json: {"tts_command": "piper", "tts_args": ["--model", "en", "{text}"]} """ def __init__(self): self.enabled = False self._proc = None # Try user config override first, then platform default custom = _load_tts_config() if custom: self._backend = custom else: self._backend = _detect_tts_backend() self.available = self._check_available() self.label = self._backend["label"] # Load pronunciation glossary self._abbreviations = {} self._symbols = {} self._abbr_pattern = None self._load_glossary() def _load_glossary(self): """ Load pronunciation glossary from tts_glossary.json. Falls back to a minimal built-in set if the file is missing. """ glossary_path = GLOSSARY_FILE if os.path.exists(glossary_path): try: with open(glossary_path, "r") as f: data = json.load(f) self._abbreviations = data.get("abbreviations", {}) self._symbols = data.get("symbols", {}) except Exception: self._abbreviations = {} self._symbols = {} # Compile a single regex that matches any abbreviation as a whole word. # Sort by length descending so longer abbreviations match first # (e.g. "KCAS" before "CAS", "OHPTS" before "HP"). if self._abbreviations: sorted_abbrs = sorted(self._abbreviations.keys(), key=len, reverse=True) escaped = [re.escape(a) for a in sorted_abbrs] self._abbr_pattern = re.compile( r'\b(' + '|'.join(escaped) + r')\b' ) def _check_available(self): try: result = subprocess.run( self._backend["check"], capture_output=True, timeout=5 ) return result.returncode == 0 except (FileNotFoundError, subprocess.TimeoutExpired, OSError): return False def toggle(self): if not self.available: return False self.enabled = not self.enabled return self.enabled def speak(self, text): if not self.enabled or not self.available: return self.stop() clean = self._clean_for_speech(text) if not clean.strip(): return try: args = self._backend["build_args"](clean) # On Windows, hide the PowerShell window kwargs = {} if sys.platform == "win32": si = subprocess.STARTUPINFO() si.dwFlags |= subprocess.STARTF_USESHOWWINDOW si.wShowWindow = 0 # SW_HIDE kwargs["startupinfo"] = si self._proc = subprocess.Popen( args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **kwargs ) except Exception: pass def stop(self): if self._proc and self._proc.poll() is None: try: self._proc.terminate() self._proc.wait(timeout=2) except Exception: try: self._proc.kill() except Exception: pass self._proc = None def install_hint(self): return self._backend["install"] def _clean_for_speech(self, text): """ Prepare text for TTS: 1. Strip ANSI escape codes 2. Replace abbreviations using word-boundary regex from glossary 3. Replace symbols (>=, °, etc.) from glossary """ # Strip ANSI text = re.sub(r'\033\[[0-9;]*m', '', text) # Replace abbreviations at word boundaries only if self._abbr_pattern: text = self._abbr_pattern.sub( lambda m: self._abbreviations[m.group(0)], text ) # Replace symbols (order matters: >= before >) for sym, replacement in sorted(self._symbols.items(), key=lambda x: len(x[0]), reverse=True): text = text.replace(sym, replacement) return text # ── Deck Discovery & Loading ─────────────────────────────────────────────── def discover_decks(): decks = [] if os.path.isdir(DECKS_DIR): for f in sorted(os.listdir(DECKS_DIR)): if f.endswith(".json"): path = os.path.join(DECKS_DIR, f) info = _peek_deck(path) if info: decks.append(info) return decks def _peek_deck(path): try: with open(path, "r") as f: data = json.load(f) title = data.get("title", Path(path).stem) count = _count_questions(data) if count == 0: return None return {"path": path, "title": title, "count": count, "filename": os.path.basename(path)} except Exception: return None def _count_questions(data): if "categories" in data: return sum(len(cat.get("questions", [])) for cat in data["categories"]) elif "questions" in data: return len(data["questions"]) return 0 def load_deck(path): """ Load a deck from JSON. Supports two formats: 1. Categorized: {"categories": [{"name": "...", "questions": [...]}]} 2. Flat: {"questions": [{"question": "...", "answer": "..."}]} Returns (question_list, category_names, title). """ with open(path, "r") as f: data = json.load(f) title = data.get("title", Path(path).stem) questions = [] categories = [] auto_id = 1 if "categories" in data: for cat in data["categories"]: cat_name = cat.get("name", "Uncategorized") categories.append(cat_name) for q in cat.get("questions", []): if "id" not in q: q["id"] = auto_id auto_id += 1 q.setdefault("category", cat_name) q.setdefault("ref", "") questions.append(q) elif "questions" in data: categories.append("General") for q in data["questions"]: if "id" not in q: q["id"] = auto_id auto_id += 1 q.setdefault("category", "General") q.setdefault("ref", "") questions.append(q) return questions, categories, title # ── Progress Persistence ─────────────────────────────────────────────────── def _progress_path(deck_path): deck_hash = hashlib.md5(os.path.abspath(deck_path).encode()).hexdigest()[:12] deck_name = Path(deck_path).stem os.makedirs(PROGRESS_DIR, exist_ok=True) return os.path.join(PROGRESS_DIR, f"{deck_name}_{deck_hash}.json") def load_progress(deck_path): ppath = _progress_path(deck_path) if os.path.exists(ppath): with open(ppath, "r") as f: return json.load(f) return {"buckets": {}, "stats": {"total_sessions": 0, "total_correct": 0, "total_wrong": 0}} def save_progress(deck_path, progress): ppath = _progress_path(deck_path) with open(ppath, "w") as f: json.dump(progress, f, indent=2) # ── Bucket Management ────────────────────────────────────────────────────── def get_bucket(progress, qid): return progress["buckets"].get(str(qid), 1) def set_bucket(progress, qid, bucket): """Set a question to a specific bucket (1-5).""" progress["buckets"][str(qid)] = max(1, min(bucket, 5)) def grade_question(progress, qid, grade): """ Apply a grade to a question and return the new bucket. Grades: 'n' -> bucket 1 (absolute) '1' -> bucket 2 (absolute) '2' -> bucket 3 (absolute) '3' -> bucket 4 (absolute) 'y' -> promote by 1, max 5 (relative) """ if grade in GRADE_TO_BUCKET: new_bucket = GRADE_TO_BUCKET[grade] elif grade == "y": new_bucket = min(get_bucket(progress, qid) + 1, 5) else: return get_bucket(progress, qid) set_bucket(progress, qid, new_bucket) return new_bucket def pick_weighted(questions, progress): weights = [BUCKET_WEIGHTS[get_bucket(progress, q["id"])] for q in questions] return random.choices(questions, weights=weights, k=1)[0] # ── Display Helpers ──────────────────────────────────────────────────────── def term_width(): try: return min(os.get_terminal_size().columns, 80) except OSError: return 80 def clear_screen(): os.system("clear" if os.name != "nt" else "cls") def print_header(text, tts=None): width = term_width() print(f"\n{C.CYAN}{'=' * width}{C.RESET}") line = f" {text}" if tts: if tts.enabled: state = f"{C.GREEN}ON ({tts.label}){C.RESET}" else: state = f"{C.DIM}OFF{C.RESET}" line += f" {C.DIM}|{C.RESET} Voice: {state}" print(f"{C.BOLD}{C.WHITE}{line}{C.RESET}") print(f"{C.CYAN}{'=' * width}{C.RESET}") def print_question(q, num, total, progress): bucket = get_bucket(progress, q["id"]) bucket_label = BUCKET_LABELS[bucket] color_map = {1: C.RED, 2: C.RED, 3: C.YELLOW, 4: C.GREEN, 5: C.GREEN} bucket_color = color_map[bucket] cat = q.get("category", "") cat_str = f" Category: {C.CYAN}{cat}{C.RESET}" if cat and cat != "General" else "" print(f"\n{C.DIM}[{num}/{total}]{cat_str}" f" {C.DIM}| Bucket: {bucket_color}{bucket_label}{C.RESET}") # Display CAS messages if present cas = q.get("cas") if cas: if isinstance(cas, str): cas = [cas] print(f"\n {C.BOLD}{C.WHITE}CAS:{C.RESET}") for msg in cas: print(f" {msg}") print(f"\n{C.BOLD}{C.WHITE} Q: {q['question']}{C.RESET}\n") def print_answer(q): print(f"{C.GREEN}{C.BOLD} A: {C.RESET}{C.GREEN}{q['answer']}{C.RESET}") if q.get("ref"): print(f"{C.DIM} Ref: {q['ref']}{C.RESET}") if q.get("detail"): print(f"\n {C.CYAN}{C.BOLD}Detail:{C.RESET}") print(f" {C.CYAN}{q['detail']}{C.RESET}") def print_session_stats(grades, total): width = term_width() nailed = grades.get("y", 0) missed = grades.get("n", 0) partial = grades.get("1", 0) + grades.get("2", 0) + grades.get("3", 0) # Score: y=100%, 3=75%, 2=50%, 1=25%, n=0% score_sum = (nailed * 100 + grades.get("3", 0) * 75 + grades.get("2", 0) * 50 + grades.get("1", 0) * 25) avg = (score_sum / total) if total > 0 else 0 print(f"\n{C.CYAN}{'-' * width}{C.RESET}") print(f" {C.BOLD}Session Results ({total} questions):{C.RESET}") print(f" {C.GREEN}Nailed (y): {nailed}{C.RESET} | " f"{C.YELLOW}Partial (1/2/3): {partial}{C.RESET} | " f"{C.RED}Missed (n): {missed}{C.RESET} | " f"{C.BOLD}Avg: {avg:.0f}%{C.RESET}") print(f"{C.CYAN}{'-' * width}{C.RESET}") def print_overall_stats(progress, questions): buckets = {i: 0 for i in range(1, 6)} for q in questions: buckets[get_bucket(progress, q["id"])] += 1 total = len(questions) print(f"\n {C.BOLD}Progress ({total} questions):{C.RESET}") print(f" {C.RED}Bucket 1 (No Clue): {buckets[1]:>4} ({buckets[1]/total*100:5.1f}%){C.RESET}") print(f" {C.RED}Bucket 2 (Some): {buckets[2]:>4} ({buckets[2]/total*100:5.1f}%){C.RESET}") print(f" {C.YELLOW}Bucket 3 (Half): {buckets[3]:>4} ({buckets[3]/total*100:5.1f}%){C.RESET}") print(f" {C.GREEN}Bucket 4 (Most): {buckets[4]:>4} ({buckets[4]/total*100:5.1f}%){C.RESET}") print(f" {C.GREEN}Bucket 5 (Nailed): {buckets[5]:>4} ({buckets[5]/total*100:5.1f}%){C.RESET}") s = progress["stats"] if s["total_sessions"] > 0: lifetime = s.get("total_correct", 0) + s.get("total_wrong", 0) + s.get("total_partial", 0) if lifetime > 0: lifetime_pct = (s.get("total_correct", 0) / lifetime * 100) print(f"\n {C.DIM}Sessions: {s['total_sessions']} | " f"Lifetime: {s.get('total_correct',0)} nailed / " f"{s.get('total_partial',0)} partial / " f"{s.get('total_wrong',0)} missed ({lifetime_pct:.0f}% nailed){C.RESET}") # ── Category Selection ───────────────────────────────────────────────────── def select_categories(categories): print(f"\n {C.BOLD}Categories:{C.RESET}") print(f" {C.CYAN}0{C.RESET}) All categories") for i, cat in enumerate(categories, 1): print(f" {C.CYAN}{i}{C.RESET}) {cat}") print(f"\n Enter numbers separated by commas (e.g. 1,3,5) or 0 for all:") try: raw = input(f" {C.BOLD}>{C.RESET} ").strip() except (EOFError, KeyboardInterrupt): return None if not raw or raw == "0": return None selected = [] for part in raw.split(","): part = part.strip() if part.isdigit(): idx = int(part) if 1 <= idx <= len(categories): selected.append(categories[idx - 1]) return selected if selected else None # ── Quiz Loop ────────────────────────────────────────────────────────────── def run_quiz(questions, deck_path, progress, count, title, tts): grades = {"n": 0, "1": 0, "2": 0, "3": 0, "y": 0} for i in range(1, count + 1): clear_screen() print_header(title, tts) pick = pick_weighted(questions, progress) print_question(pick, i, count, progress) # Build spoken text with CAS context if present speak_text = "" cas = pick.get("cas") if cas: if isinstance(cas, str): cas = [cas] speak_text = "CAS: " + ", ".join(cas) + ". " speak_text += pick["question"] tts.speak(speak_text) input(f" {C.DIM}[Press Enter to reveal answer]{C.RESET}") tts.stop() print_answer(pick) print(f"\n {C.BOLD}How'd you do?{C.RESET}") print(f" {C.RED}n{C.RESET} - didn't get it " f"{C.YELLOW}1{C.RESET} - some " f"{C.YELLOW}2{C.RESET} - half " f"{C.YELLOW}3{C.RESET} - most " f"{C.GREEN}y{C.RESET} - nailed it") print(f" {C.MAGENTA}v{C.RESET} - voice toggle " f"{C.DIM}q - quit{C.RESET}") while True: try: resp = input(f" {C.BOLD}>{C.RESET} ").strip().lower() except (EOFError, KeyboardInterrupt): resp = "q" if resp in ("y", "yes", "+"): grade = "y" elif resp in ("n", "no", "-", ""): grade = "n" elif resp in ("1", "2", "3"): grade = resp elif resp in ("v", "voice"): new_state = tts.toggle() label = f"{C.GREEN}ON{C.RESET}" if new_state else f"{C.DIM}OFF{C.RESET}" print(f" {C.MAGENTA}Voice: {label}{C.RESET}") if new_state: tts.speak(speak_text) continue elif resp in ("q", "quit"): tts.stop() print_session_stats(grades, sum(grades.values())) save_progress(deck_path, progress) return else: print(f" {C.DIM}Enter n/-, 1, 2, 3, y/+, v, or q{C.RESET}") continue # Apply grade grades[grade] += 1 new_bucket = grade_question(progress, pick["id"], grade) new_label = BUCKET_LABELS[new_bucket] if grade == "y": progress["stats"]["total_correct"] = progress["stats"].get("total_correct", 0) + 1 print(f" {C.GREEN}+ Nailed it — {new_label}{C.RESET}") elif grade == "n": progress["stats"]["total_wrong"] = progress["stats"].get("total_wrong", 0) + 1 print(f" {C.RED}x Missed — {new_label}{C.RESET}") else: progress["stats"]["total_partial"] = progress["stats"].get("total_partial", 0) + 1 print(f" {C.YELLOW}~ Partial — {new_label}{C.RESET}") break time.sleep(0.6) progress["stats"]["total_sessions"] += 1 save_progress(deck_path, progress) tts.stop() clear_screen() print_header("Session Complete", tts) print_session_stats(grades, count) print_overall_stats(progress, questions) def review_missed(questions, deck_path, progress, title, tts): missed = [q for q in questions if get_bucket(progress, q["id"]) == 1] if not missed: print(f"\n {C.GREEN}No questions in Bucket 1 — nothing to review.{C.RESET}") return random.shuffle(missed) print(f"\n {C.BOLD}Reviewing {len(missed)} missed/new questions...{C.RESET}") time.sleep(1) run_quiz(missed, deck_path, progress, min(len(missed), 50), title, tts) # ── Deck Selection ───────────────────────────────────────────────────────── def select_deck(): decks = discover_decks() if not decks: print(f"\n {C.RED}No decks found.{C.RESET}") print(f" Place .json deck files in: {C.CYAN}{DECKS_DIR}/{C.RESET}") print(f"\n Minimal deck format:") print(f' {C.DIM}{{"title": "My Deck", "questions": ' f'[{{"question": "Q?", "answer": "A."}}]}}{C.RESET}') return None if len(decks) == 1: return decks[0]["path"] print(f"\n {C.BOLD}Available Decks:{C.RESET}\n") for i, d in enumerate(decks, 1): print(f" {C.CYAN}{i}{C.RESET}) {d['title']} " f"{C.DIM}({d['count']} questions — {d['filename']}){C.RESET}") print() try: raw = input(f" Select deck {C.BOLD}>{C.RESET} ").strip() except (EOFError, KeyboardInterrupt): return None if raw.isdigit(): idx = int(raw) if 1 <= idx <= len(decks): return decks[idx - 1]["path"] return None # ── Main Menu ────────────────────────────────────────────────────────────── def deck_menu(deck_path, tts): questions, categories, title = load_deck(deck_path) progress = load_progress(deck_path) while True: clear_screen() print_header(title, tts) print_overall_stats(progress, questions) multi_cat = len(categories) > 1 print(f"\n {C.BOLD}Menu:{C.RESET}") print(f" {C.CYAN}1{C.RESET}) Quick 10") print(f" {C.CYAN}2{C.RESET}) Session 25") print(f" {C.CYAN}3{C.RESET}) Full 50") print(f" {C.CYAN}4{C.RESET}) Custom count") if multi_cat: print(f" {C.CYAN}5{C.RESET}) By category") print(f" {C.CYAN}6{C.RESET}) Review missed only (Bucket 1)") voice_state = f"{C.GREEN}ON{C.RESET}" if tts.enabled else f"{C.DIM}OFF{C.RESET}" print(f" {C.MAGENTA}v{C.RESET}) Toggle voice ({voice_state})") print(f" {C.CYAN}7{C.RESET}) Reset deck progress") print(f" {C.CYAN}d{C.RESET}) Switch deck") print(f" {C.CYAN}q{C.RESET}) Quit") try: choice = input(f"\n {C.BOLD}>{C.RESET} ").strip().lower() except (EOFError, KeyboardInterrupt): break if choice == "1": run_quiz(questions, deck_path, progress, min(10, len(questions)), title, tts) elif choice == "2": run_quiz(questions, deck_path, progress, min(25, len(questions)), title, tts) elif choice == "3": run_quiz(questions, deck_path, progress, min(50, len(questions)), title, tts) elif choice == "4": try: n = int(input(f" How many questions? (1-{len(questions)}): ").strip()) n = max(1, min(n, len(questions))) except (ValueError, EOFError, KeyboardInterrupt): continue run_quiz(questions, deck_path, progress, n, title, tts) elif choice == "5" and multi_cat: selected = select_categories(categories) filtered = ([q for q in questions if q["category"] in selected] if selected else questions) if not filtered: print(f" {C.RED}No questions matched.{C.RESET}") time.sleep(1) continue try: n = int(input(f" How many? (1-{len(filtered)}, default 10): " ).strip() or "10") n = max(1, min(n, len(filtered))) except (ValueError, EOFError, KeyboardInterrupt): continue run_quiz(filtered, deck_path, progress, n, title, tts) elif choice == "6": review_missed(questions, deck_path, progress, title, tts) elif choice in ("v", "voice"): new_state = tts.toggle() if not tts.available: print(f" {C.RED}TTS not available ({tts.label}). " f"{tts.install_hint()}{C.RESET}") time.sleep(2) else: label = (f"{C.GREEN}ON{C.RESET}" if new_state else f"{C.DIM}OFF{C.RESET}") print(f" {C.MAGENTA}Voice: {label}{C.RESET}") time.sleep(0.5) continue elif choice == "7": try: confirm = input( f" {C.RED}Reset progress for this deck? " f"(type YES): {C.RESET}" ).strip() except (EOFError, KeyboardInterrupt): continue if confirm == "YES": progress = { "buckets": {}, "stats": {"total_sessions": 0, "total_correct": 0, "total_wrong": 0} } save_progress(deck_path, progress) print(f" {C.GREEN}Progress reset.{C.RESET}") time.sleep(1) continue elif choice == "d": save_progress(deck_path, progress) return "switch" elif choice in ("q", "quit"): break else: continue input(f"\n {C.DIM}[Press Enter to return to menu]{C.RESET}") save_progress(deck_path, progress) return "quit" def main(): C.init() os.makedirs(DECKS_DIR, exist_ok=True) # Auto-migrate legacy file from script directory into decks/ if not discover_decks(): for f in os.listdir(SCRIPT_DIR): if f.endswith(".json") and f != "package.json": src = os.path.join(SCRIPT_DIR, f) info = _peek_deck(src) if info: print(f" {C.YELLOW}Moving {f} into decks/ ...{C.RESET}") shutil.copy2(src, os.path.join(DECKS_DIR, f)) if not discover_decks(): print(f"\n {C.RED}No decks found.{C.RESET}") print(f" Place .json deck files in: {C.CYAN}{DECKS_DIR}/{C.RESET}") print(f"\n Minimal format:") print(f' {C.DIM}{{"title": "My Deck", ' f'"questions": [{{"question": "Q?", "answer": "A."}}]}}{C.RESET}\n') sys.exit(1) tts = TTS() while True: clear_screen() print_header("AWARE — Aviation Weighted Active Recall Engine", tts) deck_path = select_deck() if deck_path is None: break result = deck_menu(deck_path, tts) if result == "quit": break tts.stop() print(f"\n {C.DIM}Progress saved. Fly safe.{C.RESET}\n") if __name__ == "__main__": main()