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

This commit is contained in:
ajp_anton
2026-08-19 20:51:26 +00:00
parent 7e6dea8b24
commit 8ec3cde4f2
56 changed files with 13441 additions and 0 deletions
+354
View File
@@ -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")