"""Unified command-line interface for Saiki.""" from __future__ import annotations import argparse import importlib.metadata import sys from .audio import extract_audio from .config import Config, language_choices, load_config from .importer import ( format_tts_error, import_sentences, list_tts_voices, supported_tts_backends, synthesize_tts_sample, ) from .words import compare_word_files, compare_word_lists, extract_words, extract_words_from_file from .youtube import run_youtube def add_config_arg(parser: argparse.ArgumentParser) -> None: """Attach the shared ``--config`` option to a parser.""" parser.add_argument("--config", help="Path to YAML config file.") def add_tts_override_args(parser: argparse.ArgumentParser, tts_backends: list[str]) -> None: """Attach per-command TTS override flags. These options intentionally mirror config keys so command-line overrides can be collected mechanically and merged over the selected language. """ parser.add_argument("--tts-backend", choices=tts_backends, help="Override the configured TTS backend.") parser.add_argument("--tts-voice", help="Override the configured backend voice.") parser.add_argument("--tts-voices", help="Override the configured backend voice bundle path.") parser.add_argument("--tts-model", help="Override the configured backend model or local model path.") parser.add_argument("--tts-model-dir", help="Override the directory used for relative TTS model paths.") parser.add_argument("--tts-config", help="Override the configured backend model config path.") parser.add_argument("--tts-vocab-config", help="Override the configured backend vocab config path.") parser.add_argument("--tts-code", help="Override the configured backend language code.") parser.add_argument("--tts-tld", help="Override the configured gTTS top-level domain.") parser.add_argument("--tts-tempo", type=float, help="Override the post-processing tempo multiplier.") parser.add_argument("--tts-speed", type=float, help="Override backend-native speech speed when supported.") def collect_tts_overrides(args: argparse.Namespace) -> dict[str, object]: """Collect TTS override attributes from an argparse namespace.""" return { "tts_backend": getattr(args, "tts_backend", None), "tts_voice": getattr(args, "tts_voice", None), "tts_voices": getattr(args, "tts_voices", None), "tts_model": getattr(args, "tts_model", None), "tts_model_dir": getattr(args, "tts_model_dir", None), "tts_config": getattr(args, "tts_config", None), "tts_vocab_config": getattr(args, "tts_vocab_config", None), "tts_code": getattr(args, "tts_code", None), "tts_tld": getattr(args, "tts_tld", None), "tts_tempo": getattr(args, "tts_tempo", None), "tts_speed": getattr(args, "tts_speed", None), } def build_parser(config: Config | None = None) -> argparse.ArgumentParser: """Build the full CLI parser. Passing a loaded config lets argparse choices reflect user-defined language codes. When no config is supplied, defaults are loaded so the parser remains usable in tests and help-generation contexts. """ choices = language_choices(config or load_config()) tts_backends = supported_tts_backends() parser = argparse.ArgumentParser(description="Saiki: sentence mining and listening tools for Anki.") parser.add_argument( "-V", "--version", action="version", version=f"%(prog)s {importlib.metadata.version('saiki')}", ) add_config_arg(parser) sub = parser.add_subparsers(dest="command", required=True) audio = sub.add_parser("audio", help="Extract Anki audio into playlists.") audio.add_argument("lang", choices=choices) audio.add_argument("--concat", action="store_true") audio.add_argument("--outdir") audio.add_argument("--media-dir") audio.add_argument("--copy-only-new", action="store_true") words = sub.add_parser("words", help="Extract frequent words from Anki or from a TSV export file.") words.add_argument("lang", nargs="?", choices=choices) words.add_argument("--lang", dest="lang_option", choices=choices, help="Language code.") words.add_argument("--input", help="Anki TSV export file (instead of AnkiConnect).") group = words.add_mutually_exclusive_group() group.add_argument("--query") group.add_argument("--deck", action="append") words.add_argument("--field", help="Anki field name (AnkiConnect) or 1-based column index (TSV file).") words.add_argument("--min-freq", type=int, default=2) words.add_argument("--outdir") words.add_argument("--out", "--output", dest="out") words.add_argument("--full-field", action="store_true") words.add_argument("--spacy-model") words.add_argument("--debug", help="Path for debug TSV output.") words.add_argument( "--field-section", choices=["all", "first"], default="all", help="Which blank-line-separated TSV field section to mine.", ) words.add_argument("--debug-min-freq", type=int, default=0, help="Minimum frequency for debug output (default: all).") words.add_argument("--no-clean", action="store_true", help="Skip Anki field text cleaning before NLP.") compare = sub.add_parser("compare-words", help="Print words in source that are not in known.") compare.add_argument("source") compare.add_argument("known") compare_new = sub.add_parser("compare", help="Compare deck vocabulary against a target list.") compare_new.add_argument("--deck-words", required=True, help="Path to deck word list.") compare_new.add_argument("--target-words", required=True, help="Path to target vocabulary list.") compare_new.add_argument("--output", help="Write missing words to this file.") compare_new.add_argument("--seen-output", help="Write seen words to this file.") compare_new.add_argument("--min-frequency", type=int, default=0, help="Minimum frequency for deck words to count.") youtube = sub.add_parser("youtube", help="Mine a YouTube transcript.") youtube.add_argument("lang", choices=choices) youtube.add_argument("video") youtube.add_argument("--mode", choices=["vocab", "sentences"], default="vocab") youtube.add_argument("--top", type=int) youtube.add_argument("--no-stopwords", action="store_true") youtube.add_argument("--raw", action="store_true") youtube.add_argument("--out") youtube.add_argument("--format", choices=["tsv", "csv"], default="tsv") youtube.add_argument("--known-words", help="Word list to filter vocab_guess against.") youtube.add_argument("--only-new", action="store_true", help="Only export sentences with unknown vocab.") importer = sub.add_parser("import", help="Generate TTS and import sentence cards.") importer.add_argument("lang", choices=choices) importer.add_argument("sentence_file", nargs="?") importer.add_argument("--tags", help="Comma-separated tags. text-to-speech is always included.") importer.add_argument("--dry-run", action="store_true", help="Preview sentences without generating TTS or adding cards.") add_tts_override_args(importer, tts_backends) test_tts = sub.add_parser("tts-test", help="Synthesize one TTS sample without importing into Anki.") test_tts.add_argument("lang", choices=choices) test_tts.add_argument("text", nargs="?") test_tts.add_argument("--out", help="Output MP3 path. Defaults to ./tts_test__.mp3.") add_tts_override_args(test_tts, tts_backends) voices = sub.add_parser("tts-voices", help="List voices or voice-listing hints for a TTS backend.") voices.add_argument("lang", nargs="?", choices=choices) voices.add_argument("--backend", choices=tts_backends, help="Backend to list instead of the language default.") return parser def main(argv: list[str] | None = None) -> int: """Run the CLI and return a process exit status.""" # Parse --config first so subcommand language choices can come from the # user's config file instead of only the built-in defaults. pre = argparse.ArgumentParser(add_help=False) add_config_arg(pre) known, _ = pre.parse_known_args(argv) config = load_config(known.config) parser = build_parser(config) args = parser.parse_args(argv) if args.command == "words": lang_option = getattr(args, "lang_option", None) if args.lang and lang_option and args.lang != lang_option: print( f"Error: conflicting language values: {args.lang!r} and {lang_option!r}", file=sys.stderr, ) return 2 args.lang = lang_option or args.lang if not args.lang: print("Error: words requires a language, e.g. 'saiki words es' or 'saiki words --lang es'", file=sys.stderr) return 2 if args.command == "audio": result = extract_audio(config, args.lang, args.outdir, args.media_dir, args.copy_only_new, args.concat) print(f"Copied {result['copied']} files") print(f"Playlist: {result['playlist']}") print(f"Output directory: {result['outdir']}") if result["concat"]: print(f"Concatenated file: {result['concat']}") return 0 if args.command == "words": if args.input: # File-based extraction if args.field and not args.field.isdigit(): print( f"Error: --field must be a 1-based column index (integer) " f"when --input is used, got: {args.field!r}", file=sys.stderr, ) return 1 field_index = int(args.field) if args.field else 2 if field_index < 1: print( f"Error: --field must be a 1-based column index >= 1, got: {field_index}", file=sys.stderr, ) return 1 result = extract_words_from_file( config, args.lang, args.input, field_index=field_index, min_freq=args.min_freq, outdir=args.outdir, out=args.out, debug=args.debug, spacy_model=args.spacy_model, debug_min_freq=args.debug_min_freq, no_clean=args.no_clean, field_section=args.field_section, ) print(f"Parsed {result['records']} records") print(f"Wrote {result['written']} entries to: {result['out']}") if result.get("debug"): print(f"Debug output: {result['debug']}") else: result = extract_words( config, args.lang, args.query, args.deck, args.field, args.min_freq, args.outdir, args.out, args.full_field, args.spacy_model, ) print(f"Query: {result['query']}") print(f"Found {result['notes']} notes") print(f"Extracted {result['unique']} unique entries") print(f"Wrote {result['written']} entries to: {result['out']}") return 0 if args.command == "compare-words": for line in compare_word_files(args.source, args.known): print(line) return 0 if args.command == "compare": result = compare_word_lists( args.deck_words, args.target_words, output_path=args.output, min_frequency=args.min_frequency, seen_output_path=args.seen_output, ) print(f"Total missing from deck: {result['total_missing']}") seen_count = len(result["seen"]) print(f"Total seen in deck: {seen_count}") if result["missing"]: print("Missing words:") for w in result["missing"][:20]: print(f" {w}") if len(result["missing"]) > 20: print(f" ... and {len(result['missing']) - 20} more") if args.output: print(f"Missing words written to: {args.output}") if args.seen_output: print(f"Seen words written to: {args.seen_output}") return 0 if args.command == "youtube": result = run_youtube( config, args.lang, args.video, args.mode, args.top, args.no_stopwords, args.raw, args.out, args.format, args.known_words, args.only_new, ) if args.mode == "sentences" and not args.out: for line in result["lines"]: print(f"[{line.start:.2f}s] {line.text}") elif args.mode == "sentences": print(f"Wrote {result['written']} rows to: {result['out']}") else: for word, count in result["items"]: print(f"{word}: {count}") return 0 if args.command == "import": tts_overrides = collect_tts_overrides(args) result = import_sentences(config, args.lang, args.sentence_file, args.tags, tts_overrides=tts_overrides, dry_run=args.dry_run) if args.dry_run: print(f"Dry run: {result.processed} sentence(s) would be imported.") else: print(f"Done. Added {result.added}/{result.processed} cards. Failed: {result.failed}") for error in result.errors: print(f"Error: {error}", file=sys.stderr) return 0 if result.failed == 0 else 1 if args.command == "tts-test": try: output = synthesize_tts_sample(config, args.lang, args.text, args.out, collect_tts_overrides(args)) print(f"Wrote TTS sample: {output}") return 0 except Exception as exc: print(f"Error: {format_tts_error(exc)}", file=sys.stderr) return 1 if args.command == "tts-voices": for line in list_tts_voices(config, args.lang, args.backend): print(line) return 0 parser.print_help() return 2 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))