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
1031 lines
41 KiB
Python
1031 lines
41 KiB
Python
import json
|
|
import re
|
|
import shutil
|
|
import unicodedata
|
|
import uuid
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
from linkki_poster.models import Language, PublishBinding, TextSegmentBinding, LanguageBinding
|
|
from linkki_poster.scanner import KNOWN_IMAGE_EXTENSIONS
|
|
|
|
|
|
POST_METADATA_FILENAME = "post.json"
|
|
METADATA_SCHEMA_VERSION = 3
|
|
LANGUAGE_ORDER_DEFAULT = [Language.FI.value, Language.SV.value, Language.EN.value]
|
|
IMAGE_SCOPE_PREFIXES = {
|
|
"global": "Kuva",
|
|
Language.FI.value: "Suomi",
|
|
Language.SV.value: "Svenska",
|
|
Language.EN.value: "English",
|
|
}
|
|
TEXT_FILENAMES = {
|
|
Language.FI.value: "Suomi.md",
|
|
Language.SV.value: "Svenska.md",
|
|
Language.EN.value: "English.md",
|
|
}
|
|
LANGUAGE_FIELD_KEYS = [Language.FI.value, Language.SV.value, Language.EN.value]
|
|
TELEGRAM_DELETE_WINDOW = timedelta(hours=48)
|
|
|
|
|
|
def list_broadcasts(posts_dir: Path) -> list[dict]:
|
|
broadcasts: list[dict] = []
|
|
for post_dir in posts_dir.iterdir():
|
|
if not post_dir.is_dir():
|
|
continue
|
|
metadata = load_metadata(post_dir)
|
|
broadcasts.append(
|
|
{
|
|
"directory": post_dir.name,
|
|
"title": metadata["title"],
|
|
"sort_index": metadata["sort_index"],
|
|
"status": metadata["status"],
|
|
"scheduled_for": metadata["scheduled_for"],
|
|
"scheduled_next_attempt_at": metadata.get("scheduled_next_attempt_at"),
|
|
"scheduled_retry_count": metadata.get("scheduled_retry_count", 0),
|
|
"scheduled_unpin_for": metadata.get("scheduled_unpin_for"),
|
|
"last_attempt_at": metadata.get("last_attempt_at"),
|
|
"last_result": metadata.get("last_result"),
|
|
"linked": metadata["telegram_binding"] is not None,
|
|
"telegram_binding": metadata.get("telegram_binding"),
|
|
"discussion_pinning_mode": metadata.get("discussion_pinning_mode"),
|
|
"telegram_bot_id": metadata["telegram_bot_id"],
|
|
"telegram_chat_id": metadata["telegram_chat_id"],
|
|
"updated_at": metadata["updated_at"],
|
|
"telegram_published_at": metadata.get("telegram_published_at"),
|
|
}
|
|
)
|
|
return sorted(
|
|
broadcasts,
|
|
key=lambda item: (item["sort_index"], item["directory"].lower()),
|
|
)
|
|
|
|
|
|
def create_broadcast(
|
|
posts_dir: Path,
|
|
title: str,
|
|
default_bot_id: str = "",
|
|
default_chat_id: str = "",
|
|
) -> str:
|
|
slug = slugify(title)
|
|
directory_name = slug
|
|
post_dir = posts_dir / directory_name
|
|
if post_dir.exists():
|
|
raise ValueError(f"Directory already exists: {directory_name}")
|
|
post_dir.mkdir(parents=True, exist_ok=False)
|
|
|
|
for text_filename in TEXT_FILENAMES.values():
|
|
(post_dir / text_filename).write_text("", encoding="utf-8")
|
|
|
|
metadata = default_metadata(
|
|
slug,
|
|
title,
|
|
sort_index=new_broadcast_sort_index(posts_dir),
|
|
telegram_bot_id=default_bot_id,
|
|
telegram_chat_id=default_chat_id,
|
|
)
|
|
save_metadata(post_dir, metadata)
|
|
return directory_name
|
|
|
|
|
|
def load_broadcast(post_dir: Path) -> dict:
|
|
metadata = load_metadata(post_dir)
|
|
texts = {
|
|
language: (post_dir / TEXT_FILENAMES[language]).read_text(encoding="utf-8") if (post_dir / TEXT_FILENAMES[language]).exists() else ""
|
|
for language in LANGUAGE_FIELD_KEYS
|
|
}
|
|
image_lists = metadata["image_lists"]
|
|
return {
|
|
"metadata": metadata,
|
|
"texts": texts,
|
|
"images": image_lists,
|
|
"directory": post_dir.name,
|
|
}
|
|
|
|
|
|
def save_broadcast_changes(
|
|
post_dir: Path,
|
|
changes: dict,
|
|
) -> dict:
|
|
metadata = load_metadata(post_dir)
|
|
current_post_dir = post_dir
|
|
results: dict[str, dict] = {}
|
|
linked = metadata["telegram_binding"] is not None
|
|
scheduled = metadata["status"] == "scheduled"
|
|
|
|
requested = {key for key, value in changes.items() if value not in (None, {}, [])}
|
|
|
|
if linked:
|
|
linked_disallowed = {
|
|
"telegram_bot_id",
|
|
"telegram_chat_id",
|
|
"language_order",
|
|
"enabled_languages",
|
|
"images",
|
|
}
|
|
forbidden = requested.intersection(linked_disallowed)
|
|
if forbidden:
|
|
for field in forbidden:
|
|
results[field] = {"status": "rejected", "reason": "Field cannot be changed after publish."}
|
|
|
|
if scheduled:
|
|
scheduled_disallowed = {"telegram_bot_id", "telegram_chat_id"}
|
|
forbidden = requested.intersection(scheduled_disallowed)
|
|
if forbidden:
|
|
for field in forbidden:
|
|
results[field] = {"status": "rejected", "reason": "Telegram target cannot be changed after scheduling."}
|
|
|
|
text_changes = changes.get("texts", {})
|
|
for language, payload in text_changes.items():
|
|
field_key = f"{language}.text"
|
|
result = apply_text_change(post_dir, metadata, language, payload)
|
|
results[field_key] = result
|
|
|
|
preview_payload = changes.get("disable_link_previews")
|
|
if preview_payload is not None:
|
|
results["disable_link_previews"] = apply_disable_link_previews_change(metadata, preview_payload)
|
|
|
|
pinning_payload = changes.get("discussion_pinning_mode")
|
|
if pinning_payload is not None:
|
|
results["discussion_pinning_mode"] = apply_discussion_pinning_mode_change(metadata, pinning_payload)
|
|
|
|
delete_service_payload = changes.get("delete_pin_service_messages")
|
|
if delete_service_payload is not None:
|
|
results["delete_pin_service_messages"] = apply_delete_pin_service_messages_change(metadata, delete_service_payload)
|
|
|
|
schedule_draft_payload = changes.get("schedule_draft_for")
|
|
if schedule_draft_payload is not None:
|
|
results["schedule_draft_for"] = apply_schedule_draft_for_change(metadata, schedule_draft_payload)
|
|
|
|
title_payload = changes.get("title")
|
|
if title_payload is not None:
|
|
title_result, current_post_dir = apply_title_change(current_post_dir, metadata, title_payload)
|
|
results["title"] = title_result
|
|
|
|
if not linked:
|
|
bot_id_payload = changes.get("telegram_bot_id")
|
|
if bot_id_payload is not None and "telegram_bot_id" not in results:
|
|
results["telegram_bot_id"] = apply_simple_field_change(
|
|
metadata,
|
|
"telegram_bot_id",
|
|
{
|
|
"base_revision": bot_id_payload["base_revision"],
|
|
"value": str(bot_id_payload["value"]).strip(),
|
|
},
|
|
)
|
|
|
|
chat_id_payload = changes.get("telegram_chat_id")
|
|
if chat_id_payload is not None and "telegram_chat_id" not in results:
|
|
results["telegram_chat_id"] = apply_simple_field_change(
|
|
metadata,
|
|
"telegram_chat_id",
|
|
{
|
|
"base_revision": chat_id_payload["base_revision"],
|
|
"value": str(chat_id_payload["value"]).strip(),
|
|
},
|
|
)
|
|
|
|
order_payload = changes.get("language_order")
|
|
if order_payload is not None:
|
|
results["language_order"] = apply_language_order_change(metadata, order_payload)
|
|
|
|
enabled_payload = changes.get("enabled_languages")
|
|
if enabled_payload is not None:
|
|
results["enabled_languages"] = apply_enabled_languages_change(metadata, enabled_payload)
|
|
|
|
image_changes = changes.get("images", {})
|
|
for scope, payload in image_changes.items():
|
|
field_key = f"{scope}.images"
|
|
results[field_key] = apply_image_change(current_post_dir, metadata, scope, payload)
|
|
|
|
if any(item.get("status") == "saved" for item in results.values()):
|
|
metadata["updated_at"] = now_iso()
|
|
transition_status_after_save(metadata)
|
|
save_metadata(current_post_dir, metadata)
|
|
|
|
return {"results": results, "metadata": metadata, "post_dir": current_post_dir}
|
|
|
|
|
|
def stage_image_upload(post_dir: Path, scope: str, filename: str, content: bytes) -> dict:
|
|
extension = Path(filename).suffix.lower().lstrip(".")
|
|
if extension not in KNOWN_IMAGE_EXTENSIONS:
|
|
raise ValueError(f"Unsupported image extension: {extension}")
|
|
temp_name = f"_tmp_{scope}_{uuid.uuid4().hex}.{extension}"
|
|
file_path = post_dir / temp_name
|
|
file_path.write_bytes(content)
|
|
return {
|
|
"name": temp_name,
|
|
"url": f"/announcements/{post_dir.name}/images/{temp_name}",
|
|
}
|
|
|
|
|
|
def mark_publish_success(post_dir: Path, binding: PublishBinding, output: str) -> None:
|
|
mark_publish_success_with_result(post_dir, binding, output, "published")
|
|
|
|
|
|
def mark_publish_success_with_result(
|
|
post_dir: Path,
|
|
binding: PublishBinding,
|
|
output: str,
|
|
result: str,
|
|
effective_discussion_pinning_mode: str | None = None,
|
|
) -> None:
|
|
metadata = load_metadata(post_dir)
|
|
linked_at = now_iso()
|
|
metadata["status"] = "published"
|
|
metadata["scheduled_for"] = None
|
|
metadata["scheduled_next_attempt_at"] = None
|
|
metadata["scheduled_retry_count"] = 0
|
|
metadata["last_attempt_at"] = linked_at
|
|
metadata["last_output"] = append_status_output(metadata.get("last_output", ""), output, result, linked_at)
|
|
metadata["last_result"] = result
|
|
metadata["telegram_binding"] = serialize_binding(binding)
|
|
if result == "published" or not metadata.get("telegram_published_at"):
|
|
metadata["telegram_published_at"] = linked_at
|
|
if effective_discussion_pinning_mode is not None:
|
|
normalized_mode = normalize_discussion_pinning_mode(effective_discussion_pinning_mode)
|
|
if metadata["discussion_pinning_mode"] != normalized_mode:
|
|
metadata["discussion_pinning_mode"] = normalized_mode
|
|
metadata["field_revisions"]["discussion_pinning_mode"] = int(metadata["field_revisions"]["discussion_pinning_mode"]) + 1
|
|
metadata["dirty_text_languages"] = []
|
|
metadata["dirty_discussion_pinning"] = False
|
|
metadata["updated_at"] = linked_at
|
|
save_metadata(post_dir, metadata)
|
|
|
|
|
|
def mark_publish_failure(post_dir: Path, output: str, during_update: bool) -> None:
|
|
metadata = load_metadata(post_dir)
|
|
attempted_at = now_iso()
|
|
metadata["last_attempt_at"] = attempted_at
|
|
result = "update_failed" if during_update and metadata["telegram_binding"] is not None else "publish_failed"
|
|
metadata["last_output"] = append_status_output(metadata.get("last_output", ""), output, result, attempted_at)
|
|
if during_update and metadata["telegram_binding"] is not None:
|
|
metadata["status"] = "modified"
|
|
metadata["last_result"] = "update_failed"
|
|
else:
|
|
metadata["status"] = "failed"
|
|
metadata["scheduled_next_attempt_at"] = None
|
|
metadata["scheduled_retry_count"] = 0
|
|
metadata["telegram_binding"] = None
|
|
metadata["dirty_text_languages"] = []
|
|
metadata["dirty_discussion_pinning"] = False
|
|
metadata["last_result"] = "publish_failed"
|
|
metadata["updated_at"] = now_iso()
|
|
save_metadata(post_dir, metadata)
|
|
|
|
|
|
def mark_scheduled_publish_failure(
|
|
post_dir: Path,
|
|
output: str,
|
|
partial_binding: PublishBinding | None = None,
|
|
) -> None:
|
|
"""Keep a scheduled post eligible for exponential-backoff retry."""
|
|
|
|
metadata = load_metadata(post_dir)
|
|
attempted_at = datetime.now().astimezone()
|
|
retry_count = int(metadata.get("scheduled_retry_count", 0) or 0) + 1
|
|
retry_delay = scheduled_retry_delay(retry_count)
|
|
metadata["status"] = "scheduled"
|
|
metadata["scheduled_retry_count"] = retry_count
|
|
metadata["scheduled_next_attempt_at"] = (attempted_at + retry_delay).isoformat()
|
|
metadata["last_attempt_at"] = attempted_at.isoformat()
|
|
metadata["last_output"] = append_status_output(
|
|
metadata.get("last_output", ""),
|
|
output,
|
|
"scheduled_publish_retry",
|
|
metadata["last_attempt_at"],
|
|
)
|
|
metadata["last_result"] = "scheduled_publish_retry"
|
|
if partial_binding is not None:
|
|
metadata["telegram_binding"] = serialize_binding(partial_binding)
|
|
metadata["telegram_published_at"] = metadata.get("telegram_published_at") or metadata["last_attempt_at"]
|
|
metadata["dirty_discussion_pinning"] = True
|
|
metadata["updated_at"] = metadata["last_attempt_at"]
|
|
save_metadata(post_dir, metadata)
|
|
|
|
|
|
def scheduled_retry_delay(retry_count: int) -> timedelta:
|
|
minutes = (1, 5, 15, 30)[min(max(retry_count, 1) - 1, 3)]
|
|
return timedelta(minutes=minutes)
|
|
|
|
|
|
def mark_scheduled(post_dir: Path, scheduled_for: str, schedule_draft_for: str | None = None) -> None:
|
|
metadata = load_metadata(post_dir)
|
|
metadata["status"] = "scheduled"
|
|
metadata["scheduled_for"] = scheduled_for
|
|
metadata["scheduled_next_attempt_at"] = None
|
|
metadata["scheduled_retry_count"] = 0
|
|
set_schedule_draft_for(metadata, schedule_draft_for)
|
|
metadata["updated_at"] = now_iso()
|
|
save_metadata(post_dir, metadata)
|
|
|
|
|
|
def unschedule(post_dir: Path) -> None:
|
|
metadata = load_metadata(post_dir)
|
|
metadata["scheduled_for"] = None
|
|
metadata["scheduled_next_attempt_at"] = None
|
|
metadata["scheduled_retry_count"] = 0
|
|
if metadata["telegram_binding"] is not None and (
|
|
metadata["dirty_text_languages"] or metadata.get("dirty_discussion_pinning", False)
|
|
):
|
|
metadata["status"] = "modified"
|
|
else:
|
|
metadata["status"] = "draft"
|
|
metadata["updated_at"] = now_iso()
|
|
save_metadata(post_dir, metadata)
|
|
|
|
|
|
def set_scheduled_unpin_for(post_dir: Path, scheduled_unpin_for: str | None) -> None:
|
|
metadata = load_metadata(post_dir)
|
|
metadata["scheduled_unpin_for"] = normalize_optional_string(scheduled_unpin_for)
|
|
metadata["field_revisions"]["scheduled_unpin_for"] = int(metadata["field_revisions"]["scheduled_unpin_for"]) + 1
|
|
metadata["updated_at"] = now_iso()
|
|
save_metadata(post_dir, metadata)
|
|
|
|
|
|
def unlink_telegram(post_dir: Path) -> None:
|
|
metadata = load_metadata(post_dir)
|
|
metadata["status"] = "draft"
|
|
metadata["scheduled_for"] = None
|
|
metadata["scheduled_next_attempt_at"] = None
|
|
metadata["scheduled_retry_count"] = 0
|
|
metadata["scheduled_unpin_for"] = None
|
|
metadata["telegram_binding"] = None
|
|
metadata["dirty_text_languages"] = []
|
|
metadata["dirty_discussion_pinning"] = False
|
|
metadata["last_attempt_at"] = None
|
|
metadata["last_output"] = ""
|
|
metadata["last_result"] = None
|
|
metadata["telegram_published_at"] = None
|
|
metadata["updated_at"] = now_iso()
|
|
save_metadata(post_dir, metadata)
|
|
|
|
|
|
def clear_status_history(post_dir: Path) -> None:
|
|
metadata = load_metadata(post_dir)
|
|
metadata["last_attempt_at"] = None
|
|
metadata["last_output"] = ""
|
|
metadata["last_result"] = None
|
|
metadata["updated_at"] = now_iso()
|
|
save_metadata(post_dir, metadata)
|
|
|
|
|
|
def delete_from_storage(post_dir: Path) -> None:
|
|
shutil.rmtree(post_dir)
|
|
|
|
|
|
def get_binding(metadata: dict) -> PublishBinding | None:
|
|
binding_payload = metadata.get("telegram_binding")
|
|
if binding_payload is None:
|
|
return None
|
|
return deserialize_binding(binding_payload)
|
|
|
|
|
|
def load_metadata(post_dir: Path) -> dict:
|
|
metadata_path = post_dir / POST_METADATA_FILENAME
|
|
if metadata_path.exists():
|
|
try:
|
|
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError:
|
|
metadata = {}
|
|
else:
|
|
metadata = {}
|
|
return merge_metadata_with_defaults(post_dir, metadata)
|
|
|
|
|
|
def save_metadata(post_dir: Path, metadata: dict) -> None:
|
|
(post_dir / POST_METADATA_FILENAME).write_text(json.dumps(metadata, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
|
|
|
|
def save_binding(post_dir: Path, binding: PublishBinding) -> dict:
|
|
metadata = load_metadata(post_dir)
|
|
metadata["telegram_binding"] = serialize_binding(binding)
|
|
metadata["updated_at"] = now_iso()
|
|
save_metadata(post_dir, metadata)
|
|
return metadata
|
|
|
|
|
|
def default_metadata(
|
|
slug: str,
|
|
title: str,
|
|
sort_index: int,
|
|
telegram_bot_id: str = "",
|
|
telegram_chat_id: str = "",
|
|
) -> dict:
|
|
return {
|
|
"schema_version": METADATA_SCHEMA_VERSION,
|
|
"slug": slug,
|
|
"title": title,
|
|
"sort_index": sort_index,
|
|
"telegram_bot_id": telegram_bot_id,
|
|
"telegram_chat_id": telegram_chat_id,
|
|
"language_order": list(LANGUAGE_ORDER_DEFAULT),
|
|
"discussion_pinning_mode": "first",
|
|
"delete_pin_service_messages": True,
|
|
"enabled_languages": {language: True for language in LANGUAGE_FIELD_KEYS},
|
|
"disable_link_previews": {language: True for language in LANGUAGE_FIELD_KEYS},
|
|
"status": "draft",
|
|
"scheduled_for": None,
|
|
"scheduled_next_attempt_at": None,
|
|
"scheduled_retry_count": 0,
|
|
"schedule_draft_for": None,
|
|
"scheduled_unpin_for": None,
|
|
"last_attempt_at": None,
|
|
"last_output": "",
|
|
"last_result": None,
|
|
"telegram_published_at": None,
|
|
"telegram_binding": None,
|
|
"dirty_text_languages": [],
|
|
"dirty_discussion_pinning": False,
|
|
"field_revisions": default_field_revisions(),
|
|
"image_lists": {"global": [], **{language: [] for language in LANGUAGE_FIELD_KEYS}},
|
|
"created_at": now_iso(),
|
|
"updated_at": now_iso(),
|
|
}
|
|
|
|
|
|
def default_field_revisions() -> dict[str, int]:
|
|
revisions = {
|
|
"title": 0,
|
|
"telegram_bot_id": 0,
|
|
"telegram_chat_id": 0,
|
|
"language_order": 0,
|
|
"discussion_pinning_mode": 0,
|
|
"delete_pin_service_messages": 0,
|
|
"schedule_draft_for": 0,
|
|
"scheduled_unpin_for": 0,
|
|
"enabled_languages": 0,
|
|
"disable_link_previews": 0,
|
|
}
|
|
revisions["global.images"] = 0
|
|
for language in LANGUAGE_FIELD_KEYS:
|
|
revisions[f"{language}.text"] = 0
|
|
revisions[f"{language}.images"] = 0
|
|
return revisions
|
|
|
|
|
|
def build_image_lists_from_disk(post_dir: Path) -> dict[str, list[str]]:
|
|
image_lists = {"global": [], **{language: [] for language in LANGUAGE_FIELD_KEYS}}
|
|
files = [path for path in post_dir.iterdir() if path.is_file()]
|
|
for scope, prefix in IMAGE_SCOPE_PREFIXES.items():
|
|
matches: list[tuple[Path, int, str]] = []
|
|
pattern = re.compile(rf"^{re.escape(prefix)}(\d+)\.([^.]+)$", re.IGNORECASE)
|
|
for file_path in files:
|
|
match = pattern.match(file_path.name)
|
|
if match is None:
|
|
continue
|
|
extension = match.group(2).lower()
|
|
if extension not in KNOWN_IMAGE_EXTENSIONS:
|
|
continue
|
|
matches.append((file_path, int(match.group(1)), extension))
|
|
matches.sort(key=lambda item: (item[1], item[2], item[0].name.lower()))
|
|
image_lists[scope] = [item[0].name for item in matches]
|
|
return image_lists
|
|
|
|
|
|
def apply_text_change(post_dir: Path, metadata: dict, language: str, payload: dict) -> dict:
|
|
field_key = f"{language}.text"
|
|
expected_revision = int(payload["base_revision"])
|
|
current_revision = int(metadata["field_revisions"][field_key])
|
|
if expected_revision != current_revision:
|
|
return {"status": "conflict", "reason": "Text was changed by someone else."}
|
|
|
|
new_text = normalize_saved_text(str(payload["value"]))
|
|
text_path = post_dir / TEXT_FILENAMES[language]
|
|
current_text = text_path.read_text(encoding="utf-8") if text_path.exists() else ""
|
|
if new_text == current_text:
|
|
return {"status": "unchanged"}
|
|
|
|
text_path.write_text(new_text, encoding="utf-8")
|
|
metadata["field_revisions"][field_key] = current_revision + 1
|
|
dirty_languages = set(metadata["dirty_text_languages"])
|
|
dirty_languages.add(language)
|
|
metadata["dirty_text_languages"] = sorted(dirty_languages)
|
|
return {"status": "saved", "revision": metadata["field_revisions"][field_key]}
|
|
|
|
|
|
def apply_simple_field_change(metadata: dict, field_name: str, payload: dict) -> dict:
|
|
expected_revision = int(payload["base_revision"])
|
|
current_revision = int(metadata["field_revisions"][field_name])
|
|
if expected_revision != current_revision:
|
|
return {"status": "conflict", "reason": f"{field_name} was changed by someone else."}
|
|
|
|
new_value = payload["value"]
|
|
if new_value == metadata[field_name]:
|
|
return {"status": "unchanged"}
|
|
|
|
metadata[field_name] = new_value
|
|
metadata["field_revisions"][field_name] = current_revision + 1
|
|
return {"status": "saved", "revision": metadata["field_revisions"][field_name]}
|
|
|
|
|
|
def apply_title_change(post_dir: Path, metadata: dict, payload: dict) -> tuple[dict, Path]:
|
|
expected_revision = int(payload["base_revision"])
|
|
current_revision = int(metadata["field_revisions"]["title"])
|
|
if expected_revision != current_revision:
|
|
return {"status": "conflict", "reason": "title was changed by someone else."}, post_dir
|
|
|
|
new_title = str(payload["value"]).strip()
|
|
if new_title == "":
|
|
return {"status": "rejected", "reason": "Title cannot be empty."}, post_dir
|
|
if new_title == metadata["title"]:
|
|
return {"status": "unchanged"}, post_dir
|
|
|
|
metadata["title"] = new_title
|
|
metadata["slug"] = slugify(new_title)
|
|
metadata["field_revisions"]["title"] = current_revision + 1
|
|
|
|
target_directory_name = metadata["slug"]
|
|
target_post_dir = post_dir.parent / target_directory_name
|
|
if target_post_dir != post_dir:
|
|
if target_post_dir.exists():
|
|
return {"status": "rejected", "reason": f"Directory already exists: {target_directory_name}"}, post_dir
|
|
post_dir.rename(target_post_dir)
|
|
return {"status": "saved", "revision": metadata["field_revisions"]["title"]}, target_post_dir
|
|
|
|
return {"status": "saved", "revision": metadata["field_revisions"]["title"]}, post_dir
|
|
|
|
|
|
def apply_language_order_change(metadata: dict, payload: dict) -> dict:
|
|
values = list(payload["value"])
|
|
if sorted(values) != sorted(LANGUAGE_ORDER_DEFAULT):
|
|
return {"status": "rejected", "reason": "Language order must contain fi, sv and en exactly once."}
|
|
return apply_simple_field_change(metadata, "language_order", {"base_revision": payload["base_revision"], "value": values})
|
|
|
|
|
|
def apply_discussion_pinning_mode_change(metadata: dict, payload: dict) -> dict:
|
|
value = str(payload["value"]).strip().lower()
|
|
if value not in {"none", "first", "last", "all"}:
|
|
return {"status": "rejected", "reason": "Pinning mode must be none, first, last, or all."}
|
|
result = apply_simple_field_change(
|
|
metadata,
|
|
"discussion_pinning_mode",
|
|
{"base_revision": payload["base_revision"], "value": value},
|
|
)
|
|
if result.get("status") == "saved" and metadata["telegram_binding"] is not None:
|
|
metadata["dirty_discussion_pinning"] = True
|
|
return result
|
|
|
|
|
|
def apply_delete_pin_service_messages_change(metadata: dict, payload: dict) -> dict:
|
|
result = apply_simple_field_change(
|
|
metadata,
|
|
"delete_pin_service_messages",
|
|
{
|
|
"base_revision": payload["base_revision"],
|
|
"value": bool(payload["value"]),
|
|
},
|
|
)
|
|
if result.get("status") == "saved" and metadata["telegram_binding"] is not None:
|
|
metadata["dirty_discussion_pinning"] = True
|
|
return result
|
|
|
|
|
|
def set_schedule_draft_for(metadata: dict, value: str | None) -> bool:
|
|
normalized = normalize_optional_string(value)
|
|
if normalized == metadata["schedule_draft_for"]:
|
|
return False
|
|
metadata["schedule_draft_for"] = normalized
|
|
metadata["field_revisions"]["schedule_draft_for"] = int(metadata["field_revisions"]["schedule_draft_for"]) + 1
|
|
return True
|
|
|
|
|
|
def apply_schedule_draft_for_change(metadata: dict, payload: dict) -> dict:
|
|
expected_revision = int(payload["base_revision"])
|
|
current_revision = int(metadata["field_revisions"]["schedule_draft_for"])
|
|
if expected_revision != current_revision:
|
|
return {"status": "conflict", "reason": "Schedule draft time was changed by someone else."}
|
|
|
|
new_value = normalize_optional_string(payload["value"])
|
|
if new_value == metadata["schedule_draft_for"]:
|
|
return {"status": "unchanged"}
|
|
|
|
set_schedule_draft_for(metadata, new_value)
|
|
return {"status": "saved", "revision": metadata["field_revisions"]["schedule_draft_for"]}
|
|
|
|
|
|
def apply_enabled_languages_change(metadata: dict, payload: dict) -> dict:
|
|
values = {language: bool(enabled) for language, enabled in payload["value"].items()}
|
|
merged = {**metadata["enabled_languages"], **values}
|
|
return apply_simple_field_change(
|
|
metadata,
|
|
"enabled_languages",
|
|
{"base_revision": payload["base_revision"], "value": merged},
|
|
)
|
|
|
|
|
|
def apply_disable_link_previews_change(metadata: dict, payload: dict) -> dict:
|
|
expected_revision = int(payload["base_revision"])
|
|
current_revision = int(metadata["field_revisions"]["disable_link_previews"])
|
|
if expected_revision != current_revision:
|
|
return {"status": "conflict", "reason": "Link preview settings were changed by someone else."}
|
|
|
|
current_value = metadata["disable_link_previews"]
|
|
values = normalize_disable_link_previews(payload["value"])
|
|
if values == current_value:
|
|
return {"status": "unchanged"}
|
|
|
|
metadata["disable_link_previews"] = values
|
|
metadata["field_revisions"]["disable_link_previews"] = current_revision + 1
|
|
if metadata["telegram_binding"] is not None:
|
|
dirty_languages = set(metadata["dirty_text_languages"])
|
|
for language in LANGUAGE_FIELD_KEYS:
|
|
if current_value.get(language) != values.get(language):
|
|
dirty_languages.add(language)
|
|
metadata["dirty_text_languages"] = sorted(dirty_languages)
|
|
return {"status": "saved", "revision": metadata["field_revisions"]["disable_link_previews"]}
|
|
|
|
|
|
def apply_image_change(post_dir: Path, metadata: dict, scope: str, payload: dict) -> dict:
|
|
field_key = f"{scope}.images"
|
|
expected_revision = int(payload["base_revision"])
|
|
current_revision = int(metadata["field_revisions"][field_key])
|
|
if expected_revision != current_revision:
|
|
return {"status": "conflict", "reason": "Images were changed by someone else."}
|
|
|
|
requested_names = [str(name) for name in payload["value"]]
|
|
current_names = list(metadata["image_lists"].get(scope, []))
|
|
if requested_names == current_names:
|
|
return {"status": "unchanged"}
|
|
|
|
canonical_names = canonicalize_image_scope(post_dir, scope, requested_names)
|
|
metadata["image_lists"][scope] = canonical_names
|
|
metadata["field_revisions"][field_key] = current_revision + 1
|
|
return {"status": "saved", "revision": metadata["field_revisions"][field_key], "value": canonical_names}
|
|
|
|
|
|
def canonicalize_image_scope(post_dir: Path, scope: str, requested_names: list[str]) -> list[str]:
|
|
prefix = IMAGE_SCOPE_PREFIXES[scope]
|
|
reserved_names: list[tuple[Path, Path]] = []
|
|
final_names: list[str] = []
|
|
|
|
for index, name in enumerate(requested_names, start=1):
|
|
source_path = post_dir / name
|
|
if not source_path.exists():
|
|
raise FileNotFoundError(f"Image not found: {name}")
|
|
extension = source_path.suffix.lower()
|
|
target_name = f"{prefix}{index}{extension}"
|
|
temp_target = post_dir / f"_rename_{uuid.uuid4().hex}{extension}"
|
|
source_path.rename(temp_target)
|
|
reserved_names.append((temp_target, post_dir / target_name))
|
|
final_names.append(target_name)
|
|
|
|
for temp_path, target_path in reserved_names:
|
|
if target_path.exists():
|
|
target_path.unlink()
|
|
temp_path.rename(target_path)
|
|
|
|
existing_scope_files = [
|
|
path
|
|
for path in post_dir.iterdir()
|
|
if path.is_file() and path.name.startswith(prefix) and path.suffix.lower().lstrip(".") in KNOWN_IMAGE_EXTENSIONS
|
|
]
|
|
keep_names = set(final_names)
|
|
for path in existing_scope_files:
|
|
if path.name not in keep_names:
|
|
path.unlink()
|
|
|
|
return final_names
|
|
|
|
|
|
def transition_status_after_save(metadata: dict) -> None:
|
|
status = metadata["status"]
|
|
dirty = bool(metadata["dirty_text_languages"])
|
|
if metadata.get("dirty_discussion_pinning", False):
|
|
dirty = True
|
|
|
|
if status == "published" and dirty:
|
|
metadata["status"] = "modified"
|
|
elif status == "modified":
|
|
metadata["status"] = "modified"
|
|
elif status == "failed":
|
|
metadata["status"] = "draft"
|
|
elif status == "scheduled":
|
|
metadata["status"] = "scheduled"
|
|
|
|
|
|
def serialize_binding(binding: PublishBinding) -> dict:
|
|
return {
|
|
"global_message_ids": binding.global_message_ids,
|
|
"global_attached_to_language": binding.global_attached_to_language.value if binding.global_attached_to_language else None,
|
|
"global_primary_message_id": binding.global_primary_message_id,
|
|
"global_discussion_message_id": binding.global_discussion_message_id,
|
|
"languages": {
|
|
language.value: {
|
|
"language": language.value,
|
|
"image_message_ids": language_binding.image_message_ids,
|
|
"text_segments": [
|
|
{"message_id": segment.message_id, "mode": segment.mode}
|
|
for segment in language_binding.text_segments
|
|
],
|
|
"primary_message_id": language_binding.primary_message_id,
|
|
"primary_message_url": language_binding.primary_message_url,
|
|
"discussion_message_id": language_binding.discussion_message_id,
|
|
}
|
|
for language, language_binding in binding.languages.items()
|
|
},
|
|
"discussion_message_ids": {str(key): value for key, value in binding.discussion_message_ids.items()},
|
|
}
|
|
|
|
|
|
def deserialize_binding(payload: dict) -> PublishBinding:
|
|
languages: dict[Language, LanguageBinding] = {}
|
|
for language_key, language_payload in payload.get("languages", {}).items():
|
|
language = Language(language_key)
|
|
languages[language] = LanguageBinding(
|
|
language=language,
|
|
image_message_ids=[int(item) for item in language_payload.get("image_message_ids", [])],
|
|
text_segments=[
|
|
TextSegmentBinding(message_id=int(segment["message_id"]), mode=str(segment["mode"]))
|
|
for segment in language_payload.get("text_segments", [])
|
|
],
|
|
primary_message_id=(
|
|
int(language_payload["primary_message_id"])
|
|
if str(language_payload.get("primary_message_id", "")).lstrip("-").isdigit()
|
|
else None
|
|
),
|
|
primary_message_url=str(language_payload["primary_message_url"]),
|
|
discussion_message_id=(
|
|
int(language_payload["discussion_message_id"])
|
|
if str(language_payload.get("discussion_message_id", "")).lstrip("-").isdigit()
|
|
else None
|
|
),
|
|
)
|
|
|
|
attached_language = payload.get("global_attached_to_language")
|
|
return PublishBinding(
|
|
global_message_ids=[int(item) for item in payload.get("global_message_ids", [])],
|
|
global_attached_to_language=Language(attached_language) if attached_language else None,
|
|
languages=languages,
|
|
discussion_message_ids={
|
|
int(key): int(value)
|
|
for key, value in payload.get("discussion_message_ids", {}).items()
|
|
if str(key).lstrip("-").isdigit() and str(value).lstrip("-").isdigit()
|
|
},
|
|
global_primary_message_id=(
|
|
int(payload["global_primary_message_id"])
|
|
if str(payload.get("global_primary_message_id", "")).lstrip("-").isdigit()
|
|
else None
|
|
),
|
|
global_discussion_message_id=(
|
|
int(payload["global_discussion_message_id"])
|
|
if str(payload.get("global_discussion_message_id", "")).lstrip("-").isdigit()
|
|
else None
|
|
),
|
|
)
|
|
|
|
|
|
def slugify(title: str) -> str:
|
|
normalized = unicodedata.normalize("NFKD", title.strip())
|
|
ascii_only = normalized.encode("ascii", "ignore").decode("ascii")
|
|
lowered = ascii_only.lower()
|
|
slug = re.sub(r"[^a-z0-9]+", "-", lowered)
|
|
slug = slug.strip("-")
|
|
return slug or "announcement"
|
|
|
|
|
|
def deslugify_directory_name(directory_name: str) -> str:
|
|
directory_name = re.sub(r"^\d+-", "", directory_name)
|
|
cleaned = re.sub(r"[-_]+", " ", directory_name).strip()
|
|
cleaned = re.sub(r"\s+", " ", cleaned)
|
|
if not cleaned:
|
|
return "Announcement"
|
|
return cleaned[:1].upper() + cleaned[1:]
|
|
|
|
|
|
def now_iso() -> str:
|
|
return datetime.now().astimezone().isoformat(timespec="seconds")
|
|
|
|
|
|
def append_status_output(existing_output: str, new_output: str, result: str, attempted_at: str) -> str:
|
|
clean_output = str(new_output or "").strip()
|
|
if not clean_output:
|
|
return str(existing_output or "")
|
|
|
|
header = f"[{attempted_at}] {result}"
|
|
entry = f"{header}\n{clean_output}"
|
|
previous = str(existing_output or "").strip()
|
|
if not previous:
|
|
return entry
|
|
return f"{previous}\n\n{entry}"
|
|
|
|
|
|
def normalize_saved_text(value: str) -> str:
|
|
lines = value.splitlines(keepends=True)
|
|
if not lines:
|
|
return value.rstrip()
|
|
return "".join(_rstrip_line(line) for line in lines).rstrip()
|
|
|
|
|
|
def _rstrip_line(line: str) -> str:
|
|
if line.endswith("\r\n"):
|
|
return line[:-2].rstrip() + "\r\n"
|
|
if line.endswith("\n"):
|
|
return line[:-1].rstrip() + "\n"
|
|
if line.endswith("\r"):
|
|
return line[:-1].rstrip() + "\r"
|
|
return line.rstrip()
|
|
|
|
|
|
def merge_metadata_with_defaults(post_dir: Path, raw_metadata: object) -> dict:
|
|
title = derive_title_from_metadata(post_dir, raw_metadata)
|
|
slug = derive_slug_from_metadata(title, raw_metadata)
|
|
metadata = default_metadata(slug, title, sort_index=0)
|
|
source = raw_metadata if isinstance(raw_metadata, dict) else {}
|
|
|
|
metadata["schema_version"] = METADATA_SCHEMA_VERSION
|
|
metadata["sort_index"] = normalize_sort_index(source.get("sort_index"))
|
|
metadata["language_order"] = normalize_language_order(source.get("language_order"))
|
|
metadata["discussion_pinning_mode"] = normalize_discussion_pinning_mode(source.get("discussion_pinning_mode"))
|
|
metadata["delete_pin_service_messages"] = bool(source.get("delete_pin_service_messages", True))
|
|
metadata["telegram_bot_id"] = normalize_string(source.get("telegram_bot_id")).strip()
|
|
metadata["telegram_chat_id"] = normalize_string(source.get("telegram_chat_id")).strip()
|
|
metadata["enabled_languages"] = normalize_enabled_languages(source.get("enabled_languages"))
|
|
metadata["disable_link_previews"] = normalize_disable_link_previews(source.get("disable_link_previews"))
|
|
metadata["status"] = normalize_status(source.get("status"))
|
|
metadata["scheduled_for"] = normalize_optional_string(source.get("scheduled_for"))
|
|
metadata["scheduled_next_attempt_at"] = normalize_optional_string(source.get("scheduled_next_attempt_at"))
|
|
metadata["scheduled_retry_count"] = normalize_nonnegative_int(source.get("scheduled_retry_count"))
|
|
metadata["schedule_draft_for"] = normalize_optional_string(source.get("schedule_draft_for"))
|
|
metadata["scheduled_unpin_for"] = normalize_optional_string(source.get("scheduled_unpin_for"))
|
|
metadata["last_attempt_at"] = normalize_optional_string(source.get("last_attempt_at"))
|
|
metadata["last_output"] = normalize_string(source.get("last_output"))
|
|
metadata["last_result"] = normalize_optional_string(source.get("last_result"))
|
|
metadata["telegram_published_at"] = normalize_optional_string(source.get("telegram_published_at"))
|
|
metadata["telegram_binding"] = source.get("telegram_binding") if isinstance(source.get("telegram_binding"), dict) else None
|
|
metadata["dirty_text_languages"] = normalize_dirty_languages(source.get("dirty_text_languages"))
|
|
metadata["dirty_discussion_pinning"] = bool(source.get("dirty_discussion_pinning", False))
|
|
metadata["field_revisions"] = normalize_field_revisions(source.get("field_revisions"))
|
|
metadata["image_lists"] = normalize_image_lists(source.get("image_lists"), post_dir)
|
|
metadata["created_at"] = normalize_timestamp(source.get("created_at"), metadata["created_at"])
|
|
metadata["updated_at"] = normalize_timestamp(source.get("updated_at"), metadata["updated_at"])
|
|
return metadata
|
|
|
|
|
|
def move_broadcast(posts_dir: Path, directory_name: str, direction: str) -> None:
|
|
ordered_items = list_broadcasts(posts_dir)
|
|
index_by_directory = {item["directory"]: index for index, item in enumerate(ordered_items)}
|
|
current_index = index_by_directory.get(directory_name)
|
|
if current_index is None:
|
|
raise FileNotFoundError(directory_name)
|
|
|
|
if direction == "up":
|
|
target_index = current_index - 1
|
|
elif direction == "down":
|
|
target_index = current_index + 1
|
|
else:
|
|
raise ValueError(f"Unsupported direction: {direction}")
|
|
|
|
if target_index < 0 or target_index >= len(ordered_items):
|
|
return
|
|
|
|
ordered_items[current_index], ordered_items[target_index] = ordered_items[target_index], ordered_items[current_index]
|
|
for sort_index, item in enumerate(ordered_items):
|
|
post_dir = posts_dir / item["directory"]
|
|
metadata = load_metadata(post_dir)
|
|
if metadata["sort_index"] == sort_index:
|
|
continue
|
|
metadata["sort_index"] = sort_index
|
|
save_metadata(post_dir, metadata)
|
|
|
|
|
|
def derive_title_from_metadata(post_dir: Path, raw_metadata: object) -> str:
|
|
if isinstance(raw_metadata, dict):
|
|
title = str(raw_metadata.get("title", "")).strip()
|
|
if title:
|
|
return title
|
|
return deslugify_directory_name(post_dir.name)
|
|
|
|
|
|
def derive_slug_from_metadata(title: str, raw_metadata: object) -> str:
|
|
if isinstance(raw_metadata, dict):
|
|
slug = str(raw_metadata.get("slug", "")).strip()
|
|
if slug:
|
|
return slugify(slug)
|
|
return slugify(title)
|
|
|
|
|
|
def normalize_language_order(value: object) -> list[str]:
|
|
if isinstance(value, list):
|
|
items = [str(item) for item in value]
|
|
if sorted(items) == sorted(LANGUAGE_ORDER_DEFAULT):
|
|
return items
|
|
return list(LANGUAGE_ORDER_DEFAULT)
|
|
|
|
|
|
def normalize_discussion_pinning_mode(value: object) -> str:
|
|
text = str(value).strip().lower()
|
|
return text if text in {"none", "first", "last", "all"} else "first"
|
|
|
|
|
|
def normalize_enabled_languages(value: object) -> dict[str, bool]:
|
|
source = value if isinstance(value, dict) else {}
|
|
return {language: bool(source.get(language, True)) for language in LANGUAGE_FIELD_KEYS}
|
|
|
|
|
|
def normalize_disable_link_previews(value: object) -> dict[str, bool]:
|
|
source = value if isinstance(value, dict) else {}
|
|
return {language: bool(source.get(language, True)) for language in LANGUAGE_FIELD_KEYS}
|
|
|
|
|
|
def normalize_status(value: object) -> str:
|
|
allowed = {"draft", "scheduled", "published", "modified", "failed"}
|
|
text = str(value).strip()
|
|
return text if text in allowed else "draft"
|
|
|
|
|
|
def normalize_optional_string(value: object) -> str | None:
|
|
if value is None:
|
|
return None
|
|
text = str(value).strip()
|
|
return text or None
|
|
|
|
|
|
def normalize_nonnegative_int(value: object) -> int:
|
|
if isinstance(value, bool):
|
|
return 0
|
|
try:
|
|
number = int(value)
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
return max(number, 0)
|
|
|
|
|
|
def normalize_string(value: object) -> str:
|
|
if value is None:
|
|
return ""
|
|
return str(value)
|
|
|
|
|
|
def normalize_dirty_languages(value: object) -> list[str]:
|
|
if not isinstance(value, list):
|
|
return []
|
|
return sorted({str(item) for item in value if str(item) in LANGUAGE_FIELD_KEYS})
|
|
|
|
|
|
def normalize_sort_index(value: object) -> int:
|
|
if isinstance(value, int):
|
|
return value
|
|
if isinstance(value, str) and value.lstrip("-").isdigit():
|
|
return int(value)
|
|
return 0
|
|
|
|
|
|
def new_broadcast_sort_index(posts_dir: Path) -> int:
|
|
lowest = 0
|
|
for post_dir in posts_dir.iterdir():
|
|
if not post_dir.is_dir():
|
|
continue
|
|
metadata = load_metadata(post_dir)
|
|
lowest = min(lowest, metadata["sort_index"])
|
|
return lowest - 1
|
|
|
|
|
|
def telegram_delete_deadline(metadata: dict) -> datetime | None:
|
|
raw_value = metadata.get("telegram_published_at")
|
|
if not raw_value:
|
|
return None
|
|
return datetime.fromisoformat(raw_value) + TELEGRAM_DELETE_WINDOW
|
|
|
|
|
|
def is_telegram_delete_expired(metadata: dict, now: datetime | None = None) -> bool:
|
|
deadline = telegram_delete_deadline(metadata)
|
|
if deadline is None:
|
|
return False
|
|
current_time = now or datetime.now(deadline.tzinfo)
|
|
return current_time >= deadline
|
|
|
|
|
|
def normalize_field_revisions(value: object) -> dict[str, int]:
|
|
source = value if isinstance(value, dict) else {}
|
|
revisions = default_field_revisions()
|
|
for key in revisions:
|
|
try:
|
|
revisions[key] = int(source.get(key, revisions[key]))
|
|
except (TypeError, ValueError):
|
|
revisions[key] = revisions[key]
|
|
return revisions
|
|
|
|
|
|
def normalize_image_lists(value: object, post_dir: Path) -> dict[str, list[str]]:
|
|
disk_lists = build_image_lists_from_disk(post_dir)
|
|
if not isinstance(value, dict):
|
|
return disk_lists
|
|
|
|
result: dict[str, list[str]] = {"global": [], **{language: [] for language in LANGUAGE_FIELD_KEYS}}
|
|
available = {scope: set(names) for scope, names in disk_lists.items()}
|
|
for scope in result:
|
|
raw_items = value.get(scope)
|
|
if isinstance(raw_items, list):
|
|
filtered = [str(name) for name in raw_items if str(name) in available[scope]]
|
|
missing = [name for name in disk_lists[scope] if name not in filtered]
|
|
result[scope] = filtered + missing
|
|
else:
|
|
result[scope] = disk_lists[scope]
|
|
return result
|
|
|
|
|
|
def normalize_timestamp(value: object, fallback: str) -> str:
|
|
text = normalize_optional_string(value)
|
|
return text or fallback
|