Add Linkki broadcast image
Build custom container images / build (map[base_image:php:8-fpm-alpine build_args:PHP_VERSION=8
context:php8-pgsql fingerprint_command:{ apk info -v | LC_ALL=C sort; find /usr/local/lib/php/extensions /usr/local/etc/php/conf.d -type f -exec sha256sum {} + | LC_ALL=C sort; }
name:ph… (push) Successful in 48s
Build custom container images / build (map[base_image:postgres:18 build_args:PG_VERSION=18
POSTGIS_VERSION=3
VCHORD_VERSION=0.5.3
context:postgres fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; find /usr/lib/postgresql -type f -exec sha256su… (push) Successful in 55s
Build custom container images / build (map[base_image:python:3 build_args:PYTHON_VERSION=3
context:python-tools fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
name:python-tools oci_labels:org.opencontainers.ima… (push) Successful in 54s
Build custom container images / build (map[base_image:python:3.12-slim build_args:PYTHON_VERSION=3.12-slim
context:linkki-tiedotus fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
name:linkki-tiedotus oci_labels:… (push) Successful in 39s
Build custom container images / build (map[base_image:php:8-fpm-alpine build_args:PHP_VERSION=8
context:php8-pgsql fingerprint_command:{ apk info -v | LC_ALL=C sort; find /usr/local/lib/php/extensions /usr/local/etc/php/conf.d -type f -exec sha256sum {} + | LC_ALL=C sort; }
name:ph… (push) Successful in 48s
Build custom container images / build (map[base_image:postgres:18 build_args:PG_VERSION=18
POSTGIS_VERSION=3
VCHORD_VERSION=0.5.3
context:postgres fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; find /usr/lib/postgresql -type f -exec sha256su… (push) Successful in 55s
Build custom container images / build (map[base_image:python:3 build_args:PYTHON_VERSION=3
context:python-tools fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
name:python-tools oci_labels:org.opencontainers.ima… (push) Successful in 54s
Build custom container images / build (map[base_image:python:3.12-slim build_args:PYTHON_VERSION=3.12-slim
context:linkki-tiedotus fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
name:linkki-tiedotus oci_labels:… (push) Successful in 39s
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
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")
|
||||
Reference in New Issue
Block a user