import argparse import json from json import JSONDecodeError from pathlib import Path from linkki_poster.formatting import validate_markdown_syntax from linkki_poster.models import Config, LANGUAGE_DISPLAY_NAMES, Language, LanguageAssets, ScanResult from linkki_poster.ordering import prompt_language_order from linkki_poster.progress import ProgressDisplay from linkki_poster.scanner import scan_directory from linkki_poster.telegram_api import TelegramAPI from linkki_poster.workflow import post_assets LANGUAGE_ORDER_ALIASES: dict[str, Language] = { "fi": Language.FI, "finnish": Language.FI, "suomi": Language.FI, "sv": Language.SV, "swedish": Language.SV, "svenska": Language.SV, "ruotsi": Language.SV, "en": Language.EN, "english": Language.EN, "engelska": Language.EN, "englanti": Language.EN, } def run_cli(script_dir: Path | None = None, argv: list[str] | None = None, input_fn=input) -> None: effective_script_dir = script_dir or Path(__file__).resolve().parent.parent args = parse_args(argv) target_directory = get_target_directory( script_dir=effective_script_dir, raw_directory=args.directory, non_interactive=args.non_interactive, input_fn=input_fn, ) initial_scan_result = scan_directory(target_directory) ordered_languages = determine_language_order( initial_scan_result.languages, args.order, args.non_interactive, input_fn, ) while True: scan_result = scan_directory(target_directory) effective_scan_result = ScanResult( global_images=scan_result.global_images, languages=reapply_language_order(scan_result.languages, ordered_languages), ) print_scan_summary(effective_scan_result) if not effective_scan_result.global_images and not effective_scan_result.languages: print("Nothing to post.") return syntax_issues = collect_syntax_issues(effective_scan_result.languages) if not syntax_issues: break print_syntax_issues(syntax_issues) if args.allow_invalid_syntax: break if args.non_interactive: raise ValueError("Broken markdown syntax detected.") action = prompt_syntax_action(input_fn) if action == "post": break config = load_config( script_dir=effective_script_dir, non_interactive=args.non_interactive, input_fn=input_fn, ) api = TelegramAPI(config.bot_token) progress = ProgressDisplay() posted_refs = post_assets( api=api, config=config, global_images=effective_scan_result.global_images, ordered_languages=effective_scan_result.languages, progress=progress, ) if posted_refs: print("Posted language messages:") for posted_ref in posted_refs: print(f"- {LANGUAGE_DISPLAY_NAMES[posted_ref.language]}: {posted_ref.message_url}") def parse_args(argv: list[str] | None) -> argparse.Namespace: parser = argparse.ArgumentParser(description="Post a multilingual Telegram broadcast from one asset directory.") parser.add_argument( "directory", nargs="?", help="Directory to scan. Relative paths are resolved from the script directory.", ) parser.add_argument( "--order", help="Optional language order, for example: fi,sv,en or finnish,english.", ) parser.add_argument( "--allow-invalid-syntax", action="store_true", help="Post even if supported markdown syntax appears broken.", ) parser.add_argument( "--non-interactive", action="store_true", help="Never prompt. Fail immediately when required input is missing or invalid.", ) return parser.parse_args(argv) def get_target_directory( script_dir: Path, raw_directory: str | None, non_interactive: bool, input_fn=input, ) -> Path: if raw_directory is not None: return resolve_target_directory(script_dir, raw_directory) if non_interactive: raise ValueError("Directory argument is required in non-interactive mode.") return prompt_target_directory(script_dir, input_fn) def resolve_target_directory(script_dir: Path, raw_value: str) -> Path: candidate = Path(raw_value).expanduser() if not candidate.is_absolute(): candidate = (script_dir / candidate).resolve() if not candidate.is_dir(): raise FileNotFoundError(f"Directory not found: {candidate}") return candidate def prompt_target_directory(script_dir: Path, input_fn=input) -> Path: while True: raw_value = input_fn("Directory to scan: ").strip() if raw_value == "": print("Value cannot be empty.") continue try: return resolve_target_directory(script_dir, raw_value) except FileNotFoundError as error: print(error) def determine_language_order( languages: list[LanguageAssets], raw_order: str | None, non_interactive: bool, input_fn=input, ) -> list[LanguageAssets]: if raw_order is not None: return apply_language_order(languages, raw_order) if non_interactive: return languages return prompt_language_order(languages, input_fn=input_fn) def reapply_language_order( current_languages: list[LanguageAssets], ordered_template: list[LanguageAssets], ) -> list[LanguageAssets]: current_by_language = {language_assets.language: language_assets for language_assets in current_languages} ordered: list[LanguageAssets] = [] seen: set[Language] = set() for language_assets in ordered_template: current = current_by_language.get(language_assets.language) if current is None: continue ordered.append(current) seen.add(current.language) for language_assets in current_languages: if language_assets.language not in seen: ordered.append(language_assets) return ordered def apply_language_order(languages: list[LanguageAssets], raw_order: str) -> list[LanguageAssets]: requested_languages = parse_language_order(raw_order) language_by_code = {language_assets.language: language_assets for language_assets in languages} seen: set[Language] = set() ordered: list[LanguageAssets] = [] for language in requested_languages: if language in seen: raise ValueError(f"Duplicate language in --order: {language.value}") seen.add(language) if language in language_by_code: ordered.append(language_by_code[language]) remaining = [language_assets for language_assets in languages if language_assets.language not in seen] return [*ordered, *remaining] def parse_language_order(raw_order: str) -> list[Language]: tokens = [token.strip().lower() for token in raw_order.replace(";", ",").split(",") if token.strip()] if not tokens: raise ValueError("Language order cannot be empty.") parsed_languages: list[Language] = [] for token in tokens: language = LANGUAGE_ORDER_ALIASES.get(token) if language is None: raise ValueError(f"Unknown language in --order: {token}") parsed_languages.append(language) return parsed_languages def load_config(script_dir: Path, non_interactive: bool, input_fn=input) -> Config: config_path = script_dir / "telegram_config.json" config_data: dict[str, str] = {} if config_path.exists(): raw_text = config_path.read_text(encoding="utf-8") try: loaded = json.loads(raw_text) except JSONDecodeError as error: if non_interactive: raise ValueError(f"Invalid JSON in {config_path}: {error.msg}") from error print(f"Invalid JSON in {config_path}: {error.msg}") loaded = {} if isinstance(loaded, dict): config_data = {str(key): str(value) for key, value in loaded.items()} elif non_interactive: raise FileNotFoundError(f"Missing config file: {config_path}") bot_token = resolve_config_value( config_data=config_data, key="bot_token", prompt="Bot token: ", non_interactive=non_interactive, input_fn=input_fn, ) chat_id = resolve_config_value( config_data=config_data, key="chat_id", prompt="Chat ID: ", non_interactive=non_interactive, input_fn=input_fn, ) channel_username = resolve_optional_config_value( config_data=config_data, key="channel_username", prompt="Channel username (optional, for public links): ", non_interactive=non_interactive, input_fn=input_fn, ) return Config(bot_token=bot_token, chat_id=chat_id, channel_username=channel_username) def load_config_strict(script_dir: Path) -> Config: return load_config(script_dir=script_dir, non_interactive=True) def resolve_config_value( config_data: dict[str, str], key: str, prompt: str, non_interactive: bool, input_fn=input, ) -> str: existing = config_data.get(key, "").strip() if existing: return existing if non_interactive: raise ValueError(f"Config field '{key}' is required.") while True: entered = input_fn(prompt).strip() if entered: return entered print("Value cannot be empty.") def resolve_optional_config_value( config_data: dict[str, str], key: str, prompt: str, non_interactive: bool, input_fn=input, ) -> str: existing = config_data.get(key) if existing is not None: return existing.strip() if non_interactive: return "" return input_fn(prompt).strip() def collect_syntax_issues(languages: list[LanguageAssets]) -> dict[Language, list[str]]: issues_by_language: dict[Language, list[str]] = {} for language_assets in languages: issues = validate_markdown_syntax(language_assets.text_raw) if issues: issues_by_language[language_assets.language] = [ f"{language_assets.text_file.name}: {issue.message}" for issue in issues ] return issues_by_language def print_syntax_issues(issues_by_language: dict[Language, list[str]]) -> None: print("Potential markdown syntax issues detected:") for language in (Language.FI, Language.SV, Language.EN): issues = issues_by_language.get(language) if not issues: continue print(f"- {LANGUAGE_DISPLAY_NAMES[language]}") for issue in issues: print(f" - {issue}") def prompt_syntax_action(input_fn=input) -> str: while True: answer = input_fn("Post anyway or retry after fixing files? [p/r]: ").strip().lower() if answer in {"p", "post"}: return "post" if answer in {"r", "retry"}: return "retry" print("Enter 'p' to post anyway or 'r' to retry.") def print_scan_summary(scan_result: ScanResult) -> None: print("Scan summary:") if scan_result.global_images: print("Global images:") for image_path in scan_result.global_images: print(f"- {image_path.name}") else: print("Global images: none") if not scan_result.languages: print("Languages: none (no language text files found)") return print("Languages:") for index, language_assets in enumerate(scan_result.languages, start=1): print(f"{index}. {LANGUAGE_DISPLAY_NAMES[language_assets.language]}") print(f" Text: {language_assets.text_file.name}") if language_assets.images: print(" Images:") for image_path in language_assets.images: print(f" - {image_path.name}") else: print(" Images: none")