diff --git a/.gitea/workflows/build-images.yaml b/.gitea/workflows/build-images.yaml index 02da27d..be3799e 100644 --- a/.gitea/workflows/build-images.yaml +++ b/.gitea/workflows/build-images.yaml @@ -82,6 +82,23 @@ jobs: PYTHON_VERSION=3 fingerprint_command: | { dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; } + - name: linkki-tiedotus + context: linkki-tiedotus + base_image: python:3.12-slim + primary_tag: git.ajpanton.se/ajp_anton/linkki-tiedotus:1 + oci_labels: | + org.opencontainers.image.title=Linkki Telegram Broadcast Editor + org.opencontainers.image.description=Web editor and scheduler for multilingual Telegram channel broadcasts. + org.opencontainers.image.documentation=https://git.ajpanton.se/ajp_anton/docker-images#linkki-tiedotus + org.opencontainers.image.source=https://git.ajpanton.se/ajp_anton/docker-images + org.opencontainers.image.url=https://git.ajpanton.se/ajp_anton/-/packages/container/linkki-tiedotus + tags: | + git.ajpanton.se/ajp_anton/linkki-tiedotus:1 + git.ajpanton.se/ajp_anton/linkki-tiedotus:latest + build_args: | + PYTHON_VERSION=3.12-slim + fingerprint_command: | + { dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; } steps: - name: Check out repository diff --git a/README.md b/README.md index 8e02f37..e8a9d48 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ a few commonly useful database/runtime extensions. | `git.ajpanton.se/ajp_anton/postgres-custom:18` | PostgreSQL 18 with selected extension packages installed and `pg_cron`/VectorChord preloaded. | | `git.ajpanton.se/ajp_anton/php8-pgsql:8` | PHP 8 FPM on Alpine with PostgreSQL client runtime support and the `pdo_pgsql` PHP extension enabled. | | `git.ajpanton.se/ajp_anton/python-tools:3` | Python 3 task runner with ExifTool and a web control panel for mounted task scripts. | +| `git.ajpanton.se/ajp_anton/linkki-tiedotus:1` | Web editor and scheduler for multilingual Telegram channel broadcasts. | ## Included Components @@ -99,6 +100,25 @@ Intended use: application documentation are in `python-tools/`. - Keep host systems clean by avoiding host-level package installs. +### `linkki-tiedotus` + +Base image: + +- `python:3.12-slim` +- Moving upstream Python 3.12 patch-version tag. Base-image updates are picked + up on the next rebuild. + +Included application: + +- FastAPI web editor for drafting, scheduling, publishing, updating, and + deleting multilingual Telegram channel broadcasts. +- A persistent `/data` directory for configuration, user records, posts, + uploaded images, schedules, and publish history. +- The default command starts the application on port `8080`. + +The image contains no configuration or broadcast data. Mount `/data` from a +persistent host directory and create its configuration from `linkki-tiedotus/config-examples/`. + ## Tags Moving deployment tags: @@ -106,6 +126,7 @@ Moving deployment tags: - `git.ajpanton.se/ajp_anton/postgres-custom:18` - `git.ajpanton.se/ajp_anton/php8-pgsql:8` - `git.ajpanton.se/ajp_anton/python-tools:3` +- `git.ajpanton.se/ajp_anton/linkki-tiedotus:1` Descriptive tags: @@ -117,6 +138,7 @@ Convenience tags: - `git.ajpanton.se/ajp_anton/postgres-custom:latest` - `git.ajpanton.se/ajp_anton/php8-pgsql:latest` - `git.ajpanton.se/ajp_anton/python-tools:latest` +- `git.ajpanton.se/ajp_anton/linkki-tiedotus:latest` Tags are mutable. A weekly candidate build only repoints a deployment tag when its content fingerprint changed: base image, build context, build arguments, diff --git a/linkki-tiedotus/.dockerignore b/linkki-tiedotus/.dockerignore new file mode 100644 index 0000000..4eeced5 --- /dev/null +++ b/linkki-tiedotus/.dockerignore @@ -0,0 +1,12 @@ +.git +__pycache__ +*.py[cod] +.pytest_cache +.venv +.tmp +data +tmp_test_runs +testposts +.codex +.vscode +telegram_config.json diff --git a/linkki-tiedotus/CODEX_HANDOFF.md b/linkki-tiedotus/CODEX_HANDOFF.md new file mode 100644 index 0000000..68d9b0c --- /dev/null +++ b/linkki-tiedotus/CODEX_HANDOFF.md @@ -0,0 +1,112 @@ +# Linkki Handoff Brief + +## Purpose + +This directory is the extracted Linkki Telegram broadcast editor. The active product is a FastAPI web app in a Docker container. Users draft Finnish, Swedish, and English channel broadcasts, preview Telegram formatting, schedule publishing, update published text, map channel messages to linked discussion-group messages, manage pinning, and delete or unlink Telegram posts. + +The source is intended to be absorbed into a larger project. Read `README.md` for setup and operation; this document is implementation context for the next coding agent. + +## Important entry points + +| Path | Role | +| --- | --- | +| `webapp_main.py` | Container and local web-server entry point. | +| `webapp/app.py` | FastAPI routes, async scheduler, publish/update/delete orchestration. | +| `webapp/storage.py` | On-disk post schema, optimistic field revisions, state transitions, title/directory renames. | +| `webapp/static/app.js` | Stateful editor client. Large single IIFE; also handles editor-only sidebar navigation without a full page load. | +| `webapp/static/app.css` | Existing desktop-first editor/sidebar layout. | +| `webapp/templates/` | Base shell, editor, login, and instructions markup. | +| `webapp/translations/catalog.json` | All visible UI strings in Finnish, Swedish, and English. | +| `linkki_poster/` | Telegram API wrapper, formatting parser, directory scanning, publishing, updates, deletes, and pinning. | +| `tests/` | Pytest suite. | + +## Data model + +Runtime data is outside the source tree under `LINKKI_DATA_DIR` (`/data` in Docker): + +```text +config/ + app_config.json + users.json + telegram_config.json +posts/ + slug/ + post.json + Suomi.md + Svenska.md + English.md + images named Kuva*, Suomi*, Svenska*, English* +``` + +`post.json` is canonical metadata. `webapp.storage.load_metadata()` merges it with current defaults and normalizes fields. Current schema is `METADATA_SCHEMA_VERSION = 3`. Scheduled publishes retain a retry count and next-attempt timestamp; they retry after 1, 5, 15, and then 30 minutes until success. A one-shot migration previously normalized production posts, and old binding-format fallback support was deliberately removed afterward. Do not casually reintroduce old schema handling; if a new schema change is needed, use a deliberate migration. + +Every editable persistent field has a revision in `metadata.field_revisions`. Browser saves include a base revision; storage returns a conflict if another editor changed the field. When adding a metadata field, update all of: + +- `default_metadata()` +- `default_field_revisions()` when the field is independently editable +- `merge_metadata_with_defaults()` +- the browser editable snapshot and save payload in `app.js` +- storage tests + +The title is also the directory slug. `apply_title_change()` renames the directory after checking the target path does not exist. + +## Publish state and Telegram bindings + +The central state values are `draft`, `scheduled`, `published`, `modified`, and `failed`. + +- A channel broadcast stores target selection as `telegram_bot_id` and `telegram_chat_id`. +- `telegram_binding` stores channel message IDs and corresponding discussion-group message IDs. +- Linked broadcasts lock target/language/image setup, but title and text remain editable. +- Editing a linked text marks `dirty_text_languages`; editing pinning marks `dirty_discussion_pinning`. `perform_publish_action()` sends the required Telegram update. +- A successful scheduled unpin clears its schedule and sets pin mode to `none`. + +Keep channel-broadcast bindings (`PublishBinding`, `LanguageBinding`, `TextSegmentBinding`) separate from any future direct-message or group-management feature. The current binding model assumes channel posts with optional discussion messages. + +Discussion mapping is a Telegram limitation, not merely a UI concern. The bot must receive fresh automatic-forward updates from the linked discussion group. Privacy mode/admin visibility and competing `getUpdates` consumers matter. The automatic historical repair path was removed because it could only help while relevant updates were still arriving. Manual channel/group ID mapping is the intended recovery path. + +`Delete from Telegram` deletes discussion messages first, retains partial bindings after failures, then deletes channel messages. Telegram may reject bot deletions after about 48 hours. Pin-service-message deletion is separate from pinning and defaults to enabled for new broadcasts. + +## Frontend notes + +`app.js` owns a `state` object containing the current and last-saved broadcast, permissions, operation polling, sidebar data, and active tab. Re-rendering rebuilds language panels and Telegram mapping cells. Existing helpers keep textarea and preview heights synchronized. + +Sidebar navigation between editor pages is deliberately partial: + +1. Sidebar announcement clicks are intercepted. +2. The client fetches `GET /api/announcements/{directory}`. +3. It replaces editor state, re-renders, and calls `history.pushState`. +4. Browser back/forward uses `popstate` and refetches editor state. + +Do not turn ordinary sidebar forms (creation, reordering, sign-out, language change) into partial navigation without designing their return/state behavior. The base template still owns sidebar scroll persistence. + +The Telegram link tab uses an aligned table. A group link cannot exist without its channel link. The Pinning tab is disabled when the chosen channel target lacks a discussion group. Preserve those constraints in both UI and backend routes. + +Text formatting is conservative by design. Inline styles are parsed/re-serialized to avoid crossing tags. The parser in `linkki_poster/formatting.py` is authoritative. Text-node HTML escaping intentionally uses `quote=False`: escaping apostrophes as `'` caused Telegram heading rendering bugs. + +## Permissions and security + +Permissions are in `webapp/auth.py` and normalized into `users.json`. New actions need both backend enforcement and corresponding browser disabled state. + +Never commit real Telegram tokens, user data, password hashes, or `/data` contents. + +## Operations and testing + +The FastAPI scheduler runs in-process and checks persisted schedules every `scheduler_poll_seconds`. Its first pass posts every overdue scheduled broadcast immediately after startup, then follows the persisted retry delay. In-memory operation progress (`app.state.operations`) disappears on container restart; the persisted logs and schedules remain. + +Run: + +```text +python -m pip install -r requirements-dev.txt +python -m pytest -q +python -m compileall webapp linkki_poster tests +node --check webapp/static/app.js +``` + +Use a virtual environment if the host Python is externally managed. + +## Non-goals and recent decisions + +- The application only supports channel broadcasts today. +- A proposed direct/mass-message feature was not implemented because Telegram Bot API bots cannot reliably resolve arbitrary private-user `@usernames` to chat IDs or initiate chats with them. Treat any future direct-message feature as a separate design with numeric chat IDs or a local known-contact store. +- A proposed group-creation feature was also deferred; Bot API does not provide general create-group/add-arbitrary-user-by-username behavior. +- The root standalone CLI scripts (`post_broadcast.py`, `main.py`, and `manage_broadcast.py`) remain as legacy/manual tools. They are not part of the container workflow. diff --git a/linkki-tiedotus/Dockerfile b/linkki-tiedotus/Dockerfile new file mode 100644 index 0000000..13f4926 --- /dev/null +++ b/linkki-tiedotus/Dockerfile @@ -0,0 +1,22 @@ +ARG PYTHON_VERSION=3.12-slim +FROM python:${PYTHON_VERSION} + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV LINKKI_DATA_DIR=/data + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY webapp_main.py ./ +COPY webapp ./webapp +COPY linkki_poster ./linkki_poster + +RUN mkdir -p /data/config /data/posts + +EXPOSE 8080 +VOLUME ["/data"] + +CMD ["python", "webapp_main.py"] diff --git a/linkki-tiedotus/README.md b/linkki-tiedotus/README.md new file mode 100644 index 0000000..c38e916 --- /dev/null +++ b/linkki-tiedotus/README.md @@ -0,0 +1,185 @@ +# Linkki Telegram Broadcast Editor + +Linkki is a Docker-hosted web application for drafting, scheduling, publishing, updating, and deleting multilingual Telegram channel broadcasts. It supports Finnish, Swedish, and English, using a Telegram-oriented Markdown dialect with a live preview. + +The app stores its configuration and broadcasts outside the source tree, so container rebuilds do not remove published-message mappings, images, or drafts. + +For codebase and maintenance context, see [CODEX_HANDOFF.md](CODEX_HANDOFF.md). + +## Run locally + +Requires Python 3.12 or newer. + +```text +python -m pip install -r requirements.txt +python webapp_main.py +``` + +Open `http://localhost:8080`. + +The data directory comes from `LINKKI_DATA_DIR`; without it, the app uses `data/` beside the source tree. + +```text +data/ + config/ + app_config.json + users.json + telegram_config.json + posts/ + my-broadcast/ + post.json + Suomi.md + Svenska.md + English.md + Kuva1.jpg + Suomi1.jpg + Svenska1.jpg + English1.jpg +``` + +Copy and adapt the files under `config-examples/`. Do not commit real tokens, password hashes, or production data. + +## Configuration + +`data/config/app_config.json`: + +```json +{ + "timezone": "Europe/Helsinki", + "session_secret": "replace-with-a-long-random-secret", + "scheduler_poll_seconds": 60, + "users_file": "users.json" +} +``` + +`session_secret` signs login session cookies. Changing it logs out every active user. + +`data/config/telegram_config.json` supports multiple bots and channel targets: + +```json +{ + "bots": [ + { + "display_name": "Main bot", + "token": "123456:ABCDEF...", + "default": true, + "channels": [ + { + "display_name": "Main channel", + "channel_id": "-1001234567890", + "discussion_group_id": "-1001234567891", + "channel_username": "my_public_channel", + "default": true + } + ] + } + ] +} +``` + +`discussion_group_id` is optional. It enables discussion-message mapping and pinning. `channel_username` is optional; it is used for public Telegram message URLs, while private channels use `t.me/c/...` URLs derived from `channel_id`. + +`data/config/users.json` contains password hashes, UI language preference, and permissions. Generate a password hash with: + +```text +python -m webapp.auth +``` + +Permissions default to `false` when omitted. The available keys are: + +```text +edit_content +edit_targets +save_announcements +open_telegram +post_now +schedule +unschedule +unlink_telegram +delete_telegram +delete_storage +``` + +The app normalizes missing permission keys and saved UI-language preferences back into `users.json`. + +## Using the editor + +New broadcasts appear first in the sidebar. The editor has four settings tabs: + +- `Main`: title, selected bot/channel, language order, scheduling, images, and text editors. +- `Telegram link`: channel and discussion-group message mappings. Missing links can be entered manually as a message ID or a Telegram message URL. +- `Pinning`: discussion-group pin mode, service-message cleanup, and scheduled unpinning. +- `Logs`: accumulated publish, update, delete, and unpin output. + +The sidebar markers convey state: green is published, blue is modified after publish, yellow is scheduled, and red is failed. Incomplete Telegram links make the status marker red. A pin marker means linked discussion messages are pinned; it becomes yellow when an unpin is scheduled. + +The broadcast lifecycle is deliberately restrictive: + +- Drafts can change targets, language setup, images, and text. +- Scheduled broadcasts keep their selected bot/channel locked until unscheduled. +- Linked broadcasts keep targets, language setup, and images locked. The internal title, text, link-preview settings, and pinning settings remain editable. +- Published text changes become `modified` and are sent with `Post modifications now`. +- `Delete from Telegram` tries discussion-group messages before channel messages. It retains links for messages that could not be deleted. +- Telegram bots cannot delete messages after roughly 48 hours. The UI disables that action once the stored deadline has passed. + +Discussion-message mappings require the bot to see the discussion group's forwarded messages. In practice, the bot needs privacy mode disabled or sufficient admin visibility. Historic links missed at posting time cannot be recovered automatically; use the manual mapping fields. + +`Delete service messages` is enabled by default for new broadcasts. It requires Telegram deletion rights in the discussion group and may fail independently of pinning. + +## Text syntax + +The text editor supports Telegram-oriented Markdown and preview: + +~~~text +**bold** +_italic_ +++underline++ +~~strikethrough~~ +`inline code` +```code block``` +[label](https://example.com) +||spoiler|| +> quote +- bullet +1. numbered item +# heading +~~~ + +Cross-language links use `[label](fi)`, `[label](sv)`, or `[label](en)`. A backslash escapes formatting markers. The editor toolbar and `Ctrl/Cmd+B`, `I`, and `U` apply common inline styles. + +## Container image + +This repository publishes `git.ajpanton.se/ajp_anton/linkki-tiedotus:1` and the mutable +`latest` tag. The image serves port `8080`; put an authenticated reverse proxy +in front of it when needed. `compose.example.yaml` is a complete generic +service definition. The application data directory must be persistent and must +not be committed to source control. + +```yaml +services: + linkki-tiedotus: + image: git.ajpanton.se/ajp_anton/linkki-tiedotus:1 + ports: + - "8008:8080" + environment: + LINKKI_DATA_DIR: /data + volumes: + - /host/linkki-data:/data + restart: unless-stopped +``` + +Scheduled broadcasts are persisted in `/data`. If the container is offline +when one becomes due, it is posted on the scheduler's first pass after startup. +Failed scheduled posts retry after 1, 5, 15, and then every 30 minutes until +they succeed or are unscheduled. A manually started post is retried once +immediately before it is marked failed. + +## Legacy standalone CLI + +The web app is the maintained workflow. The repository also keeps the original one-directory poster for manual use and as a lower-level reference: + +```text +python post_broadcast.py path/to/broadcast +``` + +It expects a separate, single-bot `telegram_config.json` beside the script with `bot_token`, `chat_id`, and optional `channel_username`. `main.py` is a compatibility alias. `manage_broadcast.py` is an advanced helper for updating or deleting a previously linked broadcast. These scripts are not used by the container. diff --git a/linkki-tiedotus/compose.example.yaml b/linkki-tiedotus/compose.example.yaml new file mode 100644 index 0000000..244ec0f --- /dev/null +++ b/linkki-tiedotus/compose.example.yaml @@ -0,0 +1,10 @@ +services: + linkki-tiedotus: + image: git.ajpanton.se/ajp_anton/linkki-tiedotus:1 + environment: + LINKKI_DATA_DIR: /data + ports: + - "8080:8080" + volumes: + - ${LINKKI_DATA_DIR:?Set LINKKI_DATA_DIR to the persistent host directory}:/data + restart: unless-stopped diff --git a/linkki-tiedotus/config-examples/app_config.json b/linkki-tiedotus/config-examples/app_config.json new file mode 100644 index 0000000..9246080 --- /dev/null +++ b/linkki-tiedotus/config-examples/app_config.json @@ -0,0 +1,6 @@ +{ + "timezone": "UTC", + "session_secret": "replace-with-a-long-random-secret", + "scheduler_poll_seconds": 60, + "users_file": "users.json" +} diff --git a/linkki-tiedotus/config-examples/telegram_config.json b/linkki-tiedotus/config-examples/telegram_config.json new file mode 100644 index 0000000..5d33901 --- /dev/null +++ b/linkki-tiedotus/config-examples/telegram_config.json @@ -0,0 +1,18 @@ +{ + "bots": [ + { + "display_name": "Main bot", + "token": "123456:ABCDEF...", + "default": true, + "channels": [ + { + "display_name": "Main channel", + "channel_id": "-1001234567890", + "discussion_group_id": "-1001234567891", + "channel_username": "", + "default": true + } + ] + } + ] +} diff --git a/linkki-tiedotus/config-examples/users.json b/linkki-tiedotus/config-examples/users.json new file mode 100644 index 0000000..f7e072e --- /dev/null +++ b/linkki-tiedotus/config-examples/users.json @@ -0,0 +1,21 @@ +{ + "users": [ + { + "username": "admin", + "password_hash": "output of auth.py", + "language": "auto", + "permissions": { + "edit_content": false, + "edit_targets": false, + "save_announcements": false, + "open_telegram": false, + "post_now": false, + "schedule": false, + "unschedule": false, + "unlink_telegram": false, + "delete_telegram": false, + "delete_storage": false + } + } + ] +} diff --git a/linkki-tiedotus/linkki_poster/__init__.py b/linkki-tiedotus/linkki_poster/__init__.py new file mode 100644 index 0000000..08b3503 --- /dev/null +++ b/linkki-tiedotus/linkki_poster/__init__.py @@ -0,0 +1 @@ +"""Telegram broadcast posting helpers.""" diff --git a/linkki-tiedotus/linkki_poster/broadcast_ops.py b/linkki-tiedotus/linkki_poster/broadcast_ops.py new file mode 100644 index 0000000..97ae1ac --- /dev/null +++ b/linkki-tiedotus/linkki_poster/broadcast_ops.py @@ -0,0 +1,979 @@ +import time +from dataclasses import dataclass +from pathlib import Path + +from linkki_poster.formatting import CAPTION_LIMIT, ParsedTemplate, parse_markdown_template, render_template +from linkki_poster.models import ( + Config, + Language, + LanguageAssets, + LanguageBinding, + LANGUAGE_DISPLAY_NAMES, + PublishBinding, + ScanResult, + TextSegmentBinding, +) +from linkki_poster.progress import ProgressDisplay +from linkki_poster.scanner import scan_directory +from linkki_poster.telegram_api import ( + TelegramAPI, + TelegramApiError, + is_message_missing_error, + is_message_not_modified_error, +) +from linkki_poster.workflow import ( + build_edit_label, + build_image_label, + build_message_url, + build_text_label, + is_caption_too_long_error, + normalize_channel_username, + resolve_global_image_strategy, + resolve_link_target, + split_markdown_text_for_messages, +) + + +@dataclass(frozen=True) +class PublishResult: + binding: PublishBinding + message_urls: dict[Language, str] + + +@dataclass(frozen=True) +class DeleteBindingResult: + warnings: list[str] + failures: list[str] + binding: PublishBinding | None + + +@dataclass(frozen=True) +class DiscussionResolutionStats: + updates_seen: int + discussion_group_updates: int + automatic_forward_updates: int + linked_channel_forward_updates: int + + +@dataclass(frozen=True) +class DiscussionPinningResult: + binding: PublishBinding + pinning_failed: bool + + +@dataclass +class _PublishedTextSegment: + label: str + binding: TextSegmentBinding + parsed_template: ParsedTemplate + last_rendered: str + + +@dataclass(frozen=True) +class _PublishedLanguage: + binding: LanguageBinding + segments: list[_PublishedTextSegment] + + +def publish_directory( + directory: Path, + config: Config, + ordered_languages: list[Language] | None = None, + use_captions: bool = True, + progress: ProgressDisplay | None = None, + disable_link_previews: dict[Language, bool] | None = None, +) -> PublishResult: + scan_result = scan_directory(directory) + languages = order_scan_languages(scan_result, ordered_languages) + reporter = progress or ProgressDisplay() + api = TelegramAPI(config.bot_token) + normalized_username = normalize_channel_username(config.channel_username) + link_target = resolve_link_target(config.chat_id, normalized_username) + language_links: dict[Language, str] = {} + published_languages: dict[Language, _PublishedLanguage] = {} + global_message_ids: list[int] = [] + global_attached_to_language: Language | None = None + posted_message_ids: list[int] = [] + + leading_global_images, effective_languages = resolve_global_image_strategy(scan_result.global_images, languages) + if scan_result.global_images and not leading_global_images and effective_languages: + global_attached_to_language = effective_languages[0].language + + try: + if leading_global_images: + global_message_ids = post_images( + api=api, + chat_id=config.chat_id, + image_paths=leading_global_images, + label=build_image_label("global", len(leading_global_images)), + reporter=reporter, + posted_message_ids=posted_message_ids, + ) + + for language_assets in effective_languages: + published_language = publish_language( + api=api, + chat_id=config.chat_id, + language_assets=language_assets, + language_links=language_links, + disable_link_preview=bool((disable_link_previews or {}).get(language_assets.language, False)), + link_target=link_target, + use_captions=use_captions, + reporter=reporter, + posted_message_ids=posted_message_ids, + ) + published_languages[language_assets.language] = published_language + language_links[language_assets.language] = published_language.binding.primary_message_url + + backfill_links( + api=api, + chat_id=config.chat_id, + published_segments={language: item.segments for language, item in published_languages.items()}, + language_links=language_links, + disable_link_previews=disable_link_previews or {}, + reporter=reporter, + ) + + return PublishResult( + binding=PublishBinding( + global_message_ids=global_message_ids, + global_attached_to_language=global_attached_to_language, + languages={language: item.binding for language, item in published_languages.items()}, + discussion_message_ids={}, + global_primary_message_id=global_message_ids[0] if global_message_ids and global_attached_to_language is None else None, + ), + message_urls=language_links, + ) + except Exception: + rollback_messages(api, config.chat_id, posted_message_ids, reporter) + raise + + +def update_texts( + directory: Path, + config: Config, + binding: PublishBinding, + changed_languages: set[Language], + progress: ProgressDisplay | None = None, + disable_link_previews: dict[Language, bool] | None = None, +) -> PublishBinding: + reporter = progress or ProgressDisplay() + api = TelegramAPI(config.bot_token) + scan_result = scan_directory(directory) + scan_languages = {language_assets.language: language_assets for language_assets in scan_result.languages} + language_links = {language: language_binding.primary_message_url for language, language_binding in binding.languages.items()} + normalized_username = normalize_channel_username(config.channel_username) + link_target = resolve_link_target(config.chat_id, normalized_username) + updated_languages: dict[Language, LanguageBinding] = dict(binding.languages) + + for language in changed_languages: + language_assets = scan_languages.get(language) + existing_binding = binding.languages.get(language) + if language_assets is None or existing_binding is None: + continue + + chunk_texts = split_markdown_text_for_messages(language_assets.text_raw, link_target) + if len(chunk_texts) != len(existing_binding.text_segments): + raise ValueError( + f"Updated {LANGUAGE_DISPLAY_NAMES[language]} text changed message count. Delete or unlink and publish again." + ) + + for index, (chunk_text, existing_segment) in enumerate(zip(chunk_texts, existing_binding.text_segments), start=1): + parsed_template = parse_markdown_template(chunk_text) + rendered_text = render_template(parsed_template, language_links) + step_index = reporter.add_step(build_edit_label(build_text_label(language, index, len(chunk_texts)))) + reporter.update(step_index, "in_progress") + try: + if existing_segment.mode == "message": + api.edit_message_text( + config.chat_id, + existing_segment.message_id, + rendered_text, + parse_mode="HTML", + disable_link_preview=bool((disable_link_previews or {}).get(language, False)), + ) + else: + api.edit_message_caption(config.chat_id, existing_segment.message_id, rendered_text, parse_mode="HTML") + except TelegramApiError as error: + if is_message_missing_error(error): + raise ValueError( + f"Telegram message for {LANGUAGE_DISPLAY_NAMES[language]} no longer exists. " + "Delete from Telegram or unlink this broadcast and publish again." + ) from error + if is_message_not_modified_error(error): + reporter.update(step_index, "skipped", "no Telegram-visible change") + continue + raise + reporter.update(step_index, "done") + + return PublishBinding( + global_message_ids=binding.global_message_ids, + global_attached_to_language=binding.global_attached_to_language, + languages=updated_languages, + discussion_message_ids=dict(binding.discussion_message_ids), + global_primary_message_id=binding.global_primary_message_id, + global_discussion_message_id=binding.global_discussion_message_id, + ) + + +def delete_binding_from_telegram( + config: Config, + binding: PublishBinding, + progress: ProgressDisplay | None = None, +) -> DeleteBindingResult: + reporter = progress or ProgressDisplay() + api = TelegramAPI(config.bot_token) + failures: list[str] = [] + warnings: list[str] = [] + deleted_channel_ids: set[int] = set() + deleted_discussion_channel_ids: set[int] = set() + deleted_discussion_message_ids: set[int] = set() + + if config.discussion_group_id: + discussion_targets: list[tuple[int | None, int]] = [] + if binding.global_discussion_message_id: + discussion_targets.append((binding.global_primary_message_id, binding.global_discussion_message_id)) + for language_binding in binding.languages.values(): + if language_binding.discussion_message_id: + discussion_targets.append((language_binding.primary_message_id, language_binding.discussion_message_id)) + + seen_discussion_ids: set[int] = set() + for channel_message_id, discussion_message_id in reversed(discussion_targets): + if discussion_message_id in seen_discussion_ids: + continue + seen_discussion_ids.add(discussion_message_id) + step_index = reporter.add_step(f"Delete discussion message {discussion_message_id}") + reporter.update(step_index, "in_progress") + try: + api.delete_message(config.discussion_group_id, discussion_message_id) + except TelegramApiError as error: + if is_message_missing_error(error): + detail = f"{error.description}; it may already have been deleted manually" + reporter.update(step_index, "skipped", detail) + warnings.append(f"Discussion message {discussion_message_id}: {error.description}") + if channel_message_id: + deleted_discussion_channel_ids.add(channel_message_id) + deleted_discussion_message_ids.add(discussion_message_id) + continue + reporter.update(step_index, "failed", error.description) + failures.append(f"Discussion message {discussion_message_id}: {error.description}") + continue + if channel_message_id: + deleted_discussion_channel_ids.add(channel_message_id) + deleted_discussion_message_ids.add(discussion_message_id) + reporter.update(step_index, "done") + + if failures: + return DeleteBindingResult( + warnings=warnings, + failures=failures, + binding=prune_binding_after_deletions( + binding, + deleted_channel_ids=set(), + deleted_discussion_channel_ids=deleted_discussion_channel_ids, + deleted_discussion_message_ids=deleted_discussion_message_ids, + chat_id=config.chat_id, + channel_username=config.channel_username, + ), + ) + + message_ids: list[int] = [] + message_ids.extend(binding.global_message_ids) + for language_binding in binding.languages.values(): + message_ids.extend(language_binding.image_message_ids) + message_ids.extend(segment.message_id for segment in language_binding.text_segments) + + for message_id in reversed(deduplicate_message_ids(message_ids)): + step_index = reporter.add_step(f"Delete message {message_id}") + reporter.update(step_index, "in_progress") + try: + api.delete_message(config.chat_id, message_id) + except TelegramApiError as error: + if is_message_missing_error(error): + detail = f"{error.description}; it may already have been deleted manually" + reporter.update(step_index, "skipped", detail) + warnings.append(f"Message {message_id}: {error.description}") + deleted_channel_ids.add(message_id) + continue + reporter.update(step_index, "failed", error.description) + failures.append(f"Message {message_id}: {error.description}") + continue + deleted_channel_ids.add(message_id) + reporter.update(step_index, "done") + return DeleteBindingResult( + warnings=warnings, + failures=failures, + binding=prune_binding_after_deletions( + binding, + deleted_channel_ids=deleted_channel_ids, + deleted_discussion_channel_ids=deleted_discussion_channel_ids, + deleted_discussion_message_ids=deleted_discussion_message_ids, + chat_id=config.chat_id, + channel_username=config.channel_username, + ), + ) + + +def prune_binding_after_deletions( + binding: PublishBinding, + deleted_channel_ids: set[int], + deleted_discussion_channel_ids: set[int], + deleted_discussion_message_ids: set[int], + chat_id: str, + channel_username: str, +) -> PublishBinding | None: + link_target = resolve_link_target(chat_id, normalize_channel_username(channel_username)) + global_message_ids = [message_id for message_id in binding.global_message_ids if message_id not in deleted_channel_ids] + + languages: dict[Language, LanguageBinding] = {} + for language, language_binding in binding.languages.items(): + image_message_ids = [message_id for message_id in language_binding.image_message_ids if message_id not in deleted_channel_ids] + text_segments = [ + TextSegmentBinding(message_id=segment.message_id, mode=segment.mode) + for segment in language_binding.text_segments + if segment.message_id not in deleted_channel_ids + ] + if not image_message_ids and not text_segments: + continue + primary_message_id = text_segments[0].message_id if text_segments else image_message_ids[0] + languages[language] = LanguageBinding( + language=language, + image_message_ids=image_message_ids, + text_segments=text_segments, + primary_message_id=primary_message_id, + primary_message_url=build_message_url(link_target, primary_message_id), + discussion_message_id=None, + ) + + discussion_message_ids = { + channel_message_id: discussion_message_id + for channel_message_id, discussion_message_id in binding.discussion_message_ids.items() + if channel_message_id not in deleted_channel_ids and channel_message_id not in deleted_discussion_channel_ids + } + global_primary_message_id = ( + binding.global_primary_message_id + if binding.global_primary_message_id and binding.global_primary_message_id not in deleted_channel_ids + else (global_message_ids[0] if global_message_ids and binding.global_attached_to_language is None else None) + ) + global_discussion_message_id = binding.global_discussion_message_id + if global_primary_message_id and global_primary_message_id in deleted_discussion_channel_ids: + global_discussion_message_id = None + elif global_primary_message_id and global_primary_message_id in discussion_message_ids: + global_discussion_message_id = discussion_message_ids[global_primary_message_id] + elif not global_primary_message_id: + global_discussion_message_id = None + elif global_discussion_message_id in deleted_discussion_message_ids: + global_discussion_message_id = None + + languages = { + language: LanguageBinding( + language=language_binding.language, + image_message_ids=list(language_binding.image_message_ids), + text_segments=list(language_binding.text_segments), + primary_message_id=language_binding.primary_message_id, + primary_message_url=language_binding.primary_message_url, + discussion_message_id=( + None + if language_binding.discussion_message_id in deleted_discussion_message_ids + else ( + discussion_message_ids.get(language_binding.primary_message_id) + if language_binding.primary_message_id + else language_binding.discussion_message_id + ) + ), + ) + for language, language_binding in languages.items() + } + + if not global_message_ids and not languages: + return None + + return PublishBinding( + global_message_ids=global_message_ids, + global_attached_to_language=binding.global_attached_to_language if global_message_ids else None, + languages=languages, + discussion_message_ids=discussion_message_ids, + global_primary_message_id=global_primary_message_id, + global_discussion_message_id=global_discussion_message_id, + ) + + +def snapshot_next_update_offset(config: Config) -> int: + api = TelegramAPI(config.bot_token) + offset: int | None = None + latest_offset = 0 + while True: + updates = api.get_updates(offset=offset, limit=100, timeout=0) + if not updates: + return latest_offset + latest_offset = max(int(update["update_id"]) for update in updates) + 1 + if len(updates) < 100: + return latest_offset + offset = latest_offset + + +def apply_discussion_group_pinning( + config: Config, + binding: PublishBinding, + ordered_languages: list[Language], + pinning_mode: str, + delete_service_messages: bool = False, + update_offset: int = 0, + progress: ProgressDisplay | None = None, +) -> DiscussionPinningResult: + if not config.discussion_group_id: + return DiscussionPinningResult(binding=binding, pinning_failed=False) + + reporter = progress or ProgressDisplay() + binding, update_offset = ensure_discussion_group_message_ids( + config=config, + binding=binding, + ordered_languages=ordered_languages, + update_offset=update_offset, + progress=progress, + required=True, + ) + primary_channel_message_ids = build_primary_channel_message_ids(binding, ordered_languages) + + ordered_discussion_ids = [ + binding.discussion_message_ids[channel_message_id] + for channel_message_id in primary_channel_message_ids + if channel_message_id in binding.discussion_message_ids + ] + if not ordered_discussion_ids: + return DiscussionPinningResult(binding=binding, pinning_failed=False) + + api = TelegramAPI(config.bot_token) + keep_ids = select_discussion_keep_ids(ordered_discussion_ids, pinning_mode) + pinning_failed = False + + for discussion_message_id in ordered_discussion_ids: + if discussion_message_id in keep_ids: + pin_step = reporter.add_step(f"Pin discussion message {discussion_message_id}") + reporter.update(pin_step, "in_progress") + try: + api.pin_chat_message(config.discussion_group_id, discussion_message_id, disable_notification=True) + except TelegramApiError as error: + pinning_failed = True + reporter.update(pin_step, "failed", error.description) + continue + if delete_service_messages: + update_offset = delete_pin_service_message( + api=api, + discussion_group_id=config.discussion_group_id, + pinned_message_id=discussion_message_id, + update_offset=update_offset, + reporter=reporter, + ) + reporter.update(pin_step, "done") + elif discussion_message_id not in keep_ids: + unpin_step = reporter.add_step(f"Unpin discussion message {discussion_message_id}") + reporter.update(unpin_step, "in_progress") + try: + api.unpin_chat_message(config.discussion_group_id, discussion_message_id) + except TelegramApiError as error: + pinning_failed = True + reporter.update(unpin_step, "failed", error.description) + continue + reporter.update(unpin_step, "done") + return DiscussionPinningResult( + binding=PublishBinding( + global_message_ids=binding.global_message_ids, + global_attached_to_language=binding.global_attached_to_language, + languages=binding.languages, + discussion_message_ids=dict(binding.discussion_message_ids), + global_primary_message_id=binding.global_primary_message_id, + global_discussion_message_id=binding.global_discussion_message_id, + ), + pinning_failed=pinning_failed, + ) + + +def select_discussion_keep_ids(ordered_discussion_ids: list[int], pinning_mode: str | None) -> set[int]: + if not ordered_discussion_ids: + return set() + if pinning_mode == "all": + return set(ordered_discussion_ids) + if pinning_mode == "first": + return {ordered_discussion_ids[0]} + if pinning_mode == "last": + return {ordered_discussion_ids[-1]} + return set() + + +def order_scan_languages(scan_result: ScanResult, ordered_languages: list[Language] | None) -> list[LanguageAssets]: + if ordered_languages is None: + return scan_result.languages + + by_language = {language_assets.language: language_assets for language_assets in scan_result.languages} + ordered: list[LanguageAssets] = [] + for language in ordered_languages: + match = by_language.get(language) + if match is None: + continue + ordered.append(match) + return ordered + + +def build_primary_channel_message_ids(binding: PublishBinding, ordered_languages: list[Language]) -> list[int]: + message_ids: list[int] = [] + if binding.global_primary_message_id and binding.global_attached_to_language is None: + message_ids.append(binding.global_primary_message_id) + elif binding.global_message_ids and binding.global_attached_to_language is None: + message_ids.append(binding.global_message_ids[0]) + + for language in ordered_languages: + language_binding = binding.languages.get(language) + if language_binding is None: + continue + if language_binding.primary_message_id: + message_ids.append(language_binding.primary_message_id) + elif language_binding.text_segments: + message_ids.append(language_binding.text_segments[0].message_id) + elif language_binding.image_message_ids: + message_ids.append(language_binding.image_message_ids[0]) + return message_ids + + +def ensure_discussion_group_message_ids( + config: Config, + binding: PublishBinding, + ordered_languages: list[Language], + update_offset: int = 0, + progress: ProgressDisplay | None = None, + required: bool = True, +) -> tuple[PublishBinding, int]: + if not config.discussion_group_id: + return binding, update_offset + + reporter = progress or ProgressDisplay() + primary_channel_message_ids = build_primary_channel_message_ids(binding, ordered_languages) + discussion_message_ids = dict(binding.discussion_message_ids) + missing_message_ids = [ + message_id for message_id in primary_channel_message_ids if message_id not in discussion_message_ids + ] + if not missing_message_ids: + return binding, update_offset + + step_index = reporter.add_step("Resolve discussion-group messages") + reporter.update(step_index, "in_progress") + matched_ids, next_offset, stats = collect_discussion_message_ids( + config=config, + channel_message_ids=primary_channel_message_ids, + update_offset=update_offset, + ) + discussion_message_ids.update(matched_ids) + + ordered_discussion_ids = [ + discussion_message_ids[channel_message_id] + for channel_message_id in primary_channel_message_ids + if channel_message_id in discussion_message_ids + ] + if not ordered_discussion_ids: + detail = build_discussion_resolution_detail( + stats=stats, + expected_count=len(primary_channel_message_ids), + matched_count=0, + ) + reporter.update(step_index, "failed", detail) + if required: + raise ValueError( + "Could not find forwarded discussion-group messages for this announcement. " + f"{detail}" + ) + return binding, next_offset + + if len(ordered_discussion_ids) < len(primary_channel_message_ids): + reporter.update( + step_index, + "done", + build_discussion_resolution_detail( + stats=stats, + expected_count=len(primary_channel_message_ids), + matched_count=len(ordered_discussion_ids), + ), + ) + else: + reporter.update(step_index, "done") + + updated_languages = { + language: LanguageBinding( + language=language_binding.language, + image_message_ids=list(language_binding.image_message_ids), + text_segments=list(language_binding.text_segments), + primary_message_id=language_binding.primary_message_id, + primary_message_url=language_binding.primary_message_url, + discussion_message_id=( + discussion_message_ids.get(language_binding.primary_message_id) + if language_binding.primary_message_id + else language_binding.discussion_message_id + ), + ) + for language, language_binding in binding.languages.items() + } + + return ( + PublishBinding( + global_message_ids=binding.global_message_ids, + global_attached_to_language=binding.global_attached_to_language, + languages=updated_languages, + discussion_message_ids=discussion_message_ids, + global_primary_message_id=binding.global_primary_message_id, + global_discussion_message_id=( + discussion_message_ids.get(binding.global_primary_message_id) + if binding.global_primary_message_id + else binding.global_discussion_message_id + ), + ), + next_offset, + ) + + +def collect_discussion_message_ids( + config: Config, + channel_message_ids: list[int], + update_offset: int, + timeout_seconds: int = 12, +) -> tuple[dict[int, int], int, DiscussionResolutionStats]: + api = TelegramAPI(config.bot_token) + expected = set(channel_message_ids) + matched: dict[int, int] = {} + offset = update_offset + deadline = time.monotonic() + timeout_seconds + updates_seen = 0 + discussion_group_updates = 0 + automatic_forward_updates = 0 + linked_channel_forward_updates = 0 + + while time.monotonic() < deadline and len(matched) < len(expected): + updates = api.get_updates(offset=offset, limit=100, timeout=1) + updates_seen += len(updates) + if updates: + offset = max(int(update["update_id"]) for update in updates) + 1 + for update in updates: + message = extract_message_from_update(update) + if message is None: + continue + if str((message.get("chat") or {}).get("id", "")).strip() != config.discussion_group_id: + continue + discussion_group_updates += 1 + if not bool(message.get("is_automatic_forward", False)): + continue + automatic_forward_updates += 1 + origin = message.get("forward_origin") + if not isinstance(origin, dict): + continue + origin_chat = origin.get("chat") + if not isinstance(origin_chat, dict): + continue + if str(origin_chat.get("id", "")).strip() != config.chat_id: + continue + linked_channel_forward_updates += 1 + channel_message_id = int(origin.get("message_id", 0) or 0) + if channel_message_id not in expected or channel_message_id in matched: + continue + matched[channel_message_id] = int(message["message_id"]) + return ( + matched, + offset, + DiscussionResolutionStats( + updates_seen=updates_seen, + discussion_group_updates=discussion_group_updates, + automatic_forward_updates=automatic_forward_updates, + linked_channel_forward_updates=linked_channel_forward_updates, + ), + ) + + +def build_discussion_resolution_detail( + stats: DiscussionResolutionStats, + expected_count: int, + matched_count: int, +) -> str: + detail = [f"matched {matched_count}/{expected_count} discussion-group messages"] + if stats.updates_seen == 0: + detail.append("saw no new bot updates") + elif stats.discussion_group_updates == 0: + detail.append( + f"saw {stats.updates_seen} bot updates, but none were from the configured discussion group" + ) + elif stats.automatic_forward_updates == 0: + detail.append( + f"saw {stats.discussion_group_updates} discussion-group updates, but none were automatic forwards" + ) + elif stats.linked_channel_forward_updates == 0: + detail.append( + f"saw {stats.automatic_forward_updates} automatic forwards in the discussion group, but none came from the linked channel" + ) + else: + detail.append( + f"saw {stats.linked_channel_forward_updates} automatic forwards from the linked channel" + ) + detail.append( + "check bot message access in the discussion group and make sure no other process is consuming getUpdates" + ) + detail.append( + "if discussion-group links were missed, they must be linked manually" + ) + return "; ".join(detail) + + +def delete_pin_service_message( + api: TelegramAPI, + discussion_group_id: str, + pinned_message_id: int, + update_offset: int, + reporter: ProgressDisplay, + timeout_seconds: float = 2.0, +) -> int: + deadline = time.monotonic() + timeout_seconds + offset = update_offset + while time.monotonic() < deadline: + updates = api.get_updates(offset=offset, limit=100, timeout=1) + if updates: + offset = max(int(update["update_id"]) for update in updates) + 1 + for update in updates: + message = extract_message_from_update(update) + if message is None: + continue + if str((message.get("chat") or {}).get("id", "")).strip() != discussion_group_id: + continue + pinned_payload = message.get("pinned_message") + if not isinstance(pinned_payload, dict): + continue + if int(pinned_payload.get("message_id", 0) or 0) != pinned_message_id: + continue + service_message_id = int(message.get("message_id", 0) or 0) + if service_message_id <= 0: + return offset + cleanup_step = reporter.add_step(f"Delete pin service message {service_message_id}") + reporter.update(cleanup_step, "in_progress") + try: + api.delete_message(discussion_group_id, service_message_id) + except TelegramApiError as error: + if is_message_missing_error(error): + reporter.update(cleanup_step, "skipped", "already gone") + else: + reporter.update(cleanup_step, "failed", error.description) + else: + reporter.update(cleanup_step, "done") + return offset + return offset + + +def extract_message_from_update(update: dict) -> dict | None: + for key in ("message", "edited_message", "channel_post", "edited_channel_post"): + payload = update.get(key) + if isinstance(payload, dict): + return payload + return None + + +def publish_language( + api: TelegramAPI, + chat_id: str, + language_assets: LanguageAssets, + language_links: dict[Language, str], + disable_link_preview: bool, + link_target: str, + use_captions: bool, + reporter: ProgressDisplay, + posted_message_ids: list[int], +) -> _PublishedLanguage: + full_template = parse_markdown_template(language_assets.text_raw) + full_rendered_text = render_template(full_template, language_links) + chunk_texts = split_markdown_text_for_messages(language_assets.text_raw, link_target) + image_message_ids: list[int] = [] + published_segments: list[_PublishedTextSegment] = [] + + if language_assets.images: + can_use_caption = use_captions and len(chunk_texts) == 1 and len(full_rendered_text) <= CAPTION_LIMIT + image_label = build_image_label(LANGUAGE_DISPLAY_NAMES[language_assets.language], len(language_assets.images)) + if can_use_caption: + try: + image_message_ids = post_images( + api=api, + chat_id=chat_id, + image_paths=language_assets.images, + label=image_label, + reporter=reporter, + posted_message_ids=posted_message_ids, + caption=full_rendered_text, + ) + text_label = build_text_label(language_assets.language, 1, 1) + reporter.add_step(text_label, status="done", detail="included as caption") + published_segments.append( + _PublishedTextSegment( + label=text_label, + binding=TextSegmentBinding(message_id=image_message_ids[0], mode="caption"), + parsed_template=full_template, + last_rendered=full_rendered_text, + ) + ) + except TelegramApiError as error: + if not is_caption_too_long_error(error): + raise + reporter.add_step(image_label, status="failed", detail="caption too long, retrying without caption") + image_message_ids = post_images( + api=api, + chat_id=chat_id, + image_paths=language_assets.images, + label=image_label, + reporter=reporter, + posted_message_ids=posted_message_ids, + ) + published_segments = post_text_segments( + api=api, + chat_id=chat_id, + language=language_assets.language, + chunk_texts=chunk_texts, + language_links=language_links, + disable_link_preview=disable_link_preview, + reporter=reporter, + posted_message_ids=posted_message_ids, + ) + else: + image_message_ids = post_images( + api=api, + chat_id=chat_id, + image_paths=language_assets.images, + label=image_label, + reporter=reporter, + posted_message_ids=posted_message_ids, + ) + published_segments = post_text_segments( + api=api, + chat_id=chat_id, + language=language_assets.language, + chunk_texts=chunk_texts, + language_links=language_links, + disable_link_preview=disable_link_preview, + reporter=reporter, + posted_message_ids=posted_message_ids, + ) + else: + published_segments = post_text_segments( + api=api, + chat_id=chat_id, + language=language_assets.language, + chunk_texts=chunk_texts, + language_links=language_links, + disable_link_preview=disable_link_preview, + reporter=reporter, + posted_message_ids=posted_message_ids, + ) + + if not published_segments: + raise ValueError(f"Language '{language_assets.language.value}' produced no text segments.") + + binding = LanguageBinding( + language=language_assets.language, + image_message_ids=image_message_ids, + text_segments=[segment.binding for segment in published_segments], + primary_message_id=published_segments[0].binding.message_id, + primary_message_url=build_message_url(link_target, published_segments[0].binding.message_id), + ) + return _PublishedLanguage(binding=binding, segments=published_segments) + + +def post_images( + api: TelegramAPI, + chat_id: str, + image_paths: list[Path], + label: str, + reporter: ProgressDisplay, + posted_message_ids: list[int], + caption: str | None = None, +) -> list[int]: + step_index = reporter.add_step(label) + reporter.update(step_index, "in_progress") + result = api.send_media_group(chat_id, image_paths, caption=caption, parse_mode="HTML" if caption else None) + message_ids = [int(item["message_id"]) for item in result] + posted_message_ids.extend(message_ids) + reporter.update(step_index, "done", "with caption" if caption else "") + return message_ids + + +def post_text_segments( + api: TelegramAPI, + chat_id: str, + language: Language, + chunk_texts: list[str], + language_links: dict[Language, str], + disable_link_preview: bool, + reporter: ProgressDisplay, + posted_message_ids: list[int], +) -> list[_PublishedTextSegment]: + segments: list[_PublishedTextSegment] = [] + for index, chunk_text in enumerate(chunk_texts, start=1): + label = build_text_label(language, index, len(chunk_texts)) + step_index = reporter.add_step(label) + reporter.update(step_index, "in_progress") + parsed_template = parse_markdown_template(chunk_text) + rendered_text = render_template(parsed_template, language_links) + result = api.send_message(chat_id, rendered_text, parse_mode="HTML", disable_link_preview=disable_link_preview) + message_id = int(result["message_id"]) + posted_message_ids.append(message_id) + detail = f"split part {index}/{len(chunk_texts)}" if len(chunk_texts) > 1 else "" + reporter.update(step_index, "done", detail) + segments.append( + _PublishedTextSegment( + label=label, + binding=TextSegmentBinding(message_id=message_id, mode="message"), + parsed_template=parsed_template, + last_rendered=rendered_text, + ) + ) + return segments + + +def backfill_links( + api: TelegramAPI, + chat_id: str, + published_segments: dict[Language, list[_PublishedTextSegment]], + language_links: dict[Language, str], + disable_link_previews: dict[Language, bool], + reporter: ProgressDisplay, +) -> None: + for language, segments in published_segments.items(): + for segment in segments: + updated_text = render_template(segment.parsed_template, language_links) + if updated_text == segment.last_rendered: + continue + step_index = reporter.add_step(build_edit_label(segment.label)) + reporter.update(step_index, "in_progress") + if segment.binding.mode == "message": + api.edit_message_text( + chat_id, + segment.binding.message_id, + updated_text, + parse_mode="HTML", + disable_link_preview=bool(disable_link_previews.get(language, False)), + ) + else: + api.edit_message_caption(chat_id, segment.binding.message_id, updated_text, parse_mode="HTML") + reporter.update(step_index, "done") + segment.last_rendered = updated_text + + +def rollback_messages(api: TelegramAPI, chat_id: str, posted_message_ids: list[int], reporter: ProgressDisplay) -> None: + for message_id in reversed(deduplicate_message_ids(posted_message_ids)): + step_index = reporter.add_step(f"Delete message {message_id}") + reporter.update(step_index, "in_progress") + try: + api.delete_message(chat_id, message_id) + except TelegramApiError as error: + reporter.update(step_index, "failed", error.description) + continue + reporter.update(step_index, "done") + + +def deduplicate_message_ids(message_ids: list[int]) -> list[int]: + seen: set[int] = set() + ordered: list[int] = [] + for message_id in message_ids: + if message_id in seen: + continue + seen.add(message_id) + ordered.append(message_id) + return ordered diff --git a/linkki-tiedotus/linkki_poster/cli.py b/linkki-tiedotus/linkki_poster/cli.py new file mode 100644 index 0000000..1c5864d --- /dev/null +++ b/linkki-tiedotus/linkki_poster/cli.py @@ -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") diff --git a/linkki-tiedotus/linkki_poster/formatting.py b/linkki-tiedotus/linkki_poster/formatting.py new file mode 100644 index 0000000..8e84882 --- /dev/null +++ b/linkki-tiedotus/linkki_poster/formatting.py @@ -0,0 +1,271 @@ +import html +import re +from dataclasses import dataclass + +from linkki_poster.models import Language, PlaceholderToken + + +CAPTION_LIMIT = 1024 +_FAKE_MESSAGE_ID = "9" * 20 + + +@dataclass(frozen=True) +class ParsedTemplate: + html_template: str + placeholders: list[PlaceholderToken] + markers: list[str] + + +@dataclass(frozen=True) +class SyntaxIssue: + marker: str + message: str + + +def parse_markdown_template(raw_text: str) -> ParsedTemplate: + text_with_markers, placeholders, markers = extract_language_placeholders(raw_text) + html_template = markdown_subset_to_html(text_with_markers) + return ParsedTemplate(html_template=html_template, placeholders=placeholders, markers=markers) + + +def extract_language_placeholders(raw_text: str) -> tuple[str, list[PlaceholderToken], list[str]]: + pattern = re.compile( + r"(?(?:\\.|[^\]\\])*)\](?fi|sv|en)\)", + re.IGNORECASE, + ) + placeholders: list[PlaceholderToken] = [] + markers: list[str] = [] + chunks: list[str] = [] + start_index = 0 + + for placeholder_index, match in enumerate(pattern.finditer(raw_text)): + marker = f"ZZPHTOKEN{placeholder_index}ZZ" + target_language = Language(match.group("target").lower()) + placeholders.append( + PlaceholderToken( + label=match.group("label"), + target_lang=target_language, + source_span=(match.start(), match.end()), + ) + ) + markers.append(marker) + chunks.append(raw_text[start_index : match.start()]) + chunks.append(marker) + start_index = match.end() + + chunks.append(raw_text[start_index:]) + return "".join(chunks), placeholders, markers + + +def render_template(parsed_template: ParsedTemplate, language_links: dict[Language, str]) -> str: + rendered = parsed_template.html_template + for marker, placeholder in zip(parsed_template.markers, parsed_template.placeholders): + target_url = language_links.get(placeholder.target_lang) + if target_url is None: + replacement = html.escape(placeholder.label, quote=False) + else: + escaped_url = html.escape(target_url, quote=True) + escaped_label = html.escape(placeholder.label, quote=False) + replacement = f'{escaped_label}' + rendered = rendered.replace(marker, replacement) + return rendered + + +def render_preview_html(raw_text: str, available_targets: set[Language]) -> str: + parsed_template = parse_markdown_template(raw_text) + fake_links = {language: f"#{language.value}" for language in available_targets} + return render_template(parsed_template, fake_links) + + +def estimate_caption_length(parsed_template: ParsedTemplate, normalized_channel_username: str) -> int: + fake_url = f"https://t.me/{normalized_channel_username}/{_FAKE_MESSAGE_ID}" + fake_links = { + Language.FI: fake_url, + Language.SV: fake_url, + Language.EN: fake_url, + } + rendered = render_template(parsed_template, fake_links) + return len(rendered) + + +def markdown_subset_to_html(markdown_text: str) -> str: + text_with_escape_tokens, escape_token_values = _stash_escaped_literals(markdown_text) + text_with_code_tokens, code_token_values = _stash_code_segments(text_with_escape_tokens) + escaped_text = html.escape(text_with_code_tokens, quote=False) + escaped_text = _map_blockquotes(escaped_text) + escaped_text = _map_headings(escaped_text) + escaped_text = _map_lists(escaped_text) + escaped_text = _map_standard_links(escaped_text) + escaped_text = _map_style_pairs(escaped_text, r"\|\|([\s\S]+?)\|\|", "tg-spoiler") + escaped_text = _map_style_pairs(escaped_text, r"\*\*(.+?)\*\*", "b") + escaped_text = _map_style_pairs(escaped_text, r"__(.+?)__", "b") + escaped_text = _map_style_pairs(escaped_text, r"\+\+(.+?)\+\+", "u") + escaped_text = _map_style_pairs(escaped_text, r"~~(.+?)~~", "s") + escaped_text = _map_style_pairs(escaped_text, r"(? tuple[str, dict[str, str]]: + token_values: dict[str, str] = {} + + def replace_code_block(match: re.Match[str]) -> str: + token = f"ZZCODEBLOCK{len(token_values)}ZZ" + code = html.escape(match.group(1), quote=False) + token_values[token] = f"
{code}
" + return token + + code_block_pattern = re.compile(r"```(?:[^\n`]*)\n?(.*?)```", re.DOTALL) + text = code_block_pattern.sub(replace_code_block, markdown_text) + + def replace_inline_code(match: re.Match[str]) -> str: + token = f"ZZINLINECODE{len(token_values)}ZZ" + code = html.escape(match.group(1), quote=False) + token_values[token] = f"{code}" + return token + + inline_code_pattern = re.compile(r"`([^`\n]+)`") + text = inline_code_pattern.sub(replace_inline_code, text) + return text, token_values + + +def _stash_escaped_literals(markdown_text: str) -> tuple[str, dict[str, str]]: + token_values: dict[str, str] = {} + + def replace_escape(match: re.Match[str]) -> str: + token = f"ZZESCAPED{len(token_values)}ZZ" + token_values[token] = html.escape(match.group(1), quote=False) + return token + + text = re.sub(r"\\([\\`*_+\-~|.\[\]()#>])", replace_escape, markdown_text) + return text, token_values + + +def _map_headings(text: str) -> str: + mapped_lines: list[str] = [] + for line in text.splitlines(): + heading_match = re.match(r"^(#{1,6})\s*(.+)$", line) + if heading_match is None: + mapped_lines.append(line) + continue + mapped_lines.append(f"{heading_match.group(2).upper()}") + return "\n".join(mapped_lines) + + +def _map_lists(text: str) -> str: + mapped_lines: list[str] = [] + for line in text.splitlines(): + unordered_match = re.match(r"^(?P[ \t]*)(?P[-+*])\s+(?P.+)$", line) + if unordered_match is not None: + level = _indent_level(unordered_match.group("indent")) + mapped_lines.append(f"{_list_indent(level)}\u2022 {unordered_match.group('content')}") + continue + + ordered_match = re.match(r"^(?P[ \t]*)(?P\d+)\.\s+(?P.+)$", line) + if ordered_match is not None: + level = _indent_level(ordered_match.group("indent")) + mapped_lines.append( + f"{_list_indent(level)}{ordered_match.group('number')}. {ordered_match.group('content')}" + ) + continue + + mapped_lines.append(line) + return "\n".join(mapped_lines) + + +def _map_blockquotes(text: str) -> str: + mapped_lines: list[str] = [] + quote_lines: list[str] = [] + + def flush_quote_lines() -> None: + if not quote_lines: + return + mapped_lines.append(f"
{'\n'.join(quote_lines)}
") + quote_lines.clear() + + for line in text.splitlines(): + quote_match = re.match(r"^[ \t]*>\s?(.*)$", line) + if quote_match is None: + flush_quote_lines() + mapped_lines.append(line) + continue + quote_lines.append(quote_match.group(1)) + + flush_quote_lines() + return "\n".join(mapped_lines) + + +def _indent_level(indent: str) -> int: + expanded = indent.replace("\t", " ") + return len(expanded) // 2 + + +def _list_indent(level: int) -> str: + return "\u00a0" * (level * 4) + + +def _map_standard_links(text: str) -> str: + def repl(match: re.Match[str]) -> str: + label = match.group(1) + url = match.group(2) + return f'{label}' + + return re.sub(r"\[([^\]]+)\]\(([^)\s]+)\)", repl, text) + + +def _map_style_pairs(text: str, pattern: str, html_tag: str) -> str: + return re.sub(pattern, rf"<{html_tag}>\1", text) + + +def validate_markdown_syntax(markdown_text: str) -> list[SyntaxIssue]: + issues: list[SyntaxIssue] = [] + masked_text = _mask_escaped_markers(_mask_complete_code_segments(markdown_text)) + + if masked_text.count("```") % 2 != 0: + issues.append(SyntaxIssue(marker="```", message="Unclosed code block marker.")) + + if masked_text.count("`") % 2 != 0: + issues.append(SyntaxIssue(marker="`", message="Unclosed inline code marker.")) + + sanitized_text = _mask_escaped_markers(_remove_complete_code_segments(markdown_text)) + for marker in ("**", "__", "++", "~~", "||"): + if sanitized_text.count(marker) % 2 != 0: + issues.append(SyntaxIssue(marker=marker, message=f"Unclosed or unmatched {marker} marker.")) + + bracket_balance = 0 + for character in sanitized_text: + if character == "[": + bracket_balance += 1 + elif character == "]" and bracket_balance > 0: + bracket_balance -= 1 + if bracket_balance != 0: + issues.append(SyntaxIssue(marker="[]", message="Unmatched square brackets in link-like syntax.")) + + parenthesis_balance = 0 + for character in sanitized_text: + if character == "(": + parenthesis_balance += 1 + elif character == ")" and parenthesis_balance > 0: + parenthesis_balance -= 1 + if parenthesis_balance != 0: + issues.append(SyntaxIssue(marker="()", message="Unmatched parentheses in link-like syntax.")) + + return issues + + +def _mask_complete_code_segments(markdown_text: str) -> str: + text = re.sub(r"```(?:[^\n`]*)\n?(.*?)```", "", markdown_text, flags=re.DOTALL) + return re.sub(r"`([^`\n]+)`", "", text) + + +def _remove_complete_code_segments(markdown_text: str) -> str: + text = re.sub(r"```(?:[^\n`]*)\n?(.*?)```", "", markdown_text, flags=re.DOTALL) + return re.sub(r"`([^`\n]+)`", "", text) + + +def _mask_escaped_markers(markdown_text: str) -> str: + return re.sub(r"\\([\\`*_+\-~|.\[\]()#>])", "", markdown_text) diff --git a/linkki-tiedotus/linkki_poster/models.py b/linkki-tiedotus/linkki_poster/models.py new file mode 100644 index 0000000..05a448d --- /dev/null +++ b/linkki-tiedotus/linkki_poster/models.py @@ -0,0 +1,82 @@ +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import Literal + + +class Language(str, Enum): + FI = "fi" + SV = "sv" + EN = "en" + + +LANGUAGE_DISPLAY_NAMES: dict[Language, str] = { + Language.FI: "Finnish", + Language.SV: "Swedish", + Language.EN: "English", +} + + +@dataclass(frozen=True) +class Config: + bot_token: str + chat_id: str + channel_username: str + discussion_group_id: str = "" + + +@dataclass(frozen=True) +class LanguageAssets: + language: Language + text_file: Path + text_raw: str + images: list[Path] + + +@dataclass(frozen=True) +class ScanResult: + global_images: list[Path] + languages: list[LanguageAssets] + + +@dataclass(frozen=True) +class PlaceholderToken: + label: str + target_lang: Language + source_span: tuple[int, int] + + +@dataclass +class PostedMessageRef: + language: Language + text_message_id: int | None + caption_message_id: int | None + message_url: str + had_placeholders: bool + text_mode: Literal["message", "caption", "none"] + + +@dataclass(frozen=True) +class TextSegmentBinding: + message_id: int + mode: Literal["message", "caption"] + + +@dataclass(frozen=True) +class LanguageBinding: + language: Language + image_message_ids: list[int] + text_segments: list[TextSegmentBinding] + primary_message_id: int | None + primary_message_url: str + discussion_message_id: int | None = None + + +@dataclass(frozen=True) +class PublishBinding: + global_message_ids: list[int] + global_attached_to_language: Language | None + languages: dict[Language, LanguageBinding] + discussion_message_ids: dict[int, int] + global_primary_message_id: int | None = None + global_discussion_message_id: int | None = None diff --git a/linkki-tiedotus/linkki_poster/ordering.py b/linkki-tiedotus/linkki_poster/ordering.py new file mode 100644 index 0000000..eba7e09 --- /dev/null +++ b/linkki-tiedotus/linkki_poster/ordering.py @@ -0,0 +1,35 @@ +import re + +from linkki_poster.models import LANGUAGE_DISPLAY_NAMES, LanguageAssets + + +def prompt_language_order(languages: list[LanguageAssets], input_fn=input) -> list[LanguageAssets]: + if len(languages) <= 1: + return languages + + print("Language order:") + for index, language_assets in enumerate(languages, start=1): + print(f"{index}. {LANGUAGE_DISPLAY_NAMES[language_assets.language]}") + + expected = list(range(1, len(languages) + 1)) + while True: + raw = input_fn("Enter language order: ") + if raw.strip() == "": + return languages + parsed = parse_order_input(raw, len(languages)) + if parsed is None: + print(f"Invalid order. Press Enter for default order {expected}, or enter digits {expected}.") + continue + return [languages[index - 1] for index in parsed] + + +def parse_order_input(raw_text: str, language_count: int) -> list[int] | None: + digits = [int(item) for item in re.findall(r"\d", raw_text)] + if len(digits) != language_count: + return None + + expected = set(range(1, language_count + 1)) + if set(digits) != expected: + return None + + return digits diff --git a/linkki-tiedotus/linkki_poster/progress.py b/linkki-tiedotus/linkki_poster/progress.py new file mode 100644 index 0000000..b9b6e22 --- /dev/null +++ b/linkki-tiedotus/linkki_poster/progress.py @@ -0,0 +1,87 @@ +import sys +from dataclasses import dataclass + + +@dataclass +class ProgressStep: + label: str + status: str = "pending" + detail: str = "" + + +class ProgressDisplay: + def __init__(self, stream=None): + self.stream = stream or sys.stdout + self.steps: list[ProgressStep] = [] + self._rendered_line_count = 0 + self._supports_rewrite = hasattr(self.stream, "isatty") and self.stream.isatty() + + def add_step(self, label: str, status: str = "pending", detail: str = "") -> int: + self.steps.append(ProgressStep(label=label, status=status, detail=detail)) + self._render() + return len(self.steps) - 1 + + def update(self, step_index: int, status: str, detail: str = "") -> None: + step = self.steps[step_index] + step.status = status + step.detail = detail + self._render() + + def insert_step(self, index: int, label: str, status: str = "pending", detail: str = "") -> int: + self.steps.insert(index, ProgressStep(label=label, status=status, detail=detail)) + self._render() + return index + + def _render(self) -> None: + lines = [format_step(step) for step in self.steps] + output = "\n".join(lines) + if output: + output += "\n" + + if self._supports_rewrite and self._rendered_line_count: + self.stream.write(f"\x1b[{self._rendered_line_count}A") + for _ in range(self._rendered_line_count): + self.stream.write("\x1b[2K\x1b[1B") + self.stream.write(f"\x1b[{self._rendered_line_count}A") + + self.stream.write(output) + self.stream.flush() + self._rendered_line_count = len(lines) + + +class LogProgressDisplay: + def __init__(self, on_change=None): + self.steps: list[ProgressStep] = [] + self.on_change = on_change + + def add_step(self, label: str, status: str = "pending", detail: str = "") -> int: + self.steps.append(ProgressStep(label=label, status=status, detail=detail)) + self._notify() + return len(self.steps) - 1 + + def update(self, step_index: int, status: str, detail: str = "") -> None: + step = self.steps[step_index] + step.status = status + step.detail = detail + self._notify() + + def get_output(self) -> str: + return "\n".join(format_step(step) for step in self.steps) + + def _notify(self) -> None: + if self.on_change is not None: + self.on_change(self.get_output()) + + +def format_step(step: ProgressStep) -> str: + prefix_by_status = { + "pending": "[ ]", + "in_progress": "[>]", + "done": "[x]", + "failed": "[!]", + "skipped": "[-]", + } + prefix = prefix_by_status.get(step.status, "[?]") + if step.detail: + return f"{prefix} {step.label} - {step.detail}" + return f"{prefix} {step.label}" diff --git a/linkki-tiedotus/linkki_poster/scanner.py b/linkki-tiedotus/linkki_poster/scanner.py new file mode 100644 index 0000000..fc185bc --- /dev/null +++ b/linkki-tiedotus/linkki_poster/scanner.py @@ -0,0 +1,110 @@ +import re +from pathlib import Path + +from linkki_poster.models import Language, LanguageAssets, ScanResult + + +KNOWN_IMAGE_EXTENSIONS = { + "jpg", + "jpeg", + "png", + "webp", + "gif", +} + +GLOBAL_IMAGE_PREFIXES = ["Kuva"] + +LANGUAGE_TEXT_CANDIDATES: dict[Language, list[str]] = { + Language.FI: ["Suomi.md"], + Language.SV: ["Svenska.md"], + Language.EN: ["English.md"], +} + +LANGUAGE_IMAGE_PREFIXES: dict[Language, list[str]] = { + Language.FI: ["Suomi"], + Language.SV: ["Svenska"], + Language.EN: ["English"], +} + + +def scan_directory(directory: Path) -> ScanResult: + files = sorted((entry for entry in directory.iterdir() if entry.is_file()), key=lambda path: path.name.lower()) + casefold_name_index = _build_casefold_name_index(files) + + global_images = select_first_matching_image_family(files, GLOBAL_IMAGE_PREFIXES) + + language_assets: list[LanguageAssets] = [] + for language in (Language.FI, Language.SV, Language.EN): + text_file = select_first_matching_text_file(casefold_name_index, LANGUAGE_TEXT_CANDIDATES[language]) + if text_file is None: + continue + + text_raw = text_file.read_text(encoding="utf-8") + if text_raw.strip() == "": + continue + + language_images = select_first_matching_image_family(files, LANGUAGE_IMAGE_PREFIXES[language]) + language_assets.append( + LanguageAssets( + language=language, + text_file=text_file, + text_raw=text_raw, + images=language_images, + ) + ) + + return ScanResult(global_images=global_images, languages=language_assets) + + +def select_first_matching_text_file(casefold_name_index: dict[str, Path], candidate_names: list[str]) -> Path | None: + for candidate_name in candidate_names: + match = casefold_name_index.get(candidate_name.lower()) + if match is not None: + return match + return None + + +def select_first_matching_image_family(files: list[Path], prefixes: list[str]) -> list[Path]: + for prefix in prefixes: + matches = collect_image_matches_for_prefix(files, prefix) + if matches: + return [match[0] for match in sorted(matches, key=image_sort_key)] + return [] + + +def collect_image_matches_for_prefix(files: list[Path], prefix: str) -> list[tuple[Path, str, str]]: + pattern = re.compile(rf"^{re.escape(prefix)}(\d*)\.([^.]+)$", re.IGNORECASE) + matches: list[tuple[Path, str, str]] = [] + 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 + + numeric_suffix = match.group(1) + matches.append((file_path, numeric_suffix, extension)) + return matches + + +def image_sort_key(match_item: tuple[Path, str, str]) -> tuple[int, int, str, str]: + file_path, numeric_suffix, extension = match_item + has_numeric_suffix = numeric_suffix != "" + numeric_value = int(numeric_suffix) if has_numeric_suffix else -1 + return ( + 1 if has_numeric_suffix else 0, + numeric_value, + extension.lower(), + file_path.name.lower(), + ) + + +def _build_casefold_name_index(files: list[Path]) -> dict[str, Path]: + index: dict[str, Path] = {} + for file_path in files: + lowered = file_path.name.lower() + if lowered not in index: + index[lowered] = file_path + return index diff --git a/linkki-tiedotus/linkki_poster/telegram_api.py b/linkki-tiedotus/linkki_poster/telegram_api.py new file mode 100644 index 0000000..ddffc57 --- /dev/null +++ b/linkki-tiedotus/linkki_poster/telegram_api.py @@ -0,0 +1,186 @@ +import json +from contextlib import ExitStack +from pathlib import Path + +import requests + + +class TelegramApiError(RuntimeError): + def __init__(self, description: str, error_code: int | None = None): + super().__init__(description) + self.description = description + self.error_code = error_code + + +class TelegramAPI: + def __init__(self, bot_token: str): + self.base_url = f"https://api.telegram.org/bot{bot_token}" + + def send_media_group( + self, + chat_id: str, + image_paths: list[Path], + caption: str | None = None, + parse_mode: str | None = None, + ) -> list[dict]: + if not image_paths: + raise ValueError("send_media_group requires at least one image.") + + media_items = [] + for index, _ in enumerate(image_paths): + media_item = { + "type": "photo", + "media": f"attach://file{index}", + } + if index == 0 and caption is not None: + media_item["caption"] = caption + if parse_mode is not None: + media_item["parse_mode"] = parse_mode + media_items.append(media_item) + + payload = {"chat_id": chat_id, "media": json.dumps(media_items, ensure_ascii=False)} + + with ExitStack() as stack: + files = {} + for index, image_path in enumerate(image_paths): + files[f"file{index}"] = stack.enter_context(image_path.open("rb")) + + return self._post("sendMediaGroup", data=payload, files=files, timeout=60) + + def get_updates(self, offset: int | None = None, limit: int = 100, timeout: int = 0) -> list[dict]: + payload = {"limit": limit, "timeout": timeout} + if offset is not None: + payload["offset"] = offset + return self._post("getUpdates", data=payload, timeout=timeout + 10) + + def send_message( + self, + chat_id: str, + text: str, + parse_mode: str | None = None, + disable_link_preview: bool = False, + ) -> dict: + payload = {"chat_id": chat_id, "text": text} + if parse_mode is not None: + payload["parse_mode"] = parse_mode + if disable_link_preview: + payload["link_preview_options"] = json.dumps({"is_disabled": True}) + return self._post("sendMessage", data=payload, timeout=30) + + def edit_message_text( + self, + chat_id: str, + message_id: int, + text: str, + parse_mode: str | None = None, + disable_link_preview: bool = False, + ) -> dict: + payload = { + "chat_id": chat_id, + "message_id": message_id, + "text": text, + } + if parse_mode is not None: + payload["parse_mode"] = parse_mode + if disable_link_preview: + payload["link_preview_options"] = json.dumps({"is_disabled": True}) + return self._post("editMessageText", data=payload, timeout=30) + + def edit_message_caption( + self, + chat_id: str, + message_id: int, + caption: str, + parse_mode: str | None = None, + ) -> dict: + payload = { + "chat_id": chat_id, + "message_id": message_id, + "caption": caption, + } + if parse_mode is not None: + payload["parse_mode"] = parse_mode + return self._post("editMessageCaption", data=payload, timeout=30) + + def delete_message(self, chat_id: str, message_id: int) -> bool: + payload = { + "chat_id": chat_id, + "message_id": message_id, + } + return bool(self._post("deleteMessage", data=payload, timeout=30)) + + def pin_chat_message(self, chat_id: str, message_id: int, disable_notification: bool = True) -> bool: + payload = { + "chat_id": chat_id, + "message_id": message_id, + } + if disable_notification: + payload["disable_notification"] = "true" + return bool(self._post("pinChatMessage", data=payload, timeout=30)) + + def unpin_chat_message(self, chat_id: str, message_id: int) -> bool: + payload = { + "chat_id": chat_id, + "message_id": message_id, + } + return bool(self._post("unpinChatMessage", data=payload, timeout=30)) + + def _post(self, endpoint: str, data: dict, files: dict | None = None, timeout: int = 30): + response = requests.post(f"{self.base_url}/{endpoint}", data=data, files=files, timeout=timeout) + try: + response.raise_for_status() + except requests.HTTPError as error: + description = _extract_error_description(response) or str(error) + error_code = _extract_error_code(response) + raise TelegramApiError(description=description, error_code=error_code) from error + + payload = response.json() + if not payload.get("ok", False): + raise TelegramApiError( + description=str(payload.get("description", "Telegram API request failed.")), + error_code=_to_int_or_none(payload.get("error_code")), + ) + return payload["result"] + + +def _extract_error_description(response: requests.Response) -> str | None: + try: + payload = response.json() + except ValueError: + return None + description = payload.get("description") + if description is None: + return None + return str(description) + + +def _extract_error_code(response: requests.Response) -> int | None: + try: + payload = response.json() + except ValueError: + return None + return _to_int_or_none(payload.get("error_code")) + + +def _to_int_or_none(value) -> int | None: + if isinstance(value, int): + return value + if isinstance(value, str) and value.isdigit(): + return int(value) + return None + + +def is_message_missing_error(error: TelegramApiError | str) -> bool: + description = error.description if isinstance(error, TelegramApiError) else str(error) + lowered = description.lower() + return "not found" in lowered or "message to delete not found" in lowered or "message to edit not found" in lowered + + +def is_message_not_modified_error(error: TelegramApiError | str) -> bool: + description = error.description if isinstance(error, TelegramApiError) else str(error) + return "message is not modified" in description.lower() + + +def is_message_not_deletable_error(error: TelegramApiError | str) -> bool: + description = error.description if isinstance(error, TelegramApiError) else str(error) + return "can't be deleted" in description.lower() diff --git a/linkki-tiedotus/linkki_poster/workflow.py b/linkki-tiedotus/linkki_poster/workflow.py new file mode 100644 index 0000000..fd4fe94 --- /dev/null +++ b/linkki-tiedotus/linkki_poster/workflow.py @@ -0,0 +1,539 @@ +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + +from linkki_poster.formatting import ( + CAPTION_LIMIT, + ParsedTemplate, + estimate_caption_length, + parse_markdown_template, + render_template, +) +from linkki_poster.models import Config, LANGUAGE_DISPLAY_NAMES, Language, LanguageAssets, PostedMessageRef +from linkki_poster.progress import ProgressDisplay +from linkki_poster.telegram_api import TelegramAPI, TelegramApiError + + +MESSAGE_LIMIT = 4096 +_FAKE_MESSAGE_ID = "9" * 20 + + +@dataclass +class _TextSegmentState: + label: str + message_id: int | None + mode: Literal["message", "caption"] + parsed_template: ParsedTemplate + last_rendered: str + + +@dataclass +class _PostState: + posted_ref: PostedMessageRef + text_segments: list[_TextSegmentState] + + +@dataclass(frozen=True) +class _RuntimeChunkPlan: + raw_text: str + parsed_template: ParsedTemplate + label: str + has_placeholders: bool + + +@dataclass +class _LanguageRuntimePlan: + language_assets: LanguageAssets + full_template: ParsedTemplate + full_rendered_text: str + has_text: bool + can_use_caption: bool + chunks: list[_RuntimeChunkPlan] + + +def post_assets( + api: TelegramAPI, + config: Config, + global_images: list[Path], + ordered_languages: list[LanguageAssets], + progress: ProgressDisplay | None = None, +) -> list[PostedMessageRef]: + reporter = progress or ProgressDisplay() + leading_global_images, effective_languages = resolve_global_image_strategy(global_images, ordered_languages) + normalized_username = normalize_channel_username(config.channel_username) + link_target = resolve_link_target(config.chat_id, normalized_username) + runtime_plans = build_runtime_plans(effective_languages, link_target) + + posted_message_ids: list[int] = [] + post_states: list[_PostState] = [] + language_links: dict[Language, str] = {} + + try: + if leading_global_images: + execute_image_post( + api=api, + chat_id=config.chat_id, + image_paths=leading_global_images, + label=build_image_label("global", len(leading_global_images)), + reporter=reporter, + posted_message_ids=posted_message_ids, + ) + + for runtime_plan in runtime_plans: + post_state = execute_language_plan( + api=api, + config=config, + runtime_plan=runtime_plan, + reporter=reporter, + posted_message_ids=posted_message_ids, + language_links=language_links, + link_target=link_target, + ) + post_states.append(post_state) + language_links[post_state.posted_ref.language] = post_state.posted_ref.message_url + + execute_final_backfill(api, config.chat_id, post_states, language_links, reporter) + return [post_state.posted_ref for post_state in post_states] + except Exception as error: + rollback_posted_messages(api, config.chat_id, posted_message_ids, reporter) + raise error + + +def build_runtime_plans(ordered_languages: list[LanguageAssets], link_target: str) -> list[_LanguageRuntimePlan]: + runtime_plans: list[_LanguageRuntimePlan] = [] + for language_assets in ordered_languages: + full_template = parse_markdown_template(language_assets.text_raw) + full_rendered_text = render_template(full_template, {}) + has_text = full_rendered_text.strip() != "" + split_chunks = split_markdown_text_for_messages(language_assets.text_raw, link_target) if has_text else [] + chunk_plans = [] + for index, chunk_text in enumerate(split_chunks, start=1): + parsed_chunk = parse_markdown_template(chunk_text) + chunk_plans.append( + _RuntimeChunkPlan( + raw_text=chunk_text, + parsed_template=parsed_chunk, + label=build_text_label(language_assets.language, index, len(split_chunks)), + has_placeholders=bool(parsed_chunk.placeholders), + ) + ) + can_use_caption = ( + bool(language_assets.images) + and has_text + and len(chunk_plans) == 1 + and estimate_caption_length(full_template, link_target) <= CAPTION_LIMIT + ) + runtime_plans.append( + _LanguageRuntimePlan( + language_assets=language_assets, + full_template=full_template, + full_rendered_text=full_rendered_text, + has_text=has_text, + can_use_caption=can_use_caption, + chunks=chunk_plans, + ) + ) + return runtime_plans + + +def execute_language_plan( + api: TelegramAPI, + config: Config, + runtime_plan: _LanguageRuntimePlan, + reporter: ProgressDisplay, + posted_message_ids: list[int], + language_links: dict[Language, str], + link_target: str, +) -> _PostState: + language_assets = runtime_plan.language_assets + language_name = LANGUAGE_DISPLAY_NAMES[language_assets.language] + text_segments: list[_TextSegmentState] = [] + text_mode: Literal["message", "caption", "none"] = "none" + text_message_id: int | None = None + caption_message_id: int | None = None + + if language_assets.images: + image_label = build_image_label(language_name, len(language_assets.images)) + + if runtime_plan.can_use_caption: + only_chunk = runtime_plan.chunks[0] + try: + message_ids = execute_image_post( + api=api, + chat_id=config.chat_id, + image_paths=language_assets.images, + label=image_label, + reporter=reporter, + posted_message_ids=posted_message_ids, + caption=render_template(runtime_plan.full_template, language_links), + ) + caption_message_id = message_ids[0] + text_mode = "caption" + text_step_index = reporter.add_step(only_chunk.label, status="done", detail="included as caption") + text_segments.append( + _TextSegmentState( + label=only_chunk.label, + message_id=caption_message_id, + mode="caption", + parsed_template=runtime_plan.full_template, + last_rendered=render_template(runtime_plan.full_template, language_links), + ) + ) + except TelegramApiError as error: + if not is_caption_too_long_error(error): + raise + reporter.add_step(image_label, status="failed", detail="caption too long, retrying without caption") + message_ids = execute_image_post( + api=api, + chat_id=config.chat_id, + image_paths=language_assets.images, + label=image_label, + reporter=reporter, + posted_message_ids=posted_message_ids, + ) + if runtime_plan.has_text: + segments, first_message_id = execute_text_chunks( + api=api, + chat_id=config.chat_id, + chunks=runtime_plan.chunks, + reporter=reporter, + posted_message_ids=posted_message_ids, + language_links=language_links, + ) + text_segments.extend(segments) + text_message_id = first_message_id + text_mode = "message" + caption_message_id = None + else: + message_ids = execute_image_post( + api=api, + chat_id=config.chat_id, + image_paths=language_assets.images, + label=image_label, + reporter=reporter, + posted_message_ids=posted_message_ids, + ) + if runtime_plan.has_text: + segments, first_message_id = execute_text_chunks( + api=api, + chat_id=config.chat_id, + chunks=runtime_plan.chunks, + reporter=reporter, + posted_message_ids=posted_message_ids, + language_links=language_links, + ) + text_segments.extend(segments) + text_message_id = first_message_id + text_mode = "message" + elif runtime_plan.has_text: + segments, first_message_id = execute_text_chunks( + api=api, + chat_id=config.chat_id, + chunks=runtime_plan.chunks, + reporter=reporter, + posted_message_ids=posted_message_ids, + language_links=language_links, + ) + text_segments.extend(segments) + text_message_id = first_message_id + text_mode = "message" + else: + raise ValueError(f"Language '{language_assets.language.value}' has neither text nor images.") + + primary_message_id = caption_message_id or text_message_id + if primary_message_id is None: + raise ValueError(f"Language '{language_name}' did not produce a primary message.") + + message_url = build_message_url(link_target, primary_message_id) + posted_ref = PostedMessageRef( + language=language_assets.language, + text_message_id=text_message_id, + caption_message_id=caption_message_id, + message_url=message_url, + had_placeholders=any(bool(segment.parsed_template.placeholders) for segment in text_segments), + text_mode=text_mode, + ) + return _PostState(posted_ref=posted_ref, text_segments=text_segments) + + +def execute_image_post( + api: TelegramAPI, + chat_id: str, + image_paths: list[Path], + label: str, + reporter: ProgressDisplay, + posted_message_ids: list[int], + caption: str | None = None, +) -> list[int]: + step_index = reporter.add_step(label) + reporter.update(step_index, "in_progress") + response_items = api.send_media_group( + chat_id, + image_paths, + caption=caption, + parse_mode="HTML" if caption is not None else None, + ) + message_ids = [int(item["message_id"]) for item in response_items] + posted_message_ids.extend(message_ids) + detail = "with caption" if caption is not None else "" + reporter.update(step_index, "done", detail) + return message_ids + + +def execute_text_chunks( + api: TelegramAPI, + chat_id: str, + chunks: list[_RuntimeChunkPlan], + reporter: ProgressDisplay, + posted_message_ids: list[int], + language_links: dict[Language, str], +) -> tuple[list[_TextSegmentState], int]: + segments: list[_TextSegmentState] = [] + first_message_id: int | None = None + + for chunk in chunks: + post_step_index = reporter.add_step(chunk.label) + reporter.update(post_step_index, "in_progress") + rendered_text = render_template(chunk.parsed_template, language_links) + detail = "" + if len(chunks) > 1: + detail = chunk.label.replace("Post ", "").replace(" text part ", " split part ") + sent_message = api.send_message(chat_id, rendered_text, parse_mode="HTML") + message_id = int(sent_message["message_id"]) + posted_message_ids.append(message_id) + reporter.update(post_step_index, "done", detail) + if first_message_id is None: + first_message_id = message_id + segments.append( + _TextSegmentState( + label=chunk.label, + message_id=message_id, + mode="message", + parsed_template=chunk.parsed_template, + last_rendered=rendered_text, + ) + ) + + if first_message_id is None: + raise ValueError("Expected at least one text message segment.") + return segments, first_message_id + + +def execute_final_backfill( + api: TelegramAPI, + chat_id: str, + post_states: list[_PostState], + language_links: dict[Language, str], + reporter: ProgressDisplay, +) -> None: + for post_state in post_states: + for text_segment in post_state.text_segments: + if not text_segment.parsed_template.placeholders: + continue + + updated_text = render_template(text_segment.parsed_template, language_links) + if updated_text == text_segment.last_rendered: + continue + + edit_step_index = reporter.add_step(build_edit_label(text_segment.label)) + reporter.update(edit_step_index, "in_progress") + if text_segment.message_id is None: + raise ValueError("Missing message id for edit step.") + if text_segment.mode == "message": + api.edit_message_text(chat_id, text_segment.message_id, updated_text, parse_mode="HTML") + else: + api.edit_message_caption(chat_id, text_segment.message_id, updated_text, parse_mode="HTML") + reporter.update(edit_step_index, "done") + text_segment.last_rendered = updated_text + + +def rollback_posted_messages( + api: TelegramAPI, + chat_id: str, + posted_message_ids: list[int], + reporter: ProgressDisplay, +) -> None: + for message_id in reversed(posted_message_ids): + step_index = reporter.add_step(f"Delete message {message_id}") + reporter.update(step_index, "in_progress") + try: + api.delete_message(chat_id, message_id) + except TelegramApiError as error: + reporter.update(step_index, "failed", error.description) + continue + reporter.update(step_index, "done") + + +def normalize_channel_username(channel_username: str) -> str: + return channel_username.strip().lstrip("@") + + +def build_message_url(link_target: str, message_id: int) -> str: + return f"https://t.me/{link_target}/{message_id}" + + +def resolve_global_image_strategy( + global_images: list[Path], + ordered_languages: list[LanguageAssets], +) -> tuple[list[Path], list[LanguageAssets]]: + if not global_images: + return [], ordered_languages + + if not ordered_languages: + return global_images, ordered_languages + + first_language = ordered_languages[0] + if first_language.images: + return global_images, ordered_languages + + with_attached_globals = LanguageAssets( + language=first_language.language, + text_file=first_language.text_file, + text_raw=first_language.text_raw, + images=global_images, + ) + return [], [with_attached_globals, *ordered_languages[1:]] + + +def is_caption_too_long_error(error: TelegramApiError) -> bool: + return "caption is too long" in error.description.lower() + + +def split_markdown_text_for_messages( + markdown_text: str, + link_target: str, + message_limit: int = MESSAGE_LIMIT, +) -> list[str]: + if markdown_text == "": + return [] + + chunks: list[str] = [] + remaining = markdown_text + + while remaining: + fit_index = find_max_fitting_prefix_index(remaining, link_target, message_limit) + if fit_index == len(remaining): + chunks.append(remaining) + break + + split_index = choose_split_index(remaining, fit_index) + if split_index <= 0: + split_index = fit_index + + chunks.append(remaining[:split_index]) + remaining = remaining[split_index:] + + return [chunk for chunk in chunks if chunk != ""] + + +def find_max_fitting_prefix_index(markdown_text: str, link_target: str, message_limit: int) -> int: + cached_lengths: dict[int, int] = {} + + def measured_length(end_index: int) -> int: + if end_index not in cached_lengths: + cached_lengths[end_index] = estimate_rendered_message_length(markdown_text[:end_index], link_target) + return cached_lengths[end_index] + + if measured_length(len(markdown_text)) <= message_limit: + return len(markdown_text) + + low = 1 + high = len(markdown_text) + best = 0 + + while low <= high: + middle = (low + high) // 2 + if measured_length(middle) <= message_limit: + best = middle + low = middle + 1 + else: + high = middle - 1 + + if best == 0: + raise ValueError("Unable to split message content into Telegram-compatible chunks.") + return best + + +def choose_split_index(markdown_text: str, max_index: int) -> int: + double_newline_match = find_last_break_end(markdown_text, re.compile(r"\n{2,}"), max_index) + if double_newline_match is not None: + return double_newline_match + + single_newline_index = markdown_text.rfind("\n", 0, max_index) + if single_newline_index != -1: + return single_newline_index + 1 + + whitespace_index = find_last_whitespace_break(markdown_text, max_index) + if whitespace_index is not None: + return whitespace_index + + return max_index + + +def find_last_break_end(markdown_text: str, pattern: re.Pattern[str], max_index: int) -> int | None: + last_end: int | None = None + for match in pattern.finditer(markdown_text): + if match.end() > max_index: + break + last_end = match.end() + return last_end + + +def find_last_whitespace_break(markdown_text: str, max_index: int) -> int | None: + for index in range(max_index - 1, -1, -1): + if markdown_text[index] in {" ", "\t"}: + return index + 1 + return None + + +def estimate_rendered_message_length(markdown_text: str, link_target: str) -> int: + parsed_template = parse_markdown_template(markdown_text) + fake_url = f"https://t.me/{link_target}/{_FAKE_MESSAGE_ID}" + fake_links = { + Language.FI: fake_url, + Language.SV: fake_url, + Language.EN: fake_url, + } + rendered_text = render_template(parsed_template, fake_links) + return len(rendered_text) + + +def resolve_link_target(chat_id: str, normalized_username: str = "") -> str: + if normalized_username != "": + return normalized_username + + internal_chat_id = extract_internal_chat_id(chat_id) + if internal_chat_id is None: + raise ValueError( + "Cannot generate message links without channel_username or a channel-style chat_id (-100...)." + ) + return f"c/{internal_chat_id}" + + +def extract_internal_chat_id(chat_id: str) -> str | None: + stripped = chat_id.strip() + if not stripped.startswith("-100"): + return None + suffix = stripped[4:] + if suffix == "" or not suffix.isdigit(): + return None + return suffix + + +def build_image_label(target: str, image_count: int) -> str: + noun = "image" if image_count == 1 else "images" + return f"Post {image_count} {target} {noun}" + + +def build_text_label(language: Language, chunk_index: int, chunk_count: int) -> str: + language_name = LANGUAGE_DISPLAY_NAMES[language] + if chunk_count == 1: + return f"Post {language_name} text" + return f"Post {language_name} text part {chunk_index}/{chunk_count}" + + +def build_edit_label(text_label: str) -> str: + if text_label.startswith("Post "): + return "Edit " + text_label[5:] + return "Edit " + text_label diff --git a/linkki-tiedotus/main.py b/linkki-tiedotus/main.py new file mode 100644 index 0000000..b0c7dd4 --- /dev/null +++ b/linkki-tiedotus/main.py @@ -0,0 +1,8 @@ +from pathlib import Path +import sys + +from linkki_poster.cli import run_cli + + +if __name__ == "__main__": + run_cli(script_dir=Path(__file__).resolve().parent, argv=sys.argv[1:]) diff --git a/linkki-tiedotus/manage_broadcast.py b/linkki-tiedotus/manage_broadcast.py new file mode 100644 index 0000000..9e90fb9 --- /dev/null +++ b/linkki-tiedotus/manage_broadcast.py @@ -0,0 +1,49 @@ +import argparse +import json +from pathlib import Path + +from linkki_poster.broadcast_ops import delete_binding_from_telegram, update_texts +from linkki_poster.models import Config, Language +from linkki_poster.progress import ProgressDisplay +from webapp.storage import get_binding, load_metadata + + +def main() -> None: + parser = argparse.ArgumentParser(description="Advanced broadcast management helper.") + parser.add_argument("directory", help="Broadcast directory") + parser.add_argument("action", choices=["update-texts", "delete-telegram"]) + parser.add_argument("--languages", default="fi,sv,en", help="Comma-separated languages for update-texts") + parser.add_argument("--config", default="telegram_config.json", help="Path to Telegram config JSON") + args = parser.parse_args() + + config = load_config(Path(args.config)) + post_dir = Path(args.directory).resolve() + metadata = load_metadata(post_dir) + binding = get_binding(metadata) + if binding is None: + raise ValueError("Broadcast is not linked to Telegram.") + + progress = ProgressDisplay() + if args.action == "delete-telegram": + delete_binding_from_telegram(config, binding, progress) + return + + changed_languages = { + Language(language.strip()) + for language in args.languages.split(",") + if language.strip() + } + update_texts(post_dir, config, binding, changed_languages, progress) + + +def load_config(config_path: Path) -> Config: + payload = json.loads(config_path.read_text(encoding="utf-8")) + return Config( + bot_token=str(payload["bot_token"]).strip(), + chat_id=str(payload["chat_id"]).strip(), + channel_username=str(payload.get("channel_username", "")).strip(), + ) + + +if __name__ == "__main__": + main() diff --git a/linkki-tiedotus/post_broadcast.py b/linkki-tiedotus/post_broadcast.py new file mode 100644 index 0000000..b0c7dd4 --- /dev/null +++ b/linkki-tiedotus/post_broadcast.py @@ -0,0 +1,8 @@ +from pathlib import Path +import sys + +from linkki_poster.cli import run_cli + + +if __name__ == "__main__": + run_cli(script_dir=Path(__file__).resolve().parent, argv=sys.argv[1:]) diff --git a/linkki-tiedotus/requirements-dev.txt b/linkki-tiedotus/requirements-dev.txt new file mode 100644 index 0000000..26b77f6 --- /dev/null +++ b/linkki-tiedotus/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest diff --git a/linkki-tiedotus/requirements.txt b/linkki-tiedotus/requirements.txt new file mode 100644 index 0000000..9749b69 --- /dev/null +++ b/linkki-tiedotus/requirements.txt @@ -0,0 +1,6 @@ +requests +fastapi +uvicorn +jinja2 +python-multipart +itsdangerous diff --git a/linkki-tiedotus/tests/conftest.py b/linkki-tiedotus/tests/conftest.py new file mode 100644 index 0000000..89199d7 --- /dev/null +++ b/linkki-tiedotus/tests/conftest.py @@ -0,0 +1,12 @@ +import sys +import types +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PROJECT_ROOT)) + +sys.modules.setdefault( + "requests", + types.SimpleNamespace(HTTPError=RuntimeError, Response=object, post=lambda *args, **kwargs: None), +) diff --git a/linkki-tiedotus/tests/test_app_language.py b/linkki-tiedotus/tests/test_app_language.py new file mode 100644 index 0000000..7b7e7f0 --- /dev/null +++ b/linkki-tiedotus/tests/test_app_language.py @@ -0,0 +1,25 @@ +from webapp.app import parse_accept_language, resolve_effective_ui_language + + +class _Request: + def __init__(self, header: str): + self.headers = {"accept-language": header} + + +def test_parse_accept_language_collects_supported_languages_in_browser_order() -> None: + assert parse_accept_language("en-US,en;q=0.9,fi;q=0.8,sv;q=0.7") == ["en", "fi", "sv"] + + +def test_resolve_effective_ui_language_prefers_finnish_then_swedish_then_english() -> None: + request = _Request("en-US,en;q=0.9,sv;q=0.8,fi;q=0.7") + assert resolve_effective_ui_language(request, "auto") == "fi" + + +def test_resolve_effective_ui_language_falls_back_to_english_when_no_supported_match() -> None: + request = _Request("de-DE,de;q=0.9,fr;q=0.8") + assert resolve_effective_ui_language(request, "auto") == "en" + + +def test_resolve_effective_ui_language_keeps_explicit_language() -> None: + request = _Request("fi,sv,en") + assert resolve_effective_ui_language(request, "sv") == "sv" diff --git a/linkki-tiedotus/tests/test_auth.py b/linkki-tiedotus/tests/test_auth.py new file mode 100644 index 0000000..7e7b299 --- /dev/null +++ b/linkki-tiedotus/tests/test_auth.py @@ -0,0 +1,202 @@ +import json +from pathlib import Path + +from webapp.auth import load_users, set_user_language + + +def test_load_users_defaults_permissions_to_false(tmp_path: Path) -> None: + users_file = tmp_path / "users.json" + users_file.write_text( + """ + { + "users": [ + { + "username": "viewer", + "password_hash": "hash" + } + ] + } + """, + encoding="utf-8", + ) + + users = load_users(users_file) + + assert users["viewer"]["password_hash"] == "hash" + assert all(value is False for value in users["viewer"]["permissions"].values()) + assert users["viewer"]["language"] == "en" + saved_payload = json.loads(users_file.read_text(encoding="utf-8")) + saved_permissions = saved_payload["users"][0]["permissions"] + assert all(saved_permissions[key] is False for key in saved_permissions) + + +def test_load_users_accepts_bool_like_permission_values(tmp_path: Path) -> None: + users_file = tmp_path / "users.json" + users_file.write_text( + """ + { + "users": [ + { + "username": "editor", + "password_hash": "hash", + "permissions": { + "edit_content": 1, + "edit_targets": 1, + "save_announcements": 1, + "open_telegram": 1, + "post_now": "true", + "schedule": "yes", + "unschedule": true + } + } + ] + } + """, + encoding="utf-8", + ) + + users = load_users(users_file) + permissions = users["editor"]["permissions"] + + assert permissions["edit_content"] is True + assert permissions["edit_targets"] is True + assert permissions["save_announcements"] is True + assert permissions["open_telegram"] is True + assert permissions["post_now"] is True + assert permissions["schedule"] is True + assert permissions["unschedule"] is True + assert permissions["unlink_telegram"] is False + + +def test_set_user_language_updates_users_file(tmp_path: Path) -> None: + users_file = tmp_path / "users.json" + users_file.write_text( + """ + { + "users": [ + { + "username": "editor", + "password_hash": "hash", + "permissions": {} + } + ] + } + """, + encoding="utf-8", + ) + + set_user_language(users_file, "editor", "fi") + + users = load_users(users_file) + assert users["editor"]["language"] == "fi" + + +def test_load_users_accepts_auto_language(tmp_path: Path) -> None: + users_file = tmp_path / "users.json" + users_file.write_text( + """ + { + "users": [ + { + "username": "auto_user", + "password_hash": "hash", + "language": "auto" + } + ] + } + """, + encoding="utf-8", + ) + + users = load_users(users_file) + + assert users["auto_user"]["language"] == "auto" + + +def test_set_user_language_can_store_auto(tmp_path: Path) -> None: + users_file = tmp_path / "users.json" + users_file.write_text( + """ + { + "users": [ + { + "username": "editor", + "password_hash": "hash", + "permissions": {} + } + ] + } + """, + encoding="utf-8", + ) + + set_user_language(users_file, "editor", "auto") + + users = load_users(users_file) + assert users["editor"]["language"] == "auto" + + +def test_load_users_adds_missing_permissions_to_existing_permissions_block(tmp_path: Path) -> None: + users_file = tmp_path / "users.json" + users_file.write_text( + """ + { + "users": [ + { + "username": "editor", + "password_hash": "hash", + "permissions": { + "edit_content": true + } + } + ] + } + """, + encoding="utf-8", + ) + + users = load_users(users_file) + + assert users["editor"]["permissions"]["edit_content"] is True + assert users["editor"]["permissions"]["edit_targets"] is False + assert users["editor"]["permissions"]["save_announcements"] is False + assert users["editor"]["permissions"]["open_telegram"] is False + saved_payload = json.loads(users_file.read_text(encoding="utf-8")) + saved_permissions = saved_payload["users"][0]["permissions"] + assert saved_permissions["edit_content"] is True + assert saved_permissions["edit_targets"] is False + assert saved_permissions["save_announcements"] is False + assert saved_permissions["open_telegram"] is False + assert saved_permissions["post_now"] is False + assert saved_permissions["schedule"] is False + assert saved_permissions["unschedule"] is False + assert saved_permissions["unlink_telegram"] is False + assert saved_permissions["delete_telegram"] is False + assert saved_permissions["delete_storage"] is False + + +def test_load_users_removes_legacy_edit_broadcasts_permission(tmp_path: Path) -> None: + users_file = tmp_path / "users.json" + users_file.write_text( + """ + { + "users": [ + { + "username": "editor", + "password_hash": "hash", + "permissions": { + "edit_broadcasts": true + } + } + ] + } + """, + encoding="utf-8", + ) + + users = load_users(users_file) + + assert "edit_broadcasts" not in users["editor"]["permissions"] + saved_payload = json.loads(users_file.read_text(encoding="utf-8")) + saved_permissions = saved_payload["users"][0]["permissions"] + assert "edit_broadcasts" not in saved_permissions diff --git a/linkki-tiedotus/tests/test_broadcast_ops.py b/linkki-tiedotus/tests/test_broadcast_ops.py new file mode 100644 index 0000000..c55f692 --- /dev/null +++ b/linkki-tiedotus/tests/test_broadcast_ops.py @@ -0,0 +1,204 @@ +from linkki_poster.broadcast_ops import ( + DiscussionResolutionStats, + apply_discussion_group_pinning, + build_discussion_resolution_detail, + prune_binding_after_deletions, +) +from linkki_poster.models import Config, Language, LanguageBinding, PublishBinding, TextSegmentBinding +from linkki_poster.telegram_api import TelegramApiError + + +def test_discussion_resolution_detail_mentions_missing_group_updates() -> None: + detail = build_discussion_resolution_detail( + DiscussionResolutionStats( + updates_seen=4, + discussion_group_updates=0, + automatic_forward_updates=0, + linked_channel_forward_updates=0, + ), + expected_count=2, + matched_count=0, + ) + + assert "matched 0/2" in detail + assert "none were from the configured discussion group" in detail + + +def test_discussion_resolution_detail_mentions_linked_channel_forwards() -> None: + detail = build_discussion_resolution_detail( + DiscussionResolutionStats( + updates_seen=6, + discussion_group_updates=5, + automatic_forward_updates=3, + linked_channel_forward_updates=2, + ), + expected_count=3, + matched_count=2, + ) + + assert "matched 2/3" in detail + assert "saw 2 automatic forwards from the linked channel" in detail + + +def test_discussion_resolution_detail_mentions_manual_linking_for_old_posts() -> None: + detail = build_discussion_resolution_detail( + DiscussionResolutionStats( + updates_seen=0, + discussion_group_updates=0, + automatic_forward_updates=0, + linked_channel_forward_updates=0, + ), + expected_count=3, + matched_count=0, + ) + + assert "they must be linked manually" in detail + + +def test_apply_discussion_group_pinning_skips_service_message_cleanup_when_disabled(monkeypatch) -> None: + calls = [] + + class FakeTelegramAPI: + def __init__(self, bot_token: str): + self.bot_token = bot_token + + def pin_chat_message(self, chat_id: str, message_id: int, disable_notification: bool = True) -> bool: + calls.append(("pin", chat_id, message_id, disable_notification)) + return True + + def unpin_chat_message(self, chat_id: str, message_id: int) -> bool: + calls.append(("unpin", chat_id, message_id)) + return True + + def fail_if_delete_called(**kwargs): + raise AssertionError("delete_pin_service_message should not be called") + + monkeypatch.setattr("linkki_poster.broadcast_ops.TelegramAPI", FakeTelegramAPI) + monkeypatch.setattr("linkki_poster.broadcast_ops.delete_pin_service_message", fail_if_delete_called) + + binding = PublishBinding( + global_message_ids=[], + global_attached_to_language=None, + languages={ + Language.FI: LanguageBinding( + language=Language.FI, + image_message_ids=[], + text_segments=[TextSegmentBinding(message_id=101, mode="message")], + primary_message_id=101, + primary_message_url="https://t.me/c/123/101", + discussion_message_id=201, + ), + Language.SV: LanguageBinding( + language=Language.SV, + image_message_ids=[], + text_segments=[TextSegmentBinding(message_id=102, mode="message")], + primary_message_id=102, + primary_message_url="https://t.me/c/123/102", + discussion_message_id=202, + ), + }, + discussion_message_ids={101: 201, 102: 202}, + ) + + result = apply_discussion_group_pinning( + Config(bot_token="token", chat_id="-1001", channel_username="channel", discussion_group_id="-2001"), + binding, + [Language.FI, Language.SV], + "first", + delete_service_messages=False, + ) + + assert result.binding == binding + assert result.pinning_failed is False + assert calls == [ + ("pin", "-2001", 201, True), + ("unpin", "-2001", 202), + ] + + +def test_prune_binding_after_deletions_keeps_remaining_channel_links() -> None: + binding = PublishBinding( + global_message_ids=[], + global_attached_to_language=None, + languages={ + Language.FI: LanguageBinding( + language=Language.FI, + image_message_ids=[], + text_segments=[TextSegmentBinding(message_id=101, mode="message")], + primary_message_id=101, + primary_message_url="https://t.me/c/123/101", + discussion_message_id=201, + ), + Language.SV: LanguageBinding( + language=Language.SV, + image_message_ids=[], + text_segments=[TextSegmentBinding(message_id=102, mode="message")], + primary_message_id=102, + primary_message_url="https://t.me/c/123/102", + discussion_message_id=202, + ), + }, + discussion_message_ids={101: 201, 102: 202}, + ) + + remaining = prune_binding_after_deletions( + binding, + deleted_channel_ids=set(), + deleted_discussion_channel_ids={101}, + deleted_discussion_message_ids={201}, + chat_id="-100123", + channel_username="", + ) + + assert remaining is not None + assert remaining.languages[Language.FI].text_segments[0].message_id == 101 + assert remaining.languages[Language.SV].text_segments[0].message_id == 102 + assert remaining.discussion_message_ids == {102: 202} + + +def test_apply_discussion_group_pinning_reports_permission_failures(monkeypatch) -> None: + class FakeTelegramAPI: + def __init__(self, bot_token: str): + self.bot_token = bot_token + + def pin_chat_message(self, chat_id: str, message_id: int, disable_notification: bool = True) -> bool: + raise TelegramApiError("Bad Request: not enough rights to manage pinned messages in the chat") + + def unpin_chat_message(self, chat_id: str, message_id: int) -> bool: + raise TelegramApiError("Bad Request: not enough rights to manage pinned messages in the chat") + + monkeypatch.setattr("linkki_poster.broadcast_ops.TelegramAPI", FakeTelegramAPI) + + binding = PublishBinding( + global_message_ids=[], + global_attached_to_language=None, + languages={ + Language.FI: LanguageBinding( + language=Language.FI, + image_message_ids=[], + text_segments=[TextSegmentBinding(message_id=101, mode="message")], + primary_message_id=101, + primary_message_url="https://t.me/c/123/101", + discussion_message_id=201, + ), + Language.SV: LanguageBinding( + language=Language.SV, + image_message_ids=[], + text_segments=[TextSegmentBinding(message_id=102, mode="message")], + primary_message_id=102, + primary_message_url="https://t.me/c/123/102", + discussion_message_id=202, + ), + }, + discussion_message_ids={101: 201, 102: 202}, + ) + + result = apply_discussion_group_pinning( + Config(bot_token="token", chat_id="-1001", channel_username="channel", discussion_group_id="-2001"), + binding, + [Language.FI, Language.SV], + "first", + ) + + assert result.binding == binding + assert result.pinning_failed is True diff --git a/linkki-tiedotus/tests/test_cli.py b/linkki-tiedotus/tests/test_cli.py new file mode 100644 index 0000000..271596b --- /dev/null +++ b/linkki-tiedotus/tests/test_cli.py @@ -0,0 +1,53 @@ +from pathlib import Path + +from linkki_poster.cli import apply_language_order, load_config, load_config_strict +from linkki_poster.models import Language, LanguageAssets + + +def test_load_config_strict_reads_required_fields(tmp_path: Path) -> None: + config_path = tmp_path / "telegram_config.json" + config_path.write_text('{"bot_token":"token","chat_id":"-100123"}', encoding="utf-8") + + config = load_config_strict(tmp_path) + + assert config.bot_token == "token" + assert config.chat_id == "-100123" + assert config.channel_username == "" + + +def test_load_config_strict_fails_when_missing(tmp_path: Path) -> None: + try: + load_config_strict(tmp_path) + except FileNotFoundError: + return + raise AssertionError("Expected FileNotFoundError") + + +def test_load_config_strict_fails_when_required_field_missing(tmp_path: Path) -> None: + config_path = tmp_path / "telegram_config.json" + config_path.write_text('{"bot_token":"token"}', encoding="utf-8") + + try: + load_config_strict(tmp_path) + except ValueError: + return + raise AssertionError("Expected ValueError") + + +def test_apply_language_order_uses_requested_prefix_and_keeps_rest(tmp_path: Path) -> None: + fi = LanguageAssets(Language.FI, tmp_path / "fi.md", "fi", []) + sv = LanguageAssets(Language.SV, tmp_path / "sv.md", "sv", []) + en = LanguageAssets(Language.EN, tmp_path / "en.md", "en", []) + + ordered = apply_language_order([fi, sv, en], "en,fi") + + assert [item.language for item in ordered] == [Language.EN, Language.FI, Language.SV] + + +def test_load_config_interactive_prompts_missing_values(tmp_path: Path) -> None: + answers = iter(["token_from_prompt", "-100123", "publicname"]) + config = load_config(tmp_path, non_interactive=False, input_fn=lambda _: next(answers)) + + assert config.bot_token == "token_from_prompt" + assert config.chat_id == "-100123" + assert config.channel_username == "publicname" diff --git a/linkki-tiedotus/tests/test_config_errors.py b/linkki-tiedotus/tests/test_config_errors.py new file mode 100644 index 0000000..9dfd264 --- /dev/null +++ b/linkki-tiedotus/tests/test_config_errors.py @@ -0,0 +1,78 @@ +import json +import os +from pathlib import Path + +from webapp.auth import load_users +from webapp.config import ConfigFileError, load_webapp_config +from webapp.i18n import load_translation_catalog +from webapp.telegram_targets import load_telegram_target_catalog + + +def test_load_users_reports_invalid_json_with_path(tmp_path: Path) -> None: + users_file = tmp_path / "users.json" + users_file.write_text("{broken", encoding="utf-8") + + try: + load_users(users_file) + except ConfigFileError as error: + assert str(users_file) in str(error) + else: + raise AssertionError("Expected ConfigFileError") + + +def test_load_telegram_target_catalog_reports_invalid_json_with_path(tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + config_file = config_dir / "telegram_config.json" + config_file.write_text("{broken", encoding="utf-8") + + try: + load_telegram_target_catalog(config_dir) + except ConfigFileError as error: + assert str(config_file) in str(error) + else: + raise AssertionError("Expected ConfigFileError") + + +def test_load_webapp_config_reports_invalid_json_with_path(tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + config_file = config_dir / "app_config.json" + config_file.write_text("{broken", encoding="utf-8") + + previous = os.environ.get("LINKKI_DATA_DIR") + os.environ["LINKKI_DATA_DIR"] = str(tmp_path) + try: + try: + load_webapp_config() + except ConfigFileError as error: + assert str(config_file) in str(error) + else: + raise AssertionError("Expected ConfigFileError") + finally: + if previous is None: + os.environ.pop("LINKKI_DATA_DIR", None) + else: + os.environ["LINKKI_DATA_DIR"] = previous + + +def test_load_translation_catalog_reports_invalid_json_with_path(tmp_path: Path) -> None: + translations_dir = tmp_path / "translations" + translations_dir.mkdir() + config_file = translations_dir / "catalog.json" + config_file.write_text('{"a":{"en":"line1\nline2"}}', encoding="utf-8") + + from webapp import i18n as i18n_module + + original = i18n_module.TRANSLATION_CATALOG_FILE + i18n_module.TRANSLATION_CATALOG_FILE = config_file + try: + try: + load_translation_catalog() + except ConfigFileError as error: + assert str(config_file) in str(error) + assert "raw line breaks" in str(error) + else: + raise AssertionError("Expected ConfigFileError") + finally: + i18n_module.TRANSLATION_CATALOG_FILE = original diff --git a/linkki-tiedotus/tests/test_formatting.py b/linkki-tiedotus/tests/test_formatting.py new file mode 100644 index 0000000..c61f039 --- /dev/null +++ b/linkki-tiedotus/tests/test_formatting.py @@ -0,0 +1,125 @@ +from linkki_poster.formatting import ( + CAPTION_LIMIT, + estimate_caption_length, + parse_markdown_template, + render_preview_html, + render_template, + validate_markdown_syntax, +) +from linkki_poster.models import Language + + +def test_markdown_subset_and_placeholder_rendering() -> None: + raw = ( + "# title\n" + "**bold** _italic_ ++under++ ~~strike~~ ||spoil|| `code` [site](https://example.com)\n" + "Go [English](en) and [Finnish](fi)\n" + ) + parsed = parse_markdown_template(raw) + + unresolved = render_template(parsed, {}) + assert "TITLE" in unresolved + assert "bold" in unresolved + assert "italic" in unresolved + assert "under" in unresolved + assert "strike" in unresolved + assert "spoil" in unresolved + assert "code" in unresolved + assert 'site' in unresolved + assert "English" in unresolved + assert "Finnish" in unresolved + assert 'href="https://t.me/' not in unresolved + + resolved = render_template( + parsed, + { + Language.EN: "https://t.me/testchan/123", + Language.FI: "https://t.me/testchan/456", + }, + ) + assert 'English' in resolved + assert 'Finnish' in resolved + + +def test_caption_length_estimate_uses_worst_case_links() -> None: + parsed = parse_markdown_template("[EN](en) " * 80) + assert estimate_caption_length(parsed, "channel") > CAPTION_LIMIT + + +def test_multiline_spoiler_is_supported() -> None: + parsed = parse_markdown_template("||line1\n\nline2||") + rendered = render_template(parsed, {}) + assert "line1\n\nline2" in rendered + + +def test_validate_markdown_syntax_finds_unclosed_markers() -> None: + issues = validate_markdown_syntax("Can we do **[bold links](fi)") + messages = [issue.message for issue in issues] + assert any("**" in message for message in messages) + + +def test_lists_and_blockquotes_are_rendered_for_telegram_html() -> None: + parsed = parse_markdown_template( + "- item\n" + " - nested\n" + "1. first\n" + " 1. nested first\n" + "> quoted\n" + "> text\n" + ) + rendered = render_template(parsed, {}) + assert "• item" in rendered + assert "\u00a0\u00a0\u00a0\u00a0• nested" in rendered + assert "1. first" in rendered + assert "\u00a0\u00a0\u00a0\u00a01. nested first" in rendered + assert "
quoted\ntext
" in rendered + + +def test_escaped_list_markers_and_heading_markers_stay_literal() -> None: + parsed = parse_markdown_template("\\* not a list\n1\\. not numbered\n\\# not a heading") + rendered = render_template(parsed, {}) + assert "* not a list" in rendered + assert "1. not numbered" in rendered + assert "# not a heading" in rendered + + +def test_escaped_backticks_and_link_brackets_stay_literal() -> None: + parsed = parse_markdown_template( + r"test \`code` test" "\n" + r"\```" "\n" + "code\n" + "```\n" + r"\[label\]\(url\)" + ) + rendered = render_template(parsed, {}) + assert r"test `code` test" in rendered + assert "code" not in rendered + assert "
" not in rendered
+    assert "[label](url)" in rendered
+
+
+def test_escaped_cross_language_link_stays_literal() -> None:
+    parsed = parse_markdown_template(r"\[English](en)")
+    rendered = render_template(parsed, {Language.EN: "https://t.me/test/123"})
+    assert "[English](en)" in rendered
+    assert 'href="https://t.me/test/123"' not in rendered
+
+
+def test_preview_renders_fake_links_for_available_targets() -> None:
+    preview = render_preview_html("[English](en) [Finnish](fi)", {Language.EN})
+    assert 'English' in preview
+    assert "Finnish" in preview
+    assert 'href="#fi"' not in preview
+
+
+def test_apostrophes_are_not_escaped_in_headings_or_plain_text() -> None:
+    heading = render_template(parse_markdown_template("#THERE'S"), {})
+    plain_heading_char = render_template(parse_markdown_template("#'"), {})
+    bold = render_template(parse_markdown_template("**THERE'S**"), {})
+
+    assert "THERE'S" in heading
+    assert "'" in plain_heading_char
+    assert "THERE'S" in bold
+    assert "'" not in heading
+    assert "'" not in plain_heading_char
+    assert "'" not in bold
diff --git a/linkki-tiedotus/tests/test_i18n.py b/linkki-tiedotus/tests/test_i18n.py
new file mode 100644
index 0000000..959dc8b
--- /dev/null
+++ b/linkki-tiedotus/tests/test_i18n.py
@@ -0,0 +1,46 @@
+import json
+from pathlib import Path
+
+from webapp.i18n import load_translations
+
+
+def test_load_translations_uses_english_fallback_for_missing_values() -> None:
+    translations = load_translations("fi")
+
+    assert translations["nav.instructions"]
+    assert translations["app.title"] == "Linkki Botti"
+
+
+def test_load_translations_prefers_requested_language_value() -> None:
+    translations = load_translations("sv")
+
+    assert translations["nav.instructions"] != "Instructions"
+
+
+def test_translation_catalog_has_no_common_utf8_mojibake_in_finnish_or_swedish() -> None:
+    catalog = json.loads(
+        (Path(__file__).resolve().parents[1] / "webapp/translations/catalog.json").read_text(
+            encoding="utf-8"
+        )
+    )
+
+    def visit(value: object) -> list[str]:
+        if isinstance(value, str):
+            return [value]
+        if isinstance(value, list):
+            result: list[str] = []
+            for item in value:
+                result.extend(visit(item))
+            return result
+        if isinstance(value, dict):
+            result: list[str] = []
+            for nested in value.values():
+                result.extend(visit(nested))
+            return result
+        return []
+
+    suspicious_markers = ("\u00c3", "\u00c2", "\ufffd")
+    for entry in catalog.values():
+        for language in ("fi", "sv"):
+            for text in visit(entry.get(language, "")):
+                assert not any(marker in text for marker in suspicious_markers), text
diff --git a/linkki-tiedotus/tests/test_ordering.py b/linkki-tiedotus/tests/test_ordering.py
new file mode 100644
index 0000000..5f674c8
--- /dev/null
+++ b/linkki-tiedotus/tests/test_ordering.py
@@ -0,0 +1,25 @@
+from pathlib import Path
+
+from linkki_poster.models import Language, LanguageAssets
+from linkki_poster.ordering import parse_order_input, prompt_language_order
+
+
+def test_parse_order_input_accepts_expected_formats() -> None:
+    assert parse_order_input("12", 2) == [1, 2]
+    assert parse_order_input("2 1", 2) == [2, 1]
+    assert parse_order_input("1,2", 2) == [1, 2]
+
+
+def test_parse_order_input_rejects_invalid_orders() -> None:
+    assert parse_order_input("11", 2) is None
+    assert parse_order_input("13", 2) is None
+    assert parse_order_input("1", 2) is None
+
+
+def test_prompt_language_order_accepts_empty_as_default(tmp_path: Path) -> None:
+    fi = LanguageAssets(Language.FI, tmp_path / "fi.md", "fi", [])
+    en = LanguageAssets(Language.EN, tmp_path / "en.md", "en", [])
+
+    ordered = prompt_language_order([fi, en], input_fn=lambda _: "")
+
+    assert [item.language for item in ordered] == [Language.FI, Language.EN]
diff --git a/linkki-tiedotus/tests/test_progress.py b/linkki-tiedotus/tests/test_progress.py
new file mode 100644
index 0000000..a2ce17b
--- /dev/null
+++ b/linkki-tiedotus/tests/test_progress.py
@@ -0,0 +1,10 @@
+from linkki_poster.progress import LogProgressDisplay
+
+
+def test_log_progress_display_keeps_only_latest_step_state() -> None:
+    progress = LogProgressDisplay()
+    step = progress.add_step("Post English text")
+    progress.update(step, "in_progress")
+    progress.update(step, "done", "split part 1/2")
+
+    assert progress.get_output() == "[x] Post English text - split part 1/2"
diff --git a/linkki-tiedotus/tests/test_scanner.py b/linkki-tiedotus/tests/test_scanner.py
new file mode 100644
index 0000000..d890ff8
--- /dev/null
+++ b/linkki-tiedotus/tests/test_scanner.py
@@ -0,0 +1,60 @@
+from pathlib import Path
+
+from linkki_poster.models import Language
+from linkki_poster.scanner import scan_directory
+
+
+def write_file(path: Path, text: str = "x") -> None:
+    path.write_text(text, encoding="utf-8")
+
+
+def test_global_prefix_first_family_wins(tmp_path: Path) -> None:
+    write_file(tmp_path / "Kuva1.jpg")
+    write_file(tmp_path / "Image1.jpg")
+    write_file(tmp_path / "Suomi.md", "hello")
+
+    result = scan_directory(tmp_path)
+    assert [path.name for path in result.global_images] == ["Kuva1.jpg"]
+
+
+def test_numeric_suffix_rule_and_case_insensitive_matching(tmp_path: Path) -> None:
+    write_file(tmp_path / "KuvaA.jpg")
+    write_file(tmp_path / "Kuva2.jpg")
+    write_file(tmp_path / "sUoMi.MD", "hello")
+    write_file(tmp_path / "sUoMi10.PNG")
+    write_file(tmp_path / "sUoMi2.jpg")
+
+    result = scan_directory(tmp_path)
+    assert [path.name for path in result.global_images] == ["Kuva2.jpg"]
+    assert len(result.languages) == 1
+    assert result.languages[0].language == Language.FI
+    assert [path.name for path in result.languages[0].images] == ["sUoMi2.jpg", "sUoMi10.PNG"]
+
+
+def test_natural_sort_and_text_precedence(tmp_path: Path) -> None:
+    write_file(tmp_path / "English.md", "right")
+    write_file(tmp_path / "english10.png")
+    write_file(tmp_path / "english9.webp")
+    write_file(tmp_path / "english9.jpg")
+    write_file(tmp_path / "english.jpg")
+
+    result = scan_directory(tmp_path)
+    assert len(result.languages) == 1
+    assets = result.languages[0]
+    assert assets.text_file.name == "English.md"
+    assert [path.name for path in assets.images] == [
+        "english.jpg",
+        "english9.jpg",
+        "english9.webp",
+        "english10.png",
+    ]
+
+
+def test_empty_text_file_is_skipped_as_missing_language(tmp_path: Path) -> None:
+    write_file(tmp_path / "Suomi.md", "Hei")
+    write_file(tmp_path / "Svenska.md", "   \n\t")
+    write_file(tmp_path / "English.md", "")
+
+    result = scan_directory(tmp_path)
+
+    assert [assets.language for assets in result.languages] == [Language.FI]
diff --git a/linkki-tiedotus/tests/test_scheduling.py b/linkki-tiedotus/tests/test_scheduling.py
new file mode 100644
index 0000000..2f0672f
--- /dev/null
+++ b/linkki-tiedotus/tests/test_scheduling.py
@@ -0,0 +1,50 @@
+import asyncio
+from datetime import datetime, timedelta
+from pathlib import Path
+
+from fastapi import FastAPI
+
+from webapp import app as app_module
+
+
+def test_startup_recovery_runs_overdue_scheduled_posts_without_waiting_for_retry() -> None:
+    now = datetime.now().astimezone()
+    item = {
+        "status": "scheduled",
+        "scheduled_for": (now - timedelta(minutes=5)).isoformat(),
+        "scheduled_next_attempt_at": (now + timedelta(minutes=30)).isoformat(),
+    }
+
+    assert app_module.is_scheduled_publish_due(item, recover_missed_jobs=True) is True
+    assert app_module.is_scheduled_publish_due(item, recover_missed_jobs=False) is False
+
+
+def test_manual_publish_retries_once_after_a_failed_attempt(monkeypatch) -> None:
+    app = FastAPI()
+    directory_name = "retry-once"
+    app.state.operations = {
+        directory_name: {
+            "running": True,
+            "completed": False,
+            "output": "",
+            "result": None,
+            "error": None,
+        }
+    }
+    app.state.operation_tasks = {directory_name: object()}
+    app.state.scheduler_lock = asyncio.Lock()
+    attempts = []
+
+    async def fake_run_publish_action(post_dir: Path, config, progress):
+        attempts.append(post_dir)
+        return {"status": "failed", "output": "still unavailable"}
+
+    monkeypatch.setattr(app_module, "run_publish_action_with_progress", fake_run_publish_action)
+
+    asyncio.run(app_module.run_publish_operation(app, directory_name, Path("/tmp/retry-once"), None))
+
+    assert attempts == [Path("/tmp/retry-once"), Path("/tmp/retry-once")]
+    operation = app.state.operations[directory_name]
+    assert operation["completed"] is True
+    assert operation["result"] == "failed"
+    assert directory_name not in app.state.operation_tasks
diff --git a/linkki-tiedotus/tests/test_storage.py b/linkki-tiedotus/tests/test_storage.py
new file mode 100644
index 0000000..ac6559e
--- /dev/null
+++ b/linkki-tiedotus/tests/test_storage.py
@@ -0,0 +1,588 @@
+from datetime import datetime, timedelta
+from pathlib import Path
+
+from linkki_poster.models import Language, LanguageBinding, PublishBinding, TextSegmentBinding
+from webapp.storage import (
+    append_status_output,
+    clear_status_history,
+    create_broadcast,
+    is_telegram_delete_expired,
+    list_broadcasts,
+    load_metadata,
+    mark_publish_success,
+    mark_publish_success_with_result,
+    mark_scheduled_publish_failure,
+    mark_scheduled,
+    move_broadcast,
+    normalize_saved_text,
+    scheduled_retry_delay,
+    save_broadcast_changes,
+    telegram_delete_deadline,
+    normalize_discussion_pinning_mode,
+    unschedule,
+)
+
+
+def test_title_change_renames_directory_and_updates_slug(tmp_path: Path) -> None:
+    directory_name = create_broadcast(tmp_path, "First title")
+    post_dir = tmp_path / directory_name
+
+    result = save_broadcast_changes(
+        post_dir,
+        {
+            "title": {
+                "base_revision": 0,
+                "value": "Renamed title",
+            },
+            "texts": {},
+            "images": {},
+        },
+    )
+
+    renamed_dir = result["post_dir"]
+    assert renamed_dir.name == "renamed-title"
+    assert renamed_dir.exists()
+    metadata = load_metadata(renamed_dir)
+    assert metadata["title"] == "Renamed title"
+    assert metadata["slug"] == "renamed-title"
+
+
+def test_linked_broadcast_allows_title_and_text_changes_but_rejects_other_locked_fields(tmp_path: Path) -> None:
+    directory_name = create_broadcast(tmp_path, "Linked")
+    post_dir = tmp_path / directory_name
+    binding = PublishBinding(
+        global_message_ids=[],
+        global_attached_to_language=None,
+        languages={
+            Language.FI: LanguageBinding(
+                language=Language.FI,
+                image_message_ids=[],
+                text_segments=[TextSegmentBinding(message_id=101, mode="message")],
+                primary_message_id=101,
+                primary_message_url="https://t.me/c/123/101",
+            )
+        },
+        discussion_message_ids={},
+    )
+    mark_publish_success(post_dir, binding, "ok")
+
+    result = save_broadcast_changes(
+        post_dir,
+        {
+            "title": {"base_revision": 0, "value": "Renamed linked"},
+            "language_order": {"base_revision": 0, "value": ["en", "fi", "sv"]},
+            "images": {
+                "global": {"base_revision": 0, "value": []},
+            },
+            "texts": {
+                "fi": {"base_revision": 0, "value": "Updated"},
+            },
+        },
+    )
+
+    renamed_dir = result["post_dir"]
+    assert renamed_dir.name == "renamed-linked"
+    assert result["results"]["title"]["status"] == "saved"
+    assert result["results"]["language_order"]["status"] == "rejected"
+    assert result["results"]["images"]["status"] == "rejected"
+    assert result["results"]["fi.text"]["status"] == "saved"
+    metadata = load_metadata(renamed_dir)
+    assert metadata["title"] == "Renamed linked"
+    assert metadata["status"] == "modified"
+    assert metadata["dirty_text_languages"] == ["fi"]
+
+
+def test_normalize_saved_text_ignores_trailing_whitespace() -> None:
+    assert normalize_saved_text("Line 1   \nLine 2\t\t\n\n") == "Line 1\nLine 2"
+
+
+def test_unschedule_restores_modified_when_linked_and_dirty(tmp_path: Path) -> None:
+    directory_name = create_broadcast(tmp_path, "Scheduled update")
+    post_dir = tmp_path / directory_name
+    binding = PublishBinding(
+        global_message_ids=[],
+        global_attached_to_language=None,
+        languages={
+            Language.FI: LanguageBinding(
+                language=Language.FI,
+                image_message_ids=[],
+                text_segments=[TextSegmentBinding(message_id=101, mode="message")],
+                primary_message_id=101,
+                primary_message_url="https://t.me/c/123/101",
+            )
+        },
+        discussion_message_ids={},
+    )
+    mark_publish_success(post_dir, binding, "ok")
+    save_broadcast_changes(
+        post_dir,
+        {
+            "texts": {
+                "fi": {"base_revision": 0, "value": "Updated"},
+            },
+        },
+    )
+    mark_scheduled(post_dir, "2026-03-02T12:00:00+02:00")
+
+    unschedule(post_dir)
+
+    metadata = load_metadata(post_dir)
+    assert metadata["status"] == "modified"
+    assert metadata["scheduled_for"] is None
+
+
+def test_create_broadcast_uses_slug_only_directory_name(tmp_path: Path) -> None:
+    directory_name = create_broadcast(tmp_path, "Test Broadcast")
+    assert directory_name == "test-broadcast"
+    assert (tmp_path / "test-broadcast").exists()
+
+
+def test_create_broadcast_defaults_pinning_mode_to_first(tmp_path: Path) -> None:
+    directory_name = create_broadcast(tmp_path, "Pin first")
+
+    metadata = load_metadata(tmp_path / directory_name)
+
+    assert metadata["discussion_pinning_mode"] == "first"
+    assert metadata["delete_pin_service_messages"] is True
+
+
+def test_normalize_discussion_pinning_mode_accepts_none() -> None:
+    assert normalize_discussion_pinning_mode("none") == "none"
+
+
+def test_append_status_output_keeps_previous_entries() -> None:
+    first = append_status_output("", "First log line", "published", "2026-04-17T10:00:00+00:00")
+    combined = append_status_output(first, "Second log line", "updated", "2026-04-17T11:00:00+00:00")
+
+    assert "[2026-04-17T10:00:00+00:00] published" in combined
+    assert "First log line" in combined
+    assert "[2026-04-17T11:00:00+00:00] updated" in combined
+    assert "Second log line" in combined
+
+
+def test_load_metadata_recovers_from_broken_post_json_and_manual_directory_name(tmp_path: Path) -> None:
+    post_dir = tmp_path / "manual broadcast_name"
+    post_dir.mkdir()
+    (post_dir / "post.json").write_text("{broken", encoding="utf-8")
+
+    metadata = load_metadata(post_dir)
+
+    assert metadata["title"] == "Manual broadcast name"
+    assert metadata["slug"] == "manual-broadcast-name"
+    assert metadata["status"] == "draft"
+
+
+def test_schedule_draft_can_be_saved_without_scheduling(tmp_path: Path) -> None:
+    directory_name = create_broadcast(tmp_path, "Saved schedule")
+    post_dir = tmp_path / directory_name
+
+    result = save_broadcast_changes(
+        post_dir,
+        {
+            "schedule_draft_for": {
+                "base_revision": 0,
+                "value": "2026-03-30T14:45",
+            },
+            "texts": {},
+            "images": {},
+        },
+    )
+
+    assert result["results"]["schedule_draft_for"]["status"] == "saved"
+    metadata = load_metadata(post_dir)
+    assert metadata["schedule_draft_for"] == "2026-03-30T14:45"
+    assert metadata["scheduled_for"] is None
+    assert metadata["status"] == "draft"
+
+    mark_scheduled(post_dir, "2026-03-30T14:45:00+02:00", "2026-03-30T14:45")
+    unschedule(post_dir)
+
+    metadata = load_metadata(post_dir)
+    assert metadata["schedule_draft_for"] == "2026-03-30T14:45"
+
+
+def test_scheduled_publish_failure_keeps_the_schedule_and_records_backoff(tmp_path: Path) -> None:
+    directory_name = create_broadcast(tmp_path, "Retry scheduled")
+    post_dir = tmp_path / directory_name
+    mark_scheduled(post_dir, "2026-03-30T14:45:00+02:00")
+
+    mark_scheduled_publish_failure(post_dir, "Temporary Telegram connection failure")
+
+    metadata = load_metadata(post_dir)
+    assert metadata["status"] == "scheduled"
+    assert metadata["scheduled_for"] == "2026-03-30T14:45:00+02:00"
+    assert metadata["scheduled_retry_count"] == 1
+    assert metadata["scheduled_next_attempt_at"] is not None
+    assert metadata["last_result"] == "scheduled_publish_retry"
+    assert "Temporary Telegram connection failure" in metadata["last_output"]
+
+
+def test_scheduled_retry_backoff_caps_at_thirty_minutes() -> None:
+    assert scheduled_retry_delay(1) == timedelta(minutes=1)
+    assert scheduled_retry_delay(2) == timedelta(minutes=5)
+    assert scheduled_retry_delay(3) == timedelta(minutes=15)
+    assert scheduled_retry_delay(4) == timedelta(minutes=30)
+    assert scheduled_retry_delay(99) == timedelta(minutes=30)
+
+
+def test_telegram_target_ids_are_saved_for_unlinked_broadcast(tmp_path: Path) -> None:
+    directory_name = create_broadcast(tmp_path, "Telegram target test")
+    post_dir = tmp_path / directory_name
+
+    result = save_broadcast_changes(
+        post_dir,
+        {
+            "telegram_bot_id": {
+                "base_revision": 0,
+                "value": "123456",
+            },
+            "telegram_chat_id": {
+                "base_revision": 0,
+                "value": "-100123",
+            },
+            "texts": {},
+            "images": {},
+        },
+    )
+
+    assert result["results"]["telegram_bot_id"]["status"] == "saved"
+    assert result["results"]["telegram_chat_id"]["status"] == "saved"
+    metadata = load_metadata(post_dir)
+    assert metadata["telegram_bot_id"] == "123456"
+    assert metadata["telegram_chat_id"] == "-100123"
+
+
+def test_linked_broadcast_rejects_telegram_target_id_change(tmp_path: Path) -> None:
+    directory_name = create_broadcast(tmp_path, "Target lock")
+    post_dir = tmp_path / directory_name
+    binding = PublishBinding(
+        global_message_ids=[],
+        global_attached_to_language=None,
+        languages={
+            Language.FI: LanguageBinding(
+                language=Language.FI,
+                image_message_ids=[],
+                text_segments=[TextSegmentBinding(message_id=101, mode="message")],
+                primary_message_id=101,
+                primary_message_url="https://t.me/c/123/101",
+            )
+        },
+        discussion_message_ids={},
+    )
+    mark_publish_success(post_dir, binding, "ok")
+
+    result = save_broadcast_changes(
+        post_dir,
+        {
+            "telegram_bot_id": {"base_revision": 0, "value": "999999"},
+            "telegram_chat_id": {"base_revision": 0, "value": "-100999"},
+            "texts": {},
+            "images": {},
+        },
+    )
+
+    assert result["results"]["telegram_bot_id"]["status"] == "rejected"
+    assert result["results"]["telegram_chat_id"]["status"] == "rejected"
+
+
+def test_scheduled_broadcast_rejects_telegram_target_id_change(tmp_path: Path) -> None:
+    directory_name = create_broadcast(tmp_path, "Scheduled target lock")
+    post_dir = tmp_path / directory_name
+    mark_scheduled(post_dir, "2026-03-02T12:00:00+02:00")
+
+    result = save_broadcast_changes(
+        post_dir,
+        {
+            "telegram_bot_id": {"base_revision": 0, "value": "999999"},
+            "telegram_chat_id": {"base_revision": 0, "value": "-100999"},
+            "texts": {},
+            "images": {},
+        },
+    )
+
+    assert result["results"]["telegram_bot_id"]["status"] == "rejected"
+    assert result["results"]["telegram_chat_id"]["status"] == "rejected"
+
+
+def test_publish_success_sets_telegram_published_at_and_delete_deadline(tmp_path: Path) -> None:
+    directory_name = create_broadcast(tmp_path, "Delete window")
+    post_dir = tmp_path / directory_name
+    binding = PublishBinding(
+        global_message_ids=[],
+        global_attached_to_language=None,
+        languages={
+            Language.FI: LanguageBinding(
+                language=Language.FI,
+                image_message_ids=[],
+                text_segments=[TextSegmentBinding(message_id=101, mode="message")],
+                primary_message_id=101,
+                primary_message_url="https://t.me/c/123/101",
+            )
+        },
+        discussion_message_ids={},
+    )
+
+    mark_publish_success(post_dir, binding, "ok")
+
+    metadata = load_metadata(post_dir)
+    assert metadata["telegram_published_at"] is not None
+    deadline = telegram_delete_deadline(metadata)
+    assert deadline is not None
+    assert deadline > datetime.fromisoformat(metadata["telegram_published_at"])
+
+
+def test_delete_expiry_uses_original_publish_time_not_last_update(tmp_path: Path) -> None:
+    directory_name = create_broadcast(tmp_path, "Delete expiry")
+    post_dir = tmp_path / directory_name
+    binding = PublishBinding(
+        global_message_ids=[],
+        global_attached_to_language=None,
+        languages={
+            Language.FI: LanguageBinding(
+                language=Language.FI,
+                image_message_ids=[],
+                text_segments=[TextSegmentBinding(message_id=101, mode="message")],
+                primary_message_id=101,
+                primary_message_url="https://t.me/c/123/101",
+            )
+        },
+        discussion_message_ids={},
+    )
+    mark_publish_success(post_dir, binding, "ok")
+    metadata = load_metadata(post_dir)
+    original_publish_time = metadata["telegram_published_at"]
+
+    save_broadcast_changes(
+        post_dir,
+        {
+            "texts": {
+                "fi": {"base_revision": 0, "value": "Updated"},
+            },
+        },
+    )
+
+    metadata = load_metadata(post_dir)
+    assert metadata["telegram_published_at"] == original_publish_time
+    assert is_telegram_delete_expired(
+        metadata,
+        now=datetime.fromisoformat(original_publish_time) + timedelta(hours=48, seconds=1),
+    )
+
+
+def test_clear_status_history_keeps_status_but_removes_attempt_log(tmp_path: Path) -> None:
+    directory_name = create_broadcast(tmp_path, "History clear")
+    post_dir = tmp_path / directory_name
+    binding = PublishBinding(
+        global_message_ids=[],
+        global_attached_to_language=None,
+        languages={
+            Language.FI: LanguageBinding(
+                language=Language.FI,
+                image_message_ids=[],
+                text_segments=[TextSegmentBinding(message_id=101, mode="message")],
+                primary_message_id=101,
+                primary_message_url="https://t.me/c/123/101",
+            )
+        },
+        discussion_message_ids={},
+    )
+
+
+def test_linked_announcement_pinning_change_marks_modified_and_unschedule_preserves_it(tmp_path: Path) -> None:
+    directory_name = create_broadcast(tmp_path, "Pinning update")
+    post_dir = tmp_path / directory_name
+    binding = PublishBinding(
+        global_message_ids=[],
+        global_attached_to_language=None,
+        languages={
+            Language.FI: LanguageBinding(
+                language=Language.FI,
+                image_message_ids=[],
+                text_segments=[TextSegmentBinding(message_id=101, mode="message")],
+                primary_message_id=101,
+                primary_message_url="https://t.me/c/123/101",
+                discussion_message_id=201,
+            )
+        },
+        discussion_message_ids={101: 201},
+    )
+    mark_publish_success(post_dir, binding, "ok")
+
+    result = save_broadcast_changes(
+        post_dir,
+        {
+            "discussion_pinning_mode": {"base_revision": 0, "value": "all"},
+            "texts": {},
+            "images": {},
+        },
+    )
+
+    assert result["results"]["discussion_pinning_mode"]["status"] == "saved"
+    metadata = load_metadata(post_dir)
+    assert metadata["status"] == "modified"
+    assert metadata["dirty_discussion_pinning"] is True
+
+    mark_scheduled(post_dir, "2026-03-02T12:00:00+02:00")
+    unschedule(post_dir)
+    metadata = load_metadata(post_dir)
+    assert metadata["status"] == "modified"
+    mark_publish_success(post_dir, binding, "posted output")
+
+    clear_status_history(post_dir)
+
+    metadata = load_metadata(post_dir)
+    assert metadata["status"] == "published"
+    assert metadata["last_attempt_at"] is None
+    assert metadata["last_output"] == ""
+    assert metadata["last_result"] is None
+
+
+def test_linked_announcement_pinning_mode_none_is_saved(tmp_path: Path) -> None:
+    directory_name = create_broadcast(tmp_path, "Pinning none")
+    post_dir = tmp_path / directory_name
+    binding = PublishBinding(
+        global_message_ids=[],
+        global_attached_to_language=None,
+        languages={
+            Language.FI: LanguageBinding(
+                language=Language.FI,
+                image_message_ids=[],
+                text_segments=[TextSegmentBinding(message_id=101, mode="message")],
+                primary_message_id=101,
+                primary_message_url="https://t.me/c/123/101",
+                discussion_message_id=201,
+            )
+        },
+        discussion_message_ids={101: 201},
+    )
+    mark_publish_success(post_dir, binding, "ok")
+
+    result = save_broadcast_changes(
+        post_dir,
+        {
+            "discussion_pinning_mode": {"base_revision": 0, "value": "none"},
+            "texts": {},
+            "images": {},
+        },
+    )
+
+    assert result["results"]["discussion_pinning_mode"]["status"] == "saved"
+    metadata = load_metadata(post_dir)
+    assert metadata["discussion_pinning_mode"] == "none"
+    assert metadata["status"] == "modified"
+    assert metadata["dirty_discussion_pinning"] is True
+
+
+def test_linked_announcement_delete_service_message_toggle_marks_modified(tmp_path: Path) -> None:
+    directory_name = create_broadcast(tmp_path, "Service cleanup update")
+    post_dir = tmp_path / directory_name
+    binding = PublishBinding(
+        global_message_ids=[],
+        global_attached_to_language=None,
+        languages={
+            Language.FI: LanguageBinding(
+                language=Language.FI,
+                image_message_ids=[],
+                text_segments=[TextSegmentBinding(message_id=101, mode="message")],
+                primary_message_id=101,
+                primary_message_url="https://t.me/c/123/101",
+                discussion_message_id=201,
+            )
+        },
+        discussion_message_ids={101: 201},
+    )
+    mark_publish_success(post_dir, binding, "ok")
+
+    result = save_broadcast_changes(
+        post_dir,
+        {
+            "delete_pin_service_messages": {"base_revision": 0, "value": False},
+            "texts": {},
+            "images": {},
+        },
+    )
+
+    assert result["results"]["delete_pin_service_messages"]["status"] == "saved"
+    metadata = load_metadata(post_dir)
+    assert metadata["delete_pin_service_messages"] is False
+    assert metadata["status"] == "modified"
+    assert metadata["dirty_discussion_pinning"] is True
+
+
+def test_publish_success_can_persist_effective_pinning_fallback(tmp_path: Path) -> None:
+    directory_name = create_broadcast(tmp_path, "Fallback pin mode")
+    post_dir = tmp_path / directory_name
+    binding = PublishBinding(
+        global_message_ids=[],
+        global_attached_to_language=None,
+        languages={
+            Language.FI: LanguageBinding(
+                language=Language.FI,
+                image_message_ids=[],
+                text_segments=[TextSegmentBinding(message_id=101, mode="message")],
+                primary_message_id=101,
+                primary_message_url="https://t.me/c/123/101",
+                discussion_message_id=201,
+            )
+        },
+        discussion_message_ids={101: 201},
+    )
+
+    save_broadcast_changes(
+        post_dir,
+        {
+            "discussion_pinning_mode": {"base_revision": 0, "value": "all"},
+            "texts": {},
+            "images": {},
+        },
+    )
+    mark_publish_success_with_result(
+        post_dir,
+        binding,
+        "ok",
+        "updated",
+        effective_discussion_pinning_mode="last",
+    )
+
+    metadata = load_metadata(post_dir)
+    assert metadata["discussion_pinning_mode"] == "last"
+    assert metadata["dirty_discussion_pinning"] is False
+
+
+def test_list_broadcasts_uses_manual_sort_order_instead_of_updated_time(tmp_path: Path) -> None:
+    first = create_broadcast(tmp_path, "First")
+    second = create_broadcast(tmp_path, "Second")
+
+    save_broadcast_changes(
+        tmp_path / first,
+        {
+            "texts": {
+                "fi": {"base_revision": 0, "value": "Updated later"},
+            },
+        },
+    )
+
+    ordered = list_broadcasts(tmp_path)
+
+    assert [item["directory"] for item in ordered] == [second, first]
+
+
+def test_new_broadcasts_are_inserted_at_top_of_sidebar_order(tmp_path: Path) -> None:
+    first = create_broadcast(tmp_path, "First")
+    second = create_broadcast(tmp_path, "Second")
+    third = create_broadcast(tmp_path, "Third")
+
+    ordered = list_broadcasts(tmp_path)
+
+    assert [item["directory"] for item in ordered] == [third, second, first]
+
+
+def test_move_broadcast_swaps_manual_order(tmp_path: Path) -> None:
+    first = create_broadcast(tmp_path, "First")
+    second = create_broadcast(tmp_path, "Second")
+    third = create_broadcast(tmp_path, "Third")
+
+    move_broadcast(tmp_path, first, "up")
+    ordered = list_broadcasts(tmp_path)
+
+    assert [item["directory"] for item in ordered] == [third, first, second]
diff --git a/linkki-tiedotus/tests/test_telegram_targets.py b/linkki-tiedotus/tests/test_telegram_targets.py
new file mode 100644
index 0000000..f229f1d
--- /dev/null
+++ b/linkki-tiedotus/tests/test_telegram_targets.py
@@ -0,0 +1,97 @@
+import json
+from pathlib import Path
+
+from webapp.telegram_targets import (
+    default_target_selection,
+    load_telegram_target_catalog,
+    resolve_target_config,
+)
+
+
+def test_multi_bot_catalog_picks_first_marked_defaults(tmp_path: Path) -> None:
+    config_dir = tmp_path / "config"
+    config_dir.mkdir()
+    (config_dir / "telegram_config.json").write_text(
+        json.dumps(
+            {
+                "bots": [
+                    {
+                        "display_name": "Bot A",
+                        "token": "token-a",
+                        "default": True,
+                        "channels": [
+                            {"display_name": "Channel A1", "channel_id": "-1001", "default": True},
+                            {"display_name": "Channel A2", "channel_id": "-1002", "default": True},
+                        ],
+                    },
+                    {
+                        "display_name": "Bot B",
+                        "token": "token-b",
+                        "default": True,
+                        "channels": [
+                            {"display_name": "Channel B1", "channel_id": "-2001", "default": True},
+                        ],
+                    },
+                ]
+            }
+        ),
+        encoding="utf-8",
+    )
+
+    catalog = load_telegram_target_catalog(config_dir)
+
+    assert default_target_selection(catalog) == ("token-a", "-1001")
+
+
+def test_resolve_target_config_uses_selected_bot_id_and_chat_id(tmp_path: Path) -> None:
+    config_dir = tmp_path / "config"
+    config_dir.mkdir()
+    (config_dir / "telegram_config.json").write_text(
+        json.dumps(
+            {
+                "bots": [
+                    {
+                        "display_name": "Bot A",
+                        "token": "token-a",
+                        "channels": [
+                            {
+                                "display_name": "Channel A1",
+                                "channel_id": "-1001",
+                                "channel_username": "public_a",
+                                "discussion_group_id": "-2001",
+                            },
+                        ],
+                    }
+                ]
+            }
+        ),
+        encoding="utf-8",
+    )
+
+    catalog = load_telegram_target_catalog(config_dir)
+    config = resolve_target_config(catalog, "token-a", "-1001")
+
+    assert config.bot_token == "token-a"
+    assert config.chat_id == "-1001"
+    assert config.channel_username == "public_a"
+    assert config.discussion_group_id == "-2001"
+
+
+def test_telegram_config_requires_top_level_bots_list(tmp_path: Path) -> None:
+    config_dir = tmp_path / "config"
+    config_dir.mkdir()
+    (config_dir / "telegram_config.json").write_text(
+        json.dumps(
+            {
+                "bot_token": "legacy-token",
+            }
+        ),
+        encoding="utf-8",
+    )
+
+    try:
+        load_telegram_target_catalog(config_dir)
+    except Exception as error:
+        assert "top-level 'bots' list" in str(error)
+    else:
+        raise AssertionError("Expected invalid config format to be rejected.")
diff --git a/linkki-tiedotus/tests/test_workflow.py b/linkki-tiedotus/tests/test_workflow.py
new file mode 100644
index 0000000..b489a21
--- /dev/null
+++ b/linkki-tiedotus/tests/test_workflow.py
@@ -0,0 +1,250 @@
+from pathlib import Path
+
+from linkki_poster.models import Config, Language, LanguageAssets
+from linkki_poster.broadcast_ops import order_scan_languages
+from linkki_poster.models import ScanResult
+from linkki_poster.telegram_api import TelegramApiError
+from linkki_poster.workflow import (
+    extract_internal_chat_id,
+    post_assets,
+    resolve_link_target,
+    split_markdown_text_for_messages,
+)
+
+
+class FakeTelegramAPI:
+    def __init__(self, fail_caption_too_long: bool = False) -> None:
+        self.next_message_id = 100
+        self.calls: list[tuple[str, dict]] = []
+        self.fail_caption_too_long = fail_caption_too_long
+
+    def send_media_group(
+        self,
+        chat_id: str,
+        image_paths: list[Path],
+        caption: str | None = None,
+        parse_mode: str | None = None,
+    ) -> list[dict]:
+        self.calls.append(
+            (
+                "send_media_group",
+                {
+                    "chat_id": chat_id,
+                    "images": [path.name for path in image_paths],
+                    "caption": caption,
+                    "parse_mode": parse_mode,
+                },
+            )
+        )
+        if caption is not None and self.fail_caption_too_long:
+            raise TelegramApiError("Bad Request: caption is too long")
+        base = self.next_message_id
+        self.next_message_id += len(image_paths)
+        return [{"message_id": base + index} for index, _ in enumerate(image_paths)]
+
+    def send_message(self, chat_id: str, text: str, parse_mode: str | None = None) -> dict:
+        self.calls.append(
+            (
+                "send_message",
+                {
+                    "chat_id": chat_id,
+                    "text": text,
+                    "parse_mode": parse_mode,
+                },
+            )
+        )
+        message_id = self.next_message_id
+        self.next_message_id += 1
+        return {"message_id": message_id}
+
+    def edit_message_text(self, chat_id: str, message_id: int, text: str, parse_mode: str | None = None) -> dict:
+        self.calls.append(
+            (
+                "edit_message_text",
+                {
+                    "chat_id": chat_id,
+                    "message_id": message_id,
+                    "text": text,
+                    "parse_mode": parse_mode,
+                },
+            )
+        )
+        return {"message_id": message_id}
+
+    def edit_message_caption(
+        self,
+        chat_id: str,
+        message_id: int,
+        caption: str,
+        parse_mode: str | None = None,
+    ) -> dict:
+        self.calls.append(
+            (
+                "edit_message_caption",
+                {
+                    "chat_id": chat_id,
+                    "message_id": message_id,
+                    "caption": caption,
+                    "parse_mode": parse_mode,
+                },
+            )
+        )
+        return {"message_id": message_id}
+
+    def delete_message(self, chat_id: str, message_id: int) -> bool:
+        self.calls.append(
+            (
+                "delete_message",
+                {
+                    "chat_id": chat_id,
+                    "message_id": message_id,
+                },
+            )
+        )
+        return True
+
+
+def test_post_assets_attaches_globals_to_first_language_without_images(tmp_path: Path) -> None:
+    config = Config(
+        bot_token="token",
+        chat_id="-100123",
+        channel_username="",
+    )
+    fi = LanguageAssets(
+        language=Language.FI,
+        text_file=tmp_path / "Finnish.md",
+        text_raw="Read [English](en)",
+        images=[],
+    )
+    en = LanguageAssets(
+        language=Language.EN,
+        text_file=tmp_path / "English.md",
+        text_raw="Hello",
+        images=[tmp_path / "English1.jpg"],
+    )
+    global_images = [tmp_path / "bild1.jpg", tmp_path / "bild2.jpg"]
+
+    api = FakeTelegramAPI()
+    refs = post_assets(api=api, config=config, global_images=global_images, ordered_languages=[fi, en])
+
+    assert api.calls[0][0] == "send_media_group"
+    assert api.calls[0][1]["images"] == ["bild1.jpg", "bild2.jpg"]
+    assert api.calls[0][1]["caption"] is not None
+
+    send_message_calls = [call for call in api.calls if call[0] == "send_message"]
+    assert len(send_message_calls) == 0
+
+    edit_caption_calls = [call for call in api.calls if call[0] == "edit_message_caption"]
+    assert len(edit_caption_calls) >= 1
+    assert 'href="https://t.me/c/123/' in edit_caption_calls[-1][1]["caption"]
+
+    assert len(refs) == 2
+    assert refs[0].language == Language.FI
+    assert refs[1].language == Language.EN
+    assert refs[0].text_mode == "caption"
+
+
+def test_post_assets_posts_globals_first_when_first_language_has_images(tmp_path: Path) -> None:
+    config = Config(bot_token="token", chat_id="-100123", channel_username="")
+    fi = LanguageAssets(
+        language=Language.FI,
+        text_file=tmp_path / "Finnish.md",
+        text_raw="Hei",
+        images=[tmp_path / "suomi1.jpg"],
+    )
+    en = LanguageAssets(
+        language=Language.EN,
+        text_file=tmp_path / "English.md",
+        text_raw="Hello",
+        images=[],
+    )
+    global_images = [tmp_path / "bild1.jpg", tmp_path / "bild2.jpg"]
+
+    api = FakeTelegramAPI()
+    post_assets(api=api, config=config, global_images=global_images, ordered_languages=[fi, en])
+
+    assert api.calls[0][0] == "send_media_group"
+    assert api.calls[0][1]["images"] == ["bild1.jpg", "bild2.jpg"]
+    assert api.calls[0][1]["caption"] is None
+
+
+def test_long_text_with_images_is_not_caption(tmp_path: Path) -> None:
+    config = Config(bot_token="token", chat_id="-100123", channel_username="")
+    long_text = "x" * 1200
+    en = LanguageAssets(
+        language=Language.EN,
+        text_file=tmp_path / "English.md",
+        text_raw=long_text,
+        images=[tmp_path / "english1.jpg"],
+    )
+
+    api = FakeTelegramAPI()
+    refs = post_assets(api=api, config=config, global_images=[], ordered_languages=[en])
+
+    first_call = api.calls[0]
+    assert first_call[0] == "send_media_group"
+    assert first_call[1]["caption"] is None
+    second_call = api.calls[1]
+    assert second_call[0] == "send_message"
+    assert refs[0].text_mode == "message"
+
+
+def test_caption_too_long_error_falls_back_to_media_then_message(tmp_path: Path) -> None:
+    config = Config(bot_token="token", chat_id="-100123", channel_username="")
+    en = LanguageAssets(
+        language=Language.EN,
+        text_file=tmp_path / "English.md",
+        text_raw="short text",
+        images=[tmp_path / "english1.jpg"],
+    )
+
+    api = FakeTelegramAPI(fail_caption_too_long=True)
+    refs = post_assets(api=api, config=config, global_images=[], ordered_languages=[en])
+
+    assert api.calls[0][0] == "send_media_group"
+    assert api.calls[0][1]["caption"] == "short text"
+    assert api.calls[1][0] == "send_media_group"
+    assert api.calls[1][1]["caption"] is None
+    assert api.calls[2][0] == "send_message"
+    assert refs[0].text_mode == "message"
+
+
+def test_split_prefers_double_newline_boundaries() -> None:
+    text = ("a" * 2500) + "\n\n" + ("b" * 2500)
+    chunks = split_markdown_text_for_messages(text, link_target="chan", message_limit=4096)
+
+    assert len(chunks) == 2
+    assert chunks[0].endswith("\n\n")
+    assert chunks[1].startswith("b")
+
+
+def test_split_does_not_force_extra_paragraph_split_when_mid_split_is_needed() -> None:
+    text = ("x" * 5000) + "\n\n" + ("tail" * 10)
+    chunks = split_markdown_text_for_messages(text, link_target="chan", message_limit=4096)
+
+    assert len(chunks) == 2
+    assert chunks[0].endswith("x")
+
+
+def test_resolve_link_target_supports_private_channel_style() -> None:
+    assert resolve_link_target("-1003896755764", "") == "c/3896755764"
+    assert extract_internal_chat_id("-1003896755764") == "3896755764"
+
+
+def test_resolve_link_target_prefers_public_username() -> None:
+    assert resolve_link_target("-1003896755764", "mychannel") == "mychannel"
+
+
+def test_order_scan_languages_respects_explicit_allowlist() -> None:
+    scan_result = ScanResult(
+        global_images=[],
+        languages=[
+            LanguageAssets(language=Language.FI, text_file=Path("Suomi.md"), text_raw="Hei", images=[]),
+            LanguageAssets(language=Language.SV, text_file=Path("Svenska.md"), text_raw="Hej", images=[]),
+            LanguageAssets(language=Language.EN, text_file=Path("English.md"), text_raw="Hello", images=[]),
+        ],
+    )
+
+    ordered = order_scan_languages(scan_result, [Language.FI, Language.SV])
+
+    assert [assets.language for assets in ordered] == [Language.FI, Language.SV]
diff --git a/linkki-tiedotus/webapp/__init__.py b/linkki-tiedotus/webapp/__init__.py
new file mode 100644
index 0000000..c20b6fe
--- /dev/null
+++ b/linkki-tiedotus/webapp/__init__.py
@@ -0,0 +1 @@
+"""Web app package for Linkki."""
diff --git a/linkki-tiedotus/webapp/app.py b/linkki-tiedotus/webapp/app.py
new file mode 100644
index 0000000..ad7b43a
--- /dev/null
+++ b/linkki-tiedotus/webapp/app.py
@@ -0,0 +1,1387 @@
+import asyncio
+import logging
+from contextlib import asynccontextmanager
+from datetime import datetime, timedelta
+from mimetypes import guess_type
+from pathlib import Path
+from urllib.error import HTTPError, URLError
+from urllib.parse import unquote, urlparse
+from urllib.request import Request as UrlRequest, urlopen
+from zoneinfo import ZoneInfo
+
+from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
+from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse
+from fastapi.staticfiles import StaticFiles
+from fastapi.templating import Jinja2Templates
+from starlette.middleware.sessions import SessionMiddleware
+
+from linkki_poster.broadcast_ops import (
+    apply_discussion_group_pinning,
+    build_primary_channel_message_ids,
+    delete_binding_from_telegram,
+    ensure_discussion_group_message_ids,
+    publish_directory,
+    snapshot_next_update_offset,
+    update_texts,
+)
+from linkki_poster.formatting import render_preview_html
+from linkki_poster.models import Language, LanguageBinding, PublishBinding, TextSegmentBinding
+from linkki_poster.telegram_api import is_message_not_deletable_error
+from linkki_poster.progress import LogProgressDisplay
+from linkki_poster.workflow import build_message_url, normalize_channel_username, resolve_link_target
+from webapp.auth import SUPPORTED_TRANSLATION_LANGUAGES, load_users, normalize_ui_language, set_user_language, verify_password
+from webapp.config import ConfigFileError, WebAppConfig, load_webapp_config
+from webapp.i18n import language_options, load_translations
+from webapp.storage import (
+    append_status_output,
+    clear_status_history,
+    create_broadcast,
+    delete_from_storage,
+    get_binding,
+    list_broadcasts,
+    load_broadcast,
+    load_metadata,
+    mark_publish_failure,
+    mark_scheduled_publish_failure,
+    mark_publish_success_with_result,
+    mark_scheduled,
+    move_broadcast,
+    normalize_discussion_pinning_mode,
+    now_iso,
+    save_metadata,
+    save_binding,
+    save_broadcast_changes,
+    serialize_binding,
+    set_scheduled_unpin_for,
+    stage_image_upload,
+    telegram_delete_deadline,
+    is_telegram_delete_expired,
+    unlink_telegram,
+    unschedule,
+)
+from webapp.telegram_targets import (
+    default_target_selection,
+    load_telegram_target_catalog,
+    resolve_poster_config,
+    resolve_target_labels,
+    serialise_catalog_for_client,
+)
+
+
+templates = Jinja2Templates(directory=str((Path(__file__).resolve().parent / "templates")))
+STATIC_DIR = Path(__file__).resolve().parent / "static"
+logger = logging.getLogger(__name__)
+
+
+def static_asset_url(asset_name: str) -> str:
+    asset_path = STATIC_DIR / asset_name
+    version = int(asset_path.stat().st_mtime) if asset_path.exists() else 0
+    return f"/static/{asset_name}?v={version}"
+
+
+templates.env.globals["static_asset_url"] = static_asset_url
+
+
+def create_app() -> FastAPI:
+    config = load_webapp_config()
+
+    @asynccontextmanager
+    async def lifespan(app: FastAPI):
+        scheduler = asyncio.create_task(scheduler_loop(app))
+        yield
+        scheduler.cancel()
+        try:
+            await scheduler
+        except asyncio.CancelledError:
+            pass
+
+    app = FastAPI(lifespan=lifespan)
+    app.state.webapp_config = config
+    app.state.scheduler_lock = asyncio.Lock()
+    app.state.operations = {}
+    app.state.operation_tasks = {}
+    app.add_middleware(SessionMiddleware, secret_key=config.session_secret)
+    app.mount("/static", StaticFiles(directory=str(Path(__file__).resolve().parent / "static")), name="static")
+
+    @app.exception_handler(ConfigFileError)
+    async def config_file_error_handler(request: Request, error: ConfigFileError):
+        return PlainTextResponse(str(error), status_code=500)
+
+    @app.get("/login", response_class=HTMLResponse)
+    async def login_page(request: Request):
+        return templates.TemplateResponse(
+            request=request,
+            name="login.html",
+            context=build_login_context(request, None),
+        )
+
+    @app.post("/login", response_class=HTMLResponse)
+    async def login_submit(request: Request, username: str = Form(...), password: str = Form(...)):
+        users = load_users(config.users_file)
+        user_record = users.get(username)
+        if user_record is None or not verify_password(password, user_record["password_hash"]):
+            return templates.TemplateResponse(
+                request=request,
+                name="login.html",
+                context=build_login_context(request, "login.invalid_credentials"),
+                status_code=401,
+            )
+
+        selected_language = normalize_login_language(request.session.get("login_ui_language"))
+        if selected_language != "auto":
+            set_user_language(config.users_file, username, selected_language)
+            user_record["language"] = selected_language
+        request.session["username"] = username
+        request.session["ui_language"] = user_record["language"]
+        request.session.pop("login_ui_language", None)
+        return RedirectResponse("/", status_code=303)
+
+    @app.post("/login-language")
+    async def login_language(request: Request, ui_language: str = Form("auto")):
+        request.session["login_ui_language"] = normalize_login_language(ui_language)
+        return RedirectResponse("/login", status_code=303)
+
+    @app.post("/logout")
+    async def logout(request: Request):
+        request.session.clear()
+        return RedirectResponse("/login", status_code=303)
+
+    @app.get("/", response_class=HTMLResponse)
+    async def index(request: Request):
+        require_auth(request)
+        announcements = list_broadcasts(config.posts_dir)
+        return templates.TemplateResponse(
+            request=request,
+            name="instructions.html",
+            context=build_template_context(request, None, announcements, config, current_path="/"),
+        )
+
+    @app.get("/instructions")
+    async def instructions_page(request: Request):
+        require_auth(request)
+        return RedirectResponse("/", status_code=303)
+
+    @app.post("/language")
+    async def change_language(request: Request, ui_language: str = Form(...), return_to: str = Form("/")):
+        current_user = get_current_user_record(request, config)
+        normalized_language = normalize_ui_language(ui_language)
+        set_user_language(config.users_file, current_user["username"], normalized_language)
+        request.session["ui_language"] = normalized_language
+        if not return_to.startswith("/"):
+            return_to = "/"
+        return RedirectResponse(return_to, status_code=303)
+
+    @app.post("/announcements", response_class=HTMLResponse)
+    async def create_broadcast_route(request: Request, title: str = Form("New announcement"), return_to: str = Form("/")):
+        require_permission(request, config, "save_announcements")
+        try:
+            default_bot_id, default_chat_id = default_target_selection(load_telegram_target_catalog(config.config_dir))
+            directory_name = create_broadcast(
+                config.posts_dir,
+                title or "New announcement",
+                default_bot_id=default_bot_id,
+                default_chat_id=default_chat_id,
+            )
+        except ValueError as error:
+            announcements = list_broadcasts(config.posts_dir)
+            create_error = str(error)
+            if return_to.startswith("/announcements/"):
+                directory_name = return_to.removeprefix("/announcements/").strip("/")
+                post_dir = config.posts_dir / directory_name
+                if post_dir.exists():
+                    broadcast = load_broadcast(post_dir)
+                    return templates.TemplateResponse(
+                        request=request,
+                        name="editor.html",
+                        context=build_template_context(
+                            request,
+                            broadcast,
+                            announcements,
+                            config,
+                            current_path=return_to,
+                            create_error=create_error,
+                        ),
+                        status_code=409,
+                    )
+            return templates.TemplateResponse(
+                request=request,
+                name="instructions.html",
+                context=build_template_context(
+                    request,
+                    None,
+                    announcements,
+                    config,
+                    current_path=return_to if return_to.startswith("/") else "/",
+                    create_error=create_error,
+                ),
+                status_code=409,
+            )
+        return RedirectResponse(f"/announcements/{directory_name}", status_code=303)
+
+    @app.post("/announcements/{directory_name}/move")
+    async def move_broadcast_route(
+        request: Request,
+        directory_name: str,
+        direction: str = Form(...),
+        return_to: str = Form("/"),
+    ):
+        require_permission(request, config, "save_announcements")
+        move_broadcast(config.posts_dir, directory_name, direction)
+        if not return_to.startswith("/"):
+            return_to = "/"
+        return RedirectResponse(return_to, status_code=303)
+
+    @app.get("/announcements/{directory_name}", response_class=HTMLResponse)
+    async def broadcast_editor(request: Request, directory_name: str):
+        require_auth(request)
+        post_dir = config.posts_dir / directory_name
+        if not post_dir.exists():
+            raise HTTPException(status_code=404)
+        broadcast = load_broadcast(post_dir)
+        announcements = list_broadcasts(config.posts_dir)
+        return templates.TemplateResponse(
+            request=request,
+            name="editor.html",
+            context=build_template_context(
+                request,
+                broadcast,
+                announcements,
+                config,
+                current_path=f"/announcements/{directory_name}",
+            ),
+        )
+
+    @app.get("/api/announcements/{directory_name}")
+    async def broadcast_editor_state(request: Request, directory_name: str):
+        require_api_auth(request)
+        post_dir = config.posts_dir / directory_name
+        if not post_dir.exists():
+            raise HTTPException(status_code=404)
+        broadcast = load_broadcast(post_dir)
+        return JSONResponse(build_editor_api_payload(request, config, broadcast))
+
+    @app.get("/announcements/{directory_name}/images/{filename}")
+    async def broadcast_image(request: Request, directory_name: str, filename: str):
+        require_auth(request)
+        image_path = config.posts_dir / directory_name / filename
+        if not image_path.exists() or not image_path.is_file():
+            raise HTTPException(status_code=404)
+        return FileResponse(image_path)
+
+    @app.post("/api/announcements/{directory_name}/save")
+    async def save_broadcast_route(request: Request, directory_name: str):
+        current_user = require_permission(request, config, "save_announcements", api=True)
+        post_dir = config.posts_dir / directory_name
+        payload = await request.json()
+        validate_save_permissions(payload, current_user["permissions"])
+        result = save_broadcast_changes(post_dir, payload)
+        broadcast = load_broadcast(result["post_dir"])
+        return JSONResponse(build_editor_api_payload(request, config, broadcast, save_result=result["results"]))
+
+    @app.post("/api/announcements/{directory_name}/upload-image")
+    async def upload_image_route(
+        request: Request,
+        directory_name: str,
+        scope: str = Form(...),
+        file: UploadFile = File(...),
+    ):
+        require_permission(request, config, "edit_content", api=True)
+        post_dir = config.posts_dir / directory_name
+        content = await file.read()
+        staged = stage_image_upload(post_dir, scope, file.filename or "upload.bin", content)
+        return JSONResponse(staged)
+
+    @app.post("/api/announcements/{directory_name}/import-image-url")
+    async def import_image_url_route(request: Request, directory_name: str):
+        require_permission(request, config, "edit_content", api=True)
+        post_dir = config.posts_dir / directory_name
+        payload = await request.json()
+        raw_url = str(payload.get("url", "")).strip()
+        filename, content = download_image_from_url(raw_url)
+        staged = stage_image_upload(post_dir, str(payload.get("scope", "")).strip(), filename, content)
+        return JSONResponse(staged)
+
+    @app.post("/api/announcements/{directory_name}/set-telegram-mapping")
+    async def set_telegram_mapping_route(request: Request, directory_name: str):
+        require_permission(request, config, "edit_targets", api=True)
+        payload = await request.json()
+        post_dir = config.posts_dir / directory_name
+        broadcast = load_broadcast(post_dir)
+        metadata = broadcast["metadata"]
+        binding = get_binding(metadata)
+        raw_rows = payload.get("rows", [])
+        if not isinstance(raw_rows, list):
+            raise HTTPException(status_code=400, detail="Telegram mapping rows must be provided as a list.")
+
+        target_bot_id = str(payload.get("telegram_bot_id", metadata.get("telegram_bot_id", ""))).strip()
+        target_chat_id = str(payload.get("telegram_chat_id", metadata.get("telegram_chat_id", ""))).strip()
+        if not target_bot_id or not target_chat_id:
+            raise HTTPException(status_code=400, detail="Choose a Telegram bot and chat before saving mapping.")
+        poster_config = resolve_poster_config(config.config_dir, target_bot_id, target_chat_id)
+
+        updated_binding = build_binding_from_manual_rows(metadata, binding, raw_rows, poster_config)
+        metadata["telegram_bot_id"] = target_bot_id
+        metadata["telegram_chat_id"] = target_chat_id
+        metadata["field_revisions"]["telegram_bot_id"] = int(metadata["field_revisions"]["telegram_bot_id"]) + 1
+        metadata["field_revisions"]["telegram_chat_id"] = int(metadata["field_revisions"]["telegram_chat_id"]) + 1
+        metadata["telegram_binding"] = serialize_binding(updated_binding)
+        timestamp = now_iso()
+        if binding_has_channel_links(updated_binding):
+            metadata["status"] = "published"
+            metadata["telegram_published_at"] = metadata.get("telegram_published_at") or timestamp
+        else:
+            metadata["status"] = "draft"
+            metadata["telegram_published_at"] = None
+        metadata["updated_at"] = timestamp
+        save_metadata(post_dir, metadata)
+        return JSONResponse(build_editor_api_payload(request, config, load_broadcast(post_dir)))
+
+    @app.post("/api/preview")
+    async def preview_route(request: Request):
+        require_api_auth(request)
+        payload = await request.json()
+        raw_text = str(payload.get("text", ""))
+        available_targets = {
+            Language(language)
+            for language in payload.get("available_targets", [])
+            if language in {"fi", "sv", "en"}
+        }
+        return JSONResponse({"html": render_preview_html(raw_text, available_targets)})
+
+    @app.post("/api/announcements/{directory_name}/publish")
+    async def publish_route(request: Request, directory_name: str):
+        require_permission(request, config, "post_now", api=True)
+        post_dir = config.posts_dir / directory_name
+        broadcast = load_broadcast(post_dir)
+        metadata = broadcast["metadata"]
+        validate_selected_target(config, metadata)
+        if metadata["scheduled_for"]:
+            raise HTTPException(status_code=400, detail="Scheduled announcements must be unscheduled before posting now.")
+        if metadata["status"] == "published":
+            raise HTTPException(status_code=400, detail="Published announcements must be unlinked before posting again.")
+        if directory_name in request.app.state.operation_tasks:
+            operation = request.app.state.operations[directory_name]
+            return JSONResponse(build_editor_api_payload(request, config, broadcast, operation=operation), status_code=202)
+
+        operation = {
+            "action": "publish",
+            "running": True,
+            "completed": False,
+            "output": "",
+            "result": None,
+            "error": None,
+        }
+        request.app.state.operations[directory_name] = operation
+        task = asyncio.create_task(run_publish_operation(request.app, directory_name, post_dir, config))
+        request.app.state.operation_tasks[directory_name] = task
+        return JSONResponse(build_editor_api_payload(request, config, broadcast, operation=operation), status_code=202)
+
+    @app.get("/api/announcements/{directory_name}/operation")
+    async def operation_status_route(request: Request, directory_name: str):
+        require_api_auth(request)
+        post_dir = config.posts_dir / directory_name
+        broadcast = load_broadcast(post_dir) if post_dir.exists() else None
+        operation = request.app.state.operations.get(
+            directory_name,
+            {
+                "action": None,
+                "running": False,
+                "completed": False,
+                "output": "",
+                "result": None,
+                "error": None,
+            },
+        )
+        return JSONResponse(build_editor_api_payload(request, config, broadcast, operation=operation))
+
+    @app.post("/api/announcements/{directory_name}/schedule")
+    async def schedule_route(request: Request, directory_name: str):
+        require_permission(request, config, "schedule", api=True)
+        post_dir = config.posts_dir / directory_name
+        metadata = load_metadata(post_dir)
+        validate_selected_target(config, metadata)
+        if get_binding(metadata) is not None and not (
+            metadata["dirty_text_languages"] or metadata.get("dirty_discussion_pinning", False)
+        ):
+            raise HTTPException(status_code=400, detail="Published announcements need saved text changes before scheduling modifications.")
+        payload = await request.json()
+        schedule_value = str(payload["scheduled_for"]).strip()
+        scheduled_iso = convert_local_schedule_to_iso(schedule_value, config.timezone)
+        mark_scheduled(post_dir, scheduled_iso, schedule_value)
+        broadcast = load_broadcast(post_dir)
+        return JSONResponse(build_editor_api_payload(request, config, broadcast))
+
+    @app.post("/api/announcements/{directory_name}/unschedule")
+    async def unschedule_route(request: Request, directory_name: str):
+        require_permission(request, config, "unschedule", api=True)
+        post_dir = config.posts_dir / directory_name
+        unschedule(post_dir)
+        broadcast = load_broadcast(post_dir)
+        return JSONResponse(build_editor_api_payload(request, config, broadcast))
+
+    @app.post("/api/announcements/{directory_name}/schedule-unpin")
+    async def schedule_unpin_route(request: Request, directory_name: str):
+        require_permission(request, config, "schedule", api=True)
+        require_permission(request, config, "edit_targets", api=True)
+        post_dir = config.posts_dir / directory_name
+        metadata = load_metadata(post_dir)
+        payload = await request.json()
+        schedule_value = str(payload["scheduled_for"]).strip()
+        scheduled_iso = convert_local_schedule_to_iso(schedule_value, config.timezone)
+        validate_selected_target(config, metadata)
+        poster_config = resolve_effective_poster_config(config, metadata)
+        if not poster_config.discussion_group_id:
+            raise HTTPException(status_code=400, detail="Selected Telegram chat does not have a discussion group.")
+        if get_binding(metadata) is None and metadata.get("status") != "scheduled":
+            raise HTTPException(status_code=400, detail="Announcement must be linked to Telegram or scheduled before scheduling an unpin.")
+        validate_scheduled_unpin_time(metadata, scheduled_iso)
+        set_scheduled_unpin_for(post_dir, scheduled_iso)
+        return JSONResponse(build_editor_api_payload(request, config, load_broadcast(post_dir)))
+
+    @app.post("/api/announcements/{directory_name}/cancel-unpin")
+    async def cancel_unpin_route(request: Request, directory_name: str):
+        require_permission(request, config, "unschedule", api=True)
+        require_permission(request, config, "edit_targets", api=True)
+        post_dir = config.posts_dir / directory_name
+        set_scheduled_unpin_for(post_dir, None)
+        return JSONResponse(build_editor_api_payload(request, config, load_broadcast(post_dir)))
+
+    @app.post("/api/announcements/{directory_name}/delete-telegram")
+    async def delete_telegram_route(request: Request, directory_name: str):
+        require_permission(request, config, "delete_telegram", api=True)
+        post_dir = config.posts_dir / directory_name
+        metadata = load_metadata(post_dir)
+        payload = await request.json()
+        binding = get_binding(metadata)
+        if binding is None:
+            raise HTTPException(status_code=400, detail="Announcement is not linked to Telegram.")
+        delete_expired = is_telegram_delete_expired(metadata)
+        progress = LogProgressDisplay()
+        try:
+            poster_config = resolve_delete_target_config(config, metadata, payload)
+        except ValueError as error:
+            raise HTTPException(status_code=400, detail=str(error)) from error
+        result = delete_binding_from_telegram(poster_config, binding, progress)
+        if result.failures:
+            if result.binding is not None:
+                save_binding(post_dir, result.binding)
+            first_error = result.failures[0]
+            if delete_expired and is_message_not_deletable_error(first_error):
+                deadline = telegram_delete_deadline(metadata)
+                detail = (
+                    "Telegram no longer allows the bot to delete these messages after 48 hours. "
+                    f"They became non-deletable at {format_timestamp_for_timezone(deadline.isoformat(), config.timezone)}. "
+                    "Delete them manually in Telegram if needed."
+                )
+            elif len(result.failures) == 1:
+                detail = first_error
+            else:
+                detail = f"{len(result.failures)} messages could not be deleted. First error: {first_error}"
+            return JSONResponse(
+                build_editor_api_payload(
+                    request,
+                    config,
+                    load_broadcast(post_dir),
+                    output=progress.get_output(),
+                    warning=detail,
+                )
+            )
+        if result.binding is None:
+            unlink_telegram(post_dir)
+        else:
+            save_binding(post_dir, result.binding)
+        broadcast = load_broadcast(post_dir)
+        warning = None
+        if result.warnings:
+            warning = (
+                "Some Telegram messages were already missing and may have been deleted manually. "
+                "Local Telegram links were updated to match the remaining messages."
+            )
+        return JSONResponse(
+            build_editor_api_payload(
+                request,
+                config,
+                broadcast,
+                output=progress.get_output(),
+                warning=warning,
+            )
+        )
+
+    @app.post("/api/announcements/{directory_name}/unlink")
+    async def unlink_route(request: Request, directory_name: str):
+        require_permission(request, config, "unlink_telegram", api=True)
+        post_dir = config.posts_dir / directory_name
+        unlink_telegram(post_dir)
+        broadcast = load_broadcast(post_dir)
+        return JSONResponse(build_editor_api_payload(request, config, broadcast))
+
+    @app.post("/api/announcements/{directory_name}/delete-storage")
+    async def delete_storage_route(request: Request, directory_name: str):
+        require_permission(request, config, "delete_storage", api=True)
+        post_dir = config.posts_dir / directory_name
+        delete_from_storage(post_dir)
+        return JSONResponse({"deleted": True})
+
+    @app.post("/api/announcements/{directory_name}/clear-status")
+    async def clear_status_route(request: Request, directory_name: str):
+        require_permission(request, config, "save_announcements", api=True)
+        post_dir = config.posts_dir / directory_name
+        clear_status_history(post_dir)
+        broadcast = load_broadcast(post_dir)
+        return JSONResponse(build_editor_api_payload(request, config, broadcast))
+
+    return app
+
+
+async def scheduler_loop(app: FastAPI) -> None:
+    config: WebAppConfig = app.state.webapp_config
+    recover_missed_jobs = True
+    while True:
+        async with app.state.scheduler_lock:
+            due_items = []
+            due_unpins = []
+            for item in list_broadcasts(config.posts_dir):
+                if is_scheduled_publish_due(item, recover_missed_jobs):
+                    due_items.append((datetime.fromisoformat(item["scheduled_for"]), item))
+
+                metadata = load_metadata(config.posts_dir / item["directory"])
+                if metadata.get("scheduled_unpin_for"):
+                    scheduled_unpin = datetime.fromisoformat(metadata["scheduled_unpin_for"])
+                    if scheduled_unpin <= datetime.now(scheduled_unpin.tzinfo):
+                        due_unpins.append((scheduled_unpin, item["directory"]))
+
+            for _, item in sorted(due_items, key=lambda item: (item[0], item[1]["directory"].lower())):
+                try:
+                    await run_publish_action(config.posts_dir / item["directory"], config, scheduled_attempt=True)
+                except Exception:
+                    logger.exception("Scheduled publish crashed for %s", item["directory"])
+            for _, directory_name in sorted(due_unpins, key=lambda item: (item[0], item[1].lower())):
+                try:
+                    await run_unpin_action(config.posts_dir / directory_name, config)
+                except Exception:
+                    logger.exception("Scheduled unpin crashed for %s", directory_name)
+        recover_missed_jobs = False
+        await asyncio.sleep(config.scheduler_poll_seconds)
+
+
+def is_scheduled_publish_due(item: dict, recover_missed_jobs: bool) -> bool:
+    if item["status"] != "scheduled" or not item["scheduled_for"]:
+        return False
+    scheduled_time = datetime.fromisoformat(item["scheduled_for"])
+    if scheduled_time > datetime.now(scheduled_time.tzinfo):
+        return False
+    if recover_missed_jobs:
+        return True
+    next_attempt = item.get("scheduled_next_attempt_at")
+    if not next_attempt:
+        return True
+    retry_time = datetime.fromisoformat(next_attempt)
+    return retry_time <= datetime.now(retry_time.tzinfo)
+
+
+async def run_publish_action(
+    post_dir: Path,
+    config: WebAppConfig,
+    scheduled_attempt: bool = False,
+) -> dict:
+    return await perform_publish_action(
+        post_dir,
+        config,
+        LogProgressDisplay(),
+        scheduled_attempt=scheduled_attempt,
+    )
+
+
+async def run_unpin_action(post_dir: Path, config: WebAppConfig) -> dict:
+    metadata = load_metadata(post_dir)
+    binding = get_binding(metadata)
+    if binding is None:
+        set_scheduled_unpin_for(post_dir, None)
+        return {"status": "skipped", "output": "Announcement is not linked to Telegram."}
+
+    poster_config = resolve_effective_poster_config(config, metadata)
+    progress = LogProgressDisplay()
+    ordered_languages = [
+        Language(language)
+        for language in metadata["language_order"]
+        if metadata["enabled_languages"].get(language, True)
+    ]
+    pinning_result = apply_discussion_group_pinning(
+        poster_config,
+        binding,
+        ordered_languages,
+        "none",
+        delete_service_messages=False,
+        progress=progress,
+    )
+    set_scheduled_unpin_for(post_dir, None)
+    metadata = load_metadata(post_dir)
+    metadata["last_attempt_at"] = now_iso()
+    metadata["last_output"] = append_status_output(
+        metadata.get("last_output", ""),
+        progress.get_output(),
+        "updated",
+        metadata["last_attempt_at"],
+    )
+    metadata["last_result"] = "updated"
+    if metadata.get("discussion_pinning_mode") != "none":
+        metadata["discussion_pinning_mode"] = "none"
+        metadata["field_revisions"]["discussion_pinning_mode"] = int(metadata["field_revisions"]["discussion_pinning_mode"]) + 1
+    metadata["dirty_discussion_pinning"] = False
+    metadata["telegram_binding"] = serialize_binding(pinning_result.binding)
+    metadata["updated_at"] = metadata["last_attempt_at"]
+    save_metadata(post_dir, metadata)
+    return {"status": "updated", "output": progress.get_output()}
+
+
+async def run_publish_operation(app: FastAPI, directory_name: str, post_dir: Path, config: WebAppConfig) -> None:
+    operation = app.state.operations[directory_name]
+
+    def on_change(output: str) -> None:
+        operation["output"] = output
+
+    progress = LogProgressDisplay(on_change=on_change)
+    try:
+        async with app.state.scheduler_lock:
+            result = await run_publish_action_with_progress(post_dir, config, progress)
+            if result["status"] == "failed":
+                retry_step = progress.add_step("Retry failed publish", status="in_progress")
+                result = await run_publish_action_with_progress(post_dir, config, progress)
+                progress.update(retry_step, "done" if result["status"] != "failed" else "failed")
+                result["output"] = progress.get_output()
+        operation["running"] = False
+        operation["completed"] = True
+        operation["result"] = result["status"]
+        operation["output"] = result["output"]
+        operation["error"] = None
+    except Exception as error:
+        operation["running"] = False
+        operation["completed"] = True
+        operation["result"] = "failed"
+        operation["error"] = str(error)
+        if progress.get_output():
+            operation["output"] = progress.get_output() + "\n" + str(error)
+        else:
+            operation["output"] = str(error)
+    finally:
+        app.state.operation_tasks.pop(directory_name, None)
+
+
+async def run_publish_action_with_progress(post_dir: Path, config: WebAppConfig, progress: LogProgressDisplay) -> dict:
+    return await perform_publish_action(post_dir, config, progress)
+
+
+async def perform_publish_action(
+    post_dir: Path,
+    config: WebAppConfig,
+    progress: LogProgressDisplay,
+    scheduled_attempt: bool = False,
+) -> dict:
+    metadata = load_metadata(post_dir)
+    binding = get_binding(metadata)
+    published_binding = None
+
+    try:
+        poster_config = resolve_effective_poster_config(config, metadata)
+        disable_link_previews = {
+            Language(language): bool(metadata["disable_link_previews"].get(language, False))
+            for language in metadata["language_order"]
+        }
+        ordered_languages = [
+            Language(language)
+            for language in metadata["language_order"]
+            if metadata["enabled_languages"].get(language, True)
+        ]
+        if binding is not None and (metadata["dirty_text_languages"] or metadata.get("dirty_discussion_pinning", False)):
+            updated_binding = binding
+            effective_pinning_mode = metadata["discussion_pinning_mode"]
+            if metadata["dirty_text_languages"]:
+                updated_binding = update_texts(
+                    directory=post_dir,
+                    config=poster_config,
+                    binding=updated_binding,
+                    changed_languages={Language(language) for language in metadata["dirty_text_languages"]},
+                    disable_link_previews=disable_link_previews,
+                    progress=progress,
+                )
+            if poster_config.discussion_group_id and metadata.get("dirty_discussion_pinning", False):
+                if has_complete_discussion_links(updated_binding, ordered_languages):
+                    pinning_result = apply_discussion_group_pinning(
+                        poster_config,
+                        updated_binding,
+                        ordered_languages,
+                        metadata["discussion_pinning_mode"],
+                        delete_service_messages=bool(metadata.get("delete_pin_service_messages", False)),
+                        progress=progress,
+                    )
+                    updated_binding = pinning_result.binding
+                    if pinning_result.pinning_failed and effective_pinning_mode != "last":
+                        effective_pinning_mode = "last"
+                        progress.add_step(
+                            "Discussion pinning fallback",
+                            status="done",
+                            detail="Pinning failed, so the saved mode was changed to only last message.",
+                        )
+                elif effective_pinning_mode not in {"none", "last"}:
+                    effective_pinning_mode = "last"
+                    progress.add_step(
+                        "Discussion pinning unavailable",
+                        status="done",
+                        detail="Discussion-group links are incomplete, so the saved mode was changed to only last message until links are repaired.",
+                    )
+            mark_publish_success_with_result(
+                post_dir,
+                updated_binding,
+                progress.get_output(),
+                "updated",
+                effective_discussion_pinning_mode=effective_pinning_mode,
+            )
+            return {"status": "updated", "output": progress.get_output()}
+
+        update_offset = 0
+        if poster_config.discussion_group_id:
+            update_offset = snapshot_next_update_offset(poster_config)
+
+        publish_result = publish_directory(
+            directory=post_dir,
+            config=poster_config,
+            ordered_languages=ordered_languages,
+            disable_link_previews=disable_link_previews,
+            use_captions=True,
+            progress=progress,
+        )
+        published_binding = publish_result.binding
+        effective_pinning_mode = metadata["discussion_pinning_mode"]
+        if poster_config.discussion_group_id:
+            published_binding, update_offset = ensure_discussion_group_message_ids(
+                poster_config,
+                published_binding,
+                ordered_languages,
+                update_offset=update_offset,
+                progress=progress,
+                required=False,
+            )
+            if not has_complete_discussion_links(published_binding, ordered_languages):
+                updated_binding = published_binding
+                if effective_pinning_mode not in {"none", "last"}:
+                    effective_pinning_mode = "last"
+                    progress.add_step(
+                        "Discussion pinning unavailable",
+                        status="done",
+                        detail="Discussion-group links are incomplete, so the saved mode was changed to only last message until links are repaired.",
+                    )
+            elif metadata["discussion_pinning_mode"] != "last":
+                pinning_result = apply_discussion_group_pinning(
+                    poster_config,
+                    published_binding,
+                    ordered_languages,
+                    metadata["discussion_pinning_mode"],
+                    delete_service_messages=bool(metadata.get("delete_pin_service_messages", False)),
+                    update_offset=update_offset,
+                    progress=progress,
+                )
+                updated_binding = pinning_result.binding
+                if pinning_result.pinning_failed and effective_pinning_mode != "last":
+                    effective_pinning_mode = "last"
+                    progress.add_step(
+                        "Discussion pinning fallback",
+                        status="done",
+                        detail="Pinning failed, so the saved mode was changed to only last message.",
+                    )
+            else:
+                updated_binding = published_binding
+        else:
+            updated_binding = published_binding
+        mark_publish_success_with_result(
+            post_dir,
+            updated_binding,
+            progress.get_output(),
+            "published",
+            effective_discussion_pinning_mode=effective_pinning_mode,
+        )
+        return {"status": "published", "output": progress.get_output()}
+    except Exception as error:
+        during_update = (
+            binding is not None
+            or published_binding is not None
+        ) and bool(
+            metadata["dirty_text_languages"] or metadata.get("dirty_discussion_pinning", False)
+        )
+        output = progress.get_output()
+        if output:
+            output += "\n"
+        output += str(error)
+        if binding is None and published_binding is not None:
+            if scheduled_attempt:
+                mark_scheduled_publish_failure(post_dir, output, partial_binding=published_binding)
+                return {"status": "failed", "output": output}
+            mark_publish_success_with_result(post_dir, published_binding, output, "published")
+            metadata_after_partial_publish = load_metadata(post_dir)
+            metadata_after_partial_publish["status"] = "modified"
+            metadata_after_partial_publish["last_result"] = "update_failed"
+            metadata_after_partial_publish["dirty_discussion_pinning"] = True
+            save_metadata(post_dir, metadata_after_partial_publish)
+            return {"status": "failed", "output": output}
+        if scheduled_attempt:
+            mark_scheduled_publish_failure(post_dir, output)
+        else:
+            mark_publish_failure(post_dir, output, during_update=during_update)
+        return {"status": "failed", "output": output}
+
+
+def require_auth(request: Request) -> str:
+    username = request.session.get("username")
+    if not username:
+        raise HTTPException(status_code=303, headers={"Location": "/login"})
+    return username
+
+
+def require_api_auth(request: Request) -> str:
+    username = request.session.get("username")
+    if not username:
+        raise HTTPException(status_code=401, detail="Authentication required.")
+    return username
+
+
+def require_permission(request: Request, config: WebAppConfig, permission_key: str, api: bool = False) -> dict:
+    user_record = get_current_user_record(request, config, api=api)
+    permissions = user_record["permissions"]
+    if not permissions.get(permission_key, False):
+        if api:
+            raise HTTPException(status_code=403, detail=f"Permission denied: {permission_key}")
+        raise HTTPException(status_code=403, detail="Permission denied.")
+    return user_record
+
+
+def validate_save_permissions(payload: dict, permissions: dict[str, bool]) -> None:
+    if any(payload.get(field) is not None for field in ("title", "language_order", "enabled_languages", "disable_link_previews")):
+        require_save_capability(permissions, "edit_content")
+
+    for field in ("texts", "images"):
+        value = payload.get(field, {})
+        if isinstance(value, dict) and value:
+            require_save_capability(permissions, "edit_content")
+            break
+
+    if any(
+        payload.get(field) is not None
+        for field in ("telegram_bot_id", "telegram_chat_id", "discussion_pinning_mode", "delete_pin_service_messages")
+    ):
+        require_save_capability(permissions, "edit_targets")
+
+
+def require_save_capability(permissions: dict[str, bool], permission_key: str) -> None:
+    if not permissions.get(permission_key, False):
+        raise HTTPException(status_code=403, detail=f"Permission denied: {permission_key}")
+
+
+def get_current_user_record(request: Request, config: WebAppConfig, api: bool = False) -> dict:
+    username = request.session.get("username")
+    if not username:
+        if api:
+            raise HTTPException(status_code=401, detail="Authentication required.")
+        raise HTTPException(status_code=303, headers={"Location": "/login"})
+
+    users = load_users(config.users_file)
+    user_record = users.get(username)
+    if user_record is None:
+        request.session.clear()
+        if api:
+            raise HTTPException(status_code=401, detail="Authentication required.")
+        raise HTTPException(status_code=303, headers={"Location": "/login"})
+    request.session["ui_language"] = user_record["language"]
+    return {"username": username, **user_record}
+
+
+def resolve_request_translations(request: Request, selected_language: str) -> tuple[str, dict]:
+    effective_language = resolve_effective_ui_language(request, selected_language)
+    return effective_language, load_translations(effective_language)
+
+
+def build_template_context(
+    request: Request,
+    broadcast: dict | None,
+    announcements: list[dict],
+    config: WebAppConfig,
+    current_path: str,
+    create_error: str | None = None,
+) -> dict:
+    current_user = get_current_user_record(request, config)
+    effective_language, translations = resolve_request_translations(request, current_user["language"])
+    return {
+        "request": request,
+        "broadcast": serialize_broadcast_for_client(broadcast, config) if broadcast else None,
+        "announcements": serialize_sidebar_broadcasts(announcements, config, translations),
+        "permissions": current_user["permissions"],
+        "telegram_targets": serialise_catalog_for_client(load_telegram_target_catalog(config.config_dir)),
+        "timezone": config.timezone,
+        "translations": translations,
+        "ui_language": current_user["language"],
+        "effective_ui_language": effective_language,
+        "language_options": language_options(),
+        "current_path": current_path,
+        "create_error": create_error,
+    }
+
+
+def build_login_context(request: Request, error_key: str | None) -> dict:
+    selected_language = normalize_login_language(request.session.get("login_ui_language"))
+    effective_language, translations = resolve_request_translations(request, selected_language)
+    return {
+        "request": request,
+        "translations": translations,
+        "error": translations[error_key] if error_key else None,
+        "login_ui_language": selected_language,
+        "effective_ui_language": effective_language,
+        "login_language_options": [
+            {"code": "auto", "label": translations["ui_language.auto"]},
+            {"code": "fi", "label": translations["ui_language.fi"]},
+            {"code": "sv", "label": translations["ui_language.sv"]},
+            {"code": "en", "label": translations["ui_language.en"]},
+        ],
+    }
+
+
+def serialize_broadcast_for_client(broadcast: dict | None, config: WebAppConfig) -> dict | None:
+    if broadcast is None:
+        return None
+    metadata = broadcast["metadata"]
+    delete_deadline = telegram_delete_deadline(metadata)
+    target_catalog = load_telegram_target_catalog(config.config_dir)
+    return {
+        "directory": broadcast["directory"],
+        "metadata": metadata,
+        "telegram_delete_deadline": delete_deadline.isoformat() if delete_deadline else None,
+        "texts": broadcast["texts"],
+        "images": {
+            scope: [
+                {"name": image_name, "url": f"/announcements/{broadcast['directory']}/images/{image_name}"}
+                for image_name in image_names
+            ]
+            for scope, image_names in broadcast["images"].items()
+        },
+        "telegram_targets": serialise_catalog_for_client(target_catalog),
+        "discussion_group_link_chat_id": discussion_group_link_chat_id(config, metadata),
+        "timezone": config.timezone,
+    }
+
+
+def discussion_group_link_chat_id(config: WebAppConfig, metadata: dict) -> str:
+    try:
+        poster_config = resolve_effective_poster_config(config, metadata)
+    except ValueError:
+        return ""
+
+    discussion_group_id = str(poster_config.discussion_group_id).strip()
+    if discussion_group_id.startswith("-100") and len(discussion_group_id) > 4:
+        return discussion_group_id[4:]
+    return discussion_group_id.lstrip("-")
+
+
+def validate_scheduled_unpin_time(metadata: dict, scheduled_unpin_for: str) -> None:
+    scheduled_publish_for = metadata.get("scheduled_for")
+    if not scheduled_publish_for:
+        return
+
+    scheduled_publish_time = datetime.fromisoformat(str(scheduled_publish_for))
+    scheduled_unpin_time = datetime.fromisoformat(str(scheduled_unpin_for))
+    minimum_unpin_time = scheduled_publish_time + timedelta(minutes=1)
+    if scheduled_unpin_time < minimum_unpin_time:
+        raise HTTPException(status_code=400, detail="Scheduled unpin must be at least 1 minute after the scheduled publish time.")
+
+
+def convert_local_schedule_to_iso(raw_value: str, timezone_name: str) -> str:
+    naive = datetime.fromisoformat(raw_value)
+    aware = naive.replace(tzinfo=ZoneInfo(timezone_name))
+    return aware.isoformat(timespec="seconds")
+
+
+def download_image_from_url(raw_url: str) -> tuple[str, bytes]:
+    parsed = urlparse(raw_url)
+    if parsed.scheme not in {"http", "https"} or not parsed.netloc:
+        raise HTTPException(status_code=400, detail="Provide a valid http or https image URL.")
+
+    request = UrlRequest(raw_url, headers={"User-Agent": "LinkkiBot/1.0"})
+    try:
+        with urlopen(request, timeout=15) as response:
+            content_type = response.headers.get_content_type()
+            if not content_type.startswith("image/"):
+                raise HTTPException(status_code=400, detail="URL did not return an image.")
+            content = response.read()
+    except HTTPError as error:
+        raise HTTPException(status_code=400, detail=f"Image download failed with HTTP {error.code}.") from error
+    except URLError as error:
+        raise HTTPException(status_code=400, detail=f"Image download failed: {error.reason}") from error
+
+    filename = imported_image_filename(raw_url, content_type)
+    return filename, content
+
+
+def imported_image_filename(raw_url: str, content_type: str) -> str:
+    parsed = urlparse(raw_url)
+    basename = Path(unquote(parsed.path)).name
+    extension = image_extension_for_import(basename, content_type)
+    stem = Path(basename).stem or "imported-image"
+    return f"{stem}.{extension}"
+
+
+def image_extension_for_import(filename: str, content_type: str) -> str:
+    by_content_type = {
+        "image/jpeg": "jpg",
+        "image/jpg": "jpg",
+        "image/png": "png",
+        "image/webp": "webp",
+        "image/gif": "gif",
+    }
+    extension = by_content_type.get(content_type.lower())
+    if extension:
+        return extension
+
+    guessed_content_type, _ = guess_type(filename)
+    fallback = {
+        "image/jpeg": "jpg",
+        "image/png": "png",
+        "image/webp": "webp",
+        "image/gif": "gif",
+    }.get((guessed_content_type or "").lower())
+    if fallback:
+        return fallback
+    raise HTTPException(status_code=400, detail="Unsupported image type.")
+
+
+def serialize_sidebar_broadcasts(broadcasts: list[dict], config: WebAppConfig, translations: dict) -> list[dict]:
+    target_catalog = load_telegram_target_catalog(config.config_dir)
+    serialized: list[dict] = []
+    total_items = len(broadcasts)
+    for index, item in enumerate(broadcasts):
+        copied = dict(item)
+        copied["sidebar_meta_lines"] = []
+        copied["can_move_up"] = index > 0
+        copied["can_move_down"] = index < total_items - 1
+        copied["status_symbol"] = status_symbol_for_sidebar(item["status"])
+        copied["sidebar_links_incomplete"] = has_incomplete_sidebar_telegram_links(item)
+        copied["sidebar_has_pins"] = sidebar_has_pins(item)
+        copied["sidebar_pin_status"] = "scheduled" if item.get("scheduled_unpin_for") else item["status"]
+        copied["target_summary"] = build_target_summary(item, target_catalog, translations)
+        if item["status"] in {"published", "modified"} and item.get("last_attempt_at"):
+            label = translations["status.sidebar_updated"] if item.get("last_result") == "updated" else translations["status.sidebar_published"]
+            copied["sidebar_meta_lines"].append(
+                f"{label}: {format_timestamp_for_timezone(item['last_attempt_at'], config.timezone)}"
+            )
+        if item.get("scheduled_for"):
+            copied["sidebar_meta_lines"].append(
+                f"{translations['status.sidebar_scheduled']}: {format_timestamp_for_timezone(item['scheduled_for'], config.timezone)}"
+            )
+        serialized.append(copied)
+    return serialized
+
+
+def status_symbol_for_sidebar(status: str) -> str:
+    return {
+        "published": "\u2714",
+        "failed": "\u2715",
+        "scheduled": "\u25f7",
+        "modified": "\u270e",
+    }.get(status, "")
+
+
+def sidebar_binding_rows(item: dict) -> list[tuple[int | None, int | None]]:
+    binding_payload = item.get("telegram_binding")
+    if not isinstance(binding_payload, dict):
+        return []
+
+    rows: list[tuple[int | None, int | None]] = []
+    global_primary = parse_optional_binding_message_id(binding_payload.get("global_primary_message_id"))
+    global_discussion = parse_optional_binding_message_id(binding_payload.get("global_discussion_message_id"))
+    if global_primary is not None or global_discussion is not None:
+        rows.append((global_primary, global_discussion))
+
+    languages_payload = binding_payload.get("languages", {})
+    if isinstance(languages_payload, dict):
+        for language_payload in languages_payload.values():
+            if not isinstance(language_payload, dict):
+                continue
+            primary_message_id = parse_optional_binding_message_id(language_payload.get("primary_message_id"))
+            discussion_message_id = parse_optional_binding_message_id(language_payload.get("discussion_message_id"))
+            if primary_message_id is not None or discussion_message_id is not None:
+                rows.append((primary_message_id, discussion_message_id))
+    return rows
+
+
+def has_incomplete_sidebar_telegram_links(item: dict) -> bool:
+    rows = sidebar_binding_rows(item)
+    return any(primary_message_id is None or discussion_message_id is None for primary_message_id, discussion_message_id in rows)
+
+
+def sidebar_has_pins(item: dict) -> bool:
+    if not item.get("linked"):
+        return False
+    if has_incomplete_sidebar_telegram_links(item):
+        return False
+    return normalize_discussion_pinning_mode(item.get("discussion_pinning_mode")) != "none"
+
+
+def build_target_summary(item: dict, target_catalog, translations: dict) -> str:
+    bot_id = str(item.get("telegram_bot_id", "")).strip()
+    chat_id = str(item.get("telegram_chat_id", "")).strip()
+    if not bot_id and not chat_id:
+        return translations["sidebar.no_target_selected"]
+
+    resolved_bot_name, resolved_chat_name = resolve_target_labels(target_catalog, bot_id, chat_id)
+    if resolved_bot_name and resolved_chat_name:
+        return f"{resolved_bot_name} / {resolved_chat_name}"
+    if resolved_bot_name:
+        return resolved_bot_name
+    if resolved_chat_name:
+        return resolved_chat_name
+    return translations["sidebar.no_target_selected"]
+
+
+def build_editor_api_payload(
+    request: Request,
+    config: WebAppConfig,
+    broadcast: dict | None,
+    *,
+    operation: dict | None = None,
+    save_result: dict | None = None,
+    output: str | None = None,
+    warning: str | None = None,
+) -> dict:
+    current_user = get_current_user_record(request, config, api=True)
+    effective_language = resolve_effective_ui_language(request, current_user["language"])
+    translations = load_translations(effective_language)
+    payload = {
+        "broadcast": serialize_broadcast_for_client(broadcast, config) if broadcast else None,
+        "announcements": serialize_sidebar_broadcasts(list_broadcasts(config.posts_dir), config, translations),
+    }
+    if operation is not None:
+        payload["operation"] = operation
+    if save_result is not None:
+        payload["save_result"] = save_result
+    if output is not None:
+        payload["output"] = output
+    if warning is not None:
+        payload["warning"] = warning
+    return payload
+
+
+def ordered_binding_languages(metadata: dict) -> list[Language]:
+    ordered: list[Language] = []
+    for item in metadata.get("language_order", []):
+        try:
+            ordered.append(Language(str(item)))
+        except ValueError:
+            continue
+    return ordered
+
+
+def has_complete_discussion_links(binding, ordered_languages: list[Language]) -> bool:
+    if binding is None:
+        return False
+    primary_channel_message_ids = build_primary_channel_message_ids(binding, ordered_languages)
+    return bool(primary_channel_message_ids) and all(
+        channel_message_id in binding.discussion_message_ids
+        for channel_message_id in primary_channel_message_ids
+    )
+
+
+def binding_has_channel_links(binding: PublishBinding | None) -> bool:
+    if binding is None:
+        return False
+    return bool(
+        binding.global_primary_message_id
+        or any(language_binding.primary_message_id for language_binding in binding.languages.values())
+    )
+
+
+def parse_binding_message_id(raw_value: object, label: str) -> int:
+    text = str(raw_value).strip()
+    if not text.lstrip("-").isdigit():
+        raise HTTPException(status_code=400, detail=f"Invalid {label}: {raw_value!r}")
+    return int(text)
+
+
+def parse_optional_binding_message_id(raw_value: object) -> int | None:
+    if raw_value in (None, ""):
+        return None
+    return parse_binding_message_id(raw_value, "message id")
+
+
+def build_binding_from_manual_rows(
+    metadata: dict,
+    existing_binding: PublishBinding | None,
+    rows: list[object],
+    poster_config,
+) -> PublishBinding:
+    link_target = resolve_link_target(poster_config.chat_id, normalize_channel_username(poster_config.channel_username))
+    languages = dict(existing_binding.languages) if existing_binding is not None else {}
+    global_message_ids = list(existing_binding.global_message_ids) if existing_binding is not None else []
+    global_attached_to_language = existing_binding.global_attached_to_language if existing_binding is not None else None
+    discussion_message_ids: dict[int, int] = {}
+    global_primary_message_id = existing_binding.global_primary_message_id if existing_binding is not None else None
+    global_discussion_message_id = existing_binding.global_discussion_message_id if existing_binding is not None else None
+
+    for row in rows:
+        if not isinstance(row, dict):
+            continue
+        row_kind = str(row.get("kind", "")).strip()
+        channel_message_id = parse_optional_binding_message_id(row.get("channel_message_id"))
+        discussion_message_id = parse_optional_binding_message_id(row.get("discussion_message_id"))
+
+        if row_kind == "global":
+            global_message_ids = [channel_message_id] if channel_message_id else []
+            global_primary_message_id = channel_message_id or None
+            global_discussion_message_id = discussion_message_id if channel_message_id else None
+            global_attached_to_language = None if global_message_ids else global_attached_to_language
+            if channel_message_id and discussion_message_id:
+                discussion_message_ids[channel_message_id] = discussion_message_id
+            continue
+
+        if row_kind != "language":
+            continue
+
+        language = Language(str(row.get("language")))
+        existing_language = languages.get(language)
+        if not channel_message_id:
+            languages.pop(language, None)
+            continue
+
+        if existing_language is None:
+            languages[language] = LanguageBinding(
+                language=language,
+                image_message_ids=[],
+                text_segments=[TextSegmentBinding(message_id=channel_message_id, mode="message")] if channel_message_id else [],
+                primary_message_id=channel_message_id,
+                primary_message_url=build_message_url(link_target, channel_message_id) if channel_message_id else "",
+                discussion_message_id=discussion_message_id or None,
+            )
+        elif existing_language.text_segments:
+            text_segments = list(existing_language.text_segments)
+            languages[language] = LanguageBinding(
+                language=language,
+                image_message_ids=list(existing_language.image_message_ids),
+                text_segments=text_segments,
+                primary_message_id=channel_message_id or None,
+                primary_message_url=build_message_url(link_target, channel_message_id) if channel_message_id else "",
+                discussion_message_id=discussion_message_id or None,
+            )
+        elif existing_language.image_message_ids:
+            image_message_ids = list(existing_language.image_message_ids)
+            languages[language] = LanguageBinding(
+                language=language,
+                image_message_ids=image_message_ids,
+                text_segments=[],
+                primary_message_id=channel_message_id or None,
+                primary_message_url=build_message_url(link_target, channel_message_id) if channel_message_id else "",
+                discussion_message_id=discussion_message_id or None,
+            )
+        else:
+            languages[language] = LanguageBinding(
+                language=language,
+                image_message_ids=list(existing_language.image_message_ids),
+                text_segments=list(existing_language.text_segments),
+                primary_message_id=channel_message_id or None,
+                primary_message_url=build_message_url(link_target, channel_message_id) if channel_message_id else "",
+                discussion_message_id=discussion_message_id or None,
+            )
+
+        if channel_message_id and discussion_message_id:
+            discussion_message_ids[channel_message_id] = discussion_message_id
+
+    return PublishBinding(
+        global_message_ids=global_message_ids,
+        global_attached_to_language=global_attached_to_language,
+        languages=languages,
+        discussion_message_ids=discussion_message_ids,
+        global_primary_message_id=global_primary_message_id,
+        global_discussion_message_id=global_discussion_message_id,
+    )
+
+
+def validate_selected_target(config: WebAppConfig, metadata: dict) -> None:
+    try:
+        resolve_effective_poster_config(config, metadata)
+    except ValueError as error:
+        raise HTTPException(status_code=400, detail=str(error)) from error
+
+
+def resolve_delete_target_config(config: WebAppConfig, metadata: dict, payload: dict | None):
+    saved_bot_id = str(metadata.get("telegram_bot_id", "")).strip()
+    saved_chat_id = str(metadata.get("telegram_chat_id", "")).strip()
+    if saved_bot_id and saved_chat_id:
+        return resolve_poster_config(config.config_dir, saved_bot_id, saved_chat_id)
+
+    if saved_bot_id or saved_chat_id:
+        try:
+            return resolve_effective_poster_config(config, metadata)
+        except ValueError:
+            pass
+
+    temporary_bot_id = str((payload or {}).get("telegram_bot_id", "")).strip()
+    temporary_chat_id = str((payload or {}).get("telegram_chat_id", "")).strip()
+    if not temporary_bot_id or not temporary_chat_id:
+        raise ValueError("Choose a temporary Telegram bot and chat before trying to delete this linked announcement.")
+
+    return resolve_poster_config(config.config_dir, temporary_bot_id, temporary_chat_id)
+
+
+def resolve_effective_poster_config(config: WebAppConfig, metadata: dict):
+    bot_id = str(metadata.get("telegram_bot_id", "")).strip()
+    chat_id = str(metadata.get("telegram_chat_id", "")).strip()
+    if bot_id and chat_id:
+        return resolve_poster_config(config.config_dir, bot_id, chat_id)
+
+    raise ValueError("Choose a Telegram bot and chat before posting or scheduling.")
+
+
+def format_timestamp_for_timezone(raw_value: str, timezone_name: str) -> str:
+    source = datetime.fromisoformat(raw_value)
+    target = source.astimezone(ZoneInfo(timezone_name))
+    return target.strftime("%Y-%m-%d %H:%M:%S")
+
+
+def normalize_login_language(value: object) -> str:
+    if isinstance(value, str):
+        lowered = value.strip().lower()
+        if lowered in {"auto", *SUPPORTED_TRANSLATION_LANGUAGES}:
+            return lowered
+    return "auto"
+
+
+def resolve_effective_ui_language(request: Request, selected_language: str) -> str:
+    if selected_language != "auto":
+        return selected_language
+
+    accepted_languages = parse_accept_language(request.headers.get("accept-language", ""))
+    for candidate in ("fi", "sv", "en"):
+        if candidate in accepted_languages:
+            return candidate
+    return "en"
+
+
+def parse_accept_language(header_value: str) -> list[str]:
+    languages: list[str] = []
+    for part in header_value.split(","):
+        token = part.split(";")[0].strip().lower()
+        if not token:
+            continue
+        base = token.split("-")[0]
+        if base in SUPPORTED_TRANSLATION_LANGUAGES and base not in languages:
+            languages.append(base)
+    return languages
+
+
+def create_error_app(message: str) -> FastAPI:
+    app = FastAPI()
+
+    @app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"])
+    async def error_page(path: str):
+        return PlainTextResponse(message, status_code=500)
+
+    return app
+
+
+try:
+    app = create_app()
+except ConfigFileError as error:
+    app = create_error_app(str(error))
diff --git a/linkki-tiedotus/webapp/auth.py b/linkki-tiedotus/webapp/auth.py
new file mode 100644
index 0000000..368f323
--- /dev/null
+++ b/linkki-tiedotus/webapp/auth.py
@@ -0,0 +1,169 @@
+import base64
+import getpass
+import hashlib
+import hmac
+import json
+import os
+from pathlib import Path
+
+from webapp.config import ConfigFileError
+
+
+PBKDF2_ALGORITHM = "sha256"
+PBKDF2_ITERATIONS = 600000
+SALT_BYTES = 16
+SUPPORTED_TRANSLATION_LANGUAGES = ["en", "fi", "sv"]
+SUPPORTED_UI_LANGUAGES = ["auto", "fi", "sv", "en"]
+PERMISSION_KEYS = [
+    "edit_content",
+    "edit_targets",
+    "save_announcements",
+    "open_telegram",
+    "post_now",
+    "schedule",
+    "unschedule",
+    "unlink_telegram",
+    "delete_telegram",
+    "delete_storage",
+]
+
+
+def load_users(users_file: Path) -> dict[str, dict]:
+    if not users_file.exists():
+        return {}
+
+    try:
+        payload = json.loads(users_file.read_text(encoding="utf-8"))
+    except json.JSONDecodeError as error:
+        raise ConfigFileError(users_file, f"Invalid JSON: {error.msg}") from error
+    users = payload.get("users", [])
+    result: dict[str, dict] = {}
+    changed = False
+    for user in users:
+        username = str(user.get("username", "")).strip()
+        password_hash = str(user.get("password_hash", "")).strip()
+        if username and password_hash:
+            permissions_payload, permissions_changed = normalize_permissions_payload(user.get("permissions", {}))
+            if permissions_changed:
+                user["permissions"] = permissions_payload
+                changed = True
+
+            language = normalize_ui_language(user.get("language", "en"))
+            if user.get("language") != language:
+                user["language"] = language
+                changed = True
+
+            result[username] = {
+                "password_hash": password_hash,
+                "permissions": load_permissions(permissions_payload),
+                "language": language,
+            }
+    if changed:
+        users_file.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
+    return result
+
+
+def load_permissions(payload: object) -> dict[str, bool]:
+    source = payload if isinstance(payload, dict) else {}
+    permissions: dict[str, bool] = {}
+    for key in PERMISSION_KEYS:
+        permissions[key] = as_permission_bool(source.get(key, False))
+    return permissions
+
+
+def normalize_permissions_payload(payload: object) -> tuple[dict[str, object], bool]:
+    source = dict(payload) if isinstance(payload, dict) else {}
+    changed = not isinstance(payload, dict)
+    normalized = {key: source[key] for key in source if key in PERMISSION_KEYS}
+    if len(normalized) != len(source):
+        changed = True
+    for key in PERMISSION_KEYS:
+        normalized_value = as_permission_bool(source.get(key, False))
+        if source.get(key) != normalized_value:
+            changed = True
+        normalized[key] = normalized_value
+    return normalized, changed
+
+
+def as_permission_bool(value: object) -> bool:
+    if isinstance(value, bool):
+        return value
+    if isinstance(value, int):
+        return value != 0
+    if isinstance(value, str):
+        lowered = value.strip().lower()
+        return lowered in {"1", "true", "yes", "on"}
+    return False
+
+
+def normalize_ui_language(value: object) -> str:
+    if isinstance(value, str):
+        lowered = value.strip().lower()
+        if lowered in SUPPORTED_UI_LANGUAGES:
+            return lowered
+    return "en"
+
+
+def normalize_translation_language(value: object) -> str:
+    if isinstance(value, str):
+        lowered = value.strip().lower()
+        if lowered in SUPPORTED_TRANSLATION_LANGUAGES:
+            return lowered
+    return "en"
+
+
+def set_user_language(users_file: Path, username: str, language: str) -> None:
+    normalized = normalize_ui_language(language)
+    try:
+        payload = json.loads(users_file.read_text(encoding="utf-8"))
+    except json.JSONDecodeError as error:
+        raise ConfigFileError(users_file, f"Invalid JSON: {error.msg}") from error
+    users = payload.get("users", [])
+    updated = False
+    for user in users:
+        if str(user.get("username", "")).strip() == username:
+            user["language"] = normalized
+            updated = True
+            break
+
+    if not updated:
+        raise ValueError(f"Unknown user: {username}")
+
+    users_file.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
+
+
+def hash_password(password: str) -> str:
+    salt = os.urandom(SALT_BYTES)
+    digest = hashlib.pbkdf2_hmac(PBKDF2_ALGORITHM, password.encode("utf-8"), salt, PBKDF2_ITERATIONS)
+    return "$".join(
+        [
+            "pbkdf2_sha256",
+            str(PBKDF2_ITERATIONS),
+            base64.urlsafe_b64encode(salt).decode("ascii"),
+            base64.urlsafe_b64encode(digest).decode("ascii"),
+        ]
+    )
+
+
+def verify_password(password: str, password_hash: str) -> bool:
+    algorithm, iterations_text, salt_text, digest_text = password_hash.split("$", 3)
+    if algorithm != "pbkdf2_sha256":
+        return False
+
+    iterations = int(iterations_text)
+    salt = base64.urlsafe_b64decode(salt_text.encode("ascii"))
+    expected_digest = base64.urlsafe_b64decode(digest_text.encode("ascii"))
+    actual_digest = hashlib.pbkdf2_hmac(PBKDF2_ALGORITHM, password.encode("utf-8"), salt, iterations)
+    return hmac.compare_digest(actual_digest, expected_digest)
+
+
+def hash_password_cli() -> None:
+    password = getpass.getpass("Password: ")
+    password_repeat = getpass.getpass("Repeat password: ")
+    if password != password_repeat:
+        raise ValueError("Passwords did not match.")
+    print(hash_password(password))
+
+
+if __name__ == "__main__":
+    hash_password_cli()
diff --git a/linkki-tiedotus/webapp/config.py b/linkki-tiedotus/webapp/config.py
new file mode 100644
index 0000000..8123f36
--- /dev/null
+++ b/linkki-tiedotus/webapp/config.py
@@ -0,0 +1,57 @@
+import json
+import os
+from dataclasses import dataclass
+from pathlib import Path
+
+
+DEFAULT_TIMEZONE = "UTC"
+DEFAULT_POLL_SECONDS = 60
+
+
+class ConfigFileError(RuntimeError):
+    def __init__(self, path: Path, message: str):
+        super().__init__(f"{path}: {message}")
+        self.path = path
+        self.message = message
+
+
+@dataclass(frozen=True)
+class WebAppConfig:
+    data_dir: Path
+    posts_dir: Path
+    config_dir: Path
+    users_file: Path
+    timezone: str
+    session_secret: str
+    scheduler_poll_seconds: int
+
+
+def load_webapp_config() -> WebAppConfig:
+    data_dir = Path(os.environ.get("LINKKI_DATA_DIR", "data")).resolve()
+    config_dir = data_dir / "config"
+    posts_dir = data_dir / "posts"
+    config_dir.mkdir(parents=True, exist_ok=True)
+    posts_dir.mkdir(parents=True, exist_ok=True)
+
+    config_path = config_dir / "app_config.json"
+    config_data: dict[str, object] = {}
+    if config_path.exists():
+        try:
+            config_data = json.loads(config_path.read_text(encoding="utf-8"))
+        except json.JSONDecodeError as error:
+            raise ConfigFileError(config_path, f"Invalid JSON: {error.msg}") from error
+
+    timezone = str(config_data.get("timezone", DEFAULT_TIMEZONE))
+    session_secret = str(config_data.get("session_secret", "change-me"))
+    poll_seconds = int(config_data.get("scheduler_poll_seconds", DEFAULT_POLL_SECONDS))
+    users_filename = str(config_data.get("users_file", "users.json"))
+
+    return WebAppConfig(
+        data_dir=data_dir,
+        posts_dir=posts_dir,
+        config_dir=config_dir,
+        users_file=config_dir / users_filename,
+        timezone=timezone,
+        session_secret=session_secret,
+        scheduler_poll_seconds=poll_seconds,
+    )
diff --git a/linkki-tiedotus/webapp/i18n.py b/linkki-tiedotus/webapp/i18n.py
new file mode 100644
index 0000000..f9e6d4e
--- /dev/null
+++ b/linkki-tiedotus/webapp/i18n.py
@@ -0,0 +1,42 @@
+import json
+from pathlib import Path
+
+from webapp.auth import SUPPORTED_UI_LANGUAGES, normalize_translation_language
+from webapp.config import ConfigFileError
+
+
+TRANSLATIONS_DIR = Path(__file__).resolve().parent / "translations"
+TRANSLATION_CATALOG_FILE = TRANSLATIONS_DIR / "catalog.json"
+
+
+def load_translations(language: str) -> dict:
+    normalized = normalize_translation_language(language)
+    catalog = load_translation_catalog()
+    merged: dict[str, object] = {}
+    for key, translations in catalog.items():
+        if not isinstance(translations, dict):
+            continue
+        english_value = translations.get("en")
+        localized_value = translations.get(normalized)
+        if localized_value not in (None, ""):
+            merged[key] = localized_value
+        elif english_value is not None:
+            merged[key] = english_value
+    return merged
+
+
+def load_translation_catalog() -> dict:
+    if not TRANSLATION_CATALOG_FILE.exists():
+        return {}
+    try:
+        payload = json.loads(TRANSLATION_CATALOG_FILE.read_text(encoding="utf-8"))
+    except json.JSONDecodeError as error:
+        detail = f"Invalid JSON: {error.msg}"
+        if "Invalid control character" in error.msg:
+            detail += " JSON strings cannot contain raw line breaks; use \\n instead."
+        raise ConfigFileError(TRANSLATION_CATALOG_FILE, detail) from error
+    return payload if isinstance(payload, dict) else {}
+
+
+def language_options() -> list[dict[str, str]]:
+    return [{"code": code, "label_key": f"ui_language.{code}"} for code in SUPPORTED_UI_LANGUAGES]
diff --git a/linkki-tiedotus/webapp/static/app.css b/linkki-tiedotus/webapp/static/app.css
new file mode 100644
index 0000000..7e6c96f
--- /dev/null
+++ b/linkki-tiedotus/webapp/static/app.css
@@ -0,0 +1,876 @@
+:root {
+  color-scheme: light dark;
+  --bg: Canvas;
+  --fg: CanvasText;
+  --panel: color-mix(in srgb, Canvas 92%, CanvasText 8%);
+  --border: color-mix(in srgb, CanvasText 20%, Canvas 80%);
+  --green: #2f8f4e;
+  --red: #b33c3c;
+  --yellow: #b58a00;
+  --blue: #4b7bd0;
+}
+
+body {
+  margin: 0;
+  font-family: sans-serif;
+  background: var(--bg);
+  color: var(--fg);
+  overflow: hidden;
+}
+
+.layout {
+  display: grid;
+  grid-template-columns: 290px 1fr;
+  height: 100vh;
+  overflow: hidden;
+}
+
+.sidebar,
+.content {
+  padding: 1rem;
+  min-height: 0;
+}
+
+.sidebar {
+  border-right: 1px solid var(--border);
+  background: var(--panel);
+  overflow-y: auto;
+}
+
+.content {
+  min-width: 0;
+  overflow-y: auto;
+}
+
+.sidebar-actions,
+.create-form {
+  display: flex;
+  gap: 0.5rem;
+  margin-bottom: 1rem;
+}
+
+.sidebar-actions {
+  align-items: center;
+  justify-content: space-between;
+}
+
+.sidebar-actions form {
+  margin: 0;
+}
+
+.sidebar-link {
+  text-decoration: none;
+  color: inherit;
+  padding: 0.4rem 0.6rem;
+  border: 1px solid var(--border);
+}
+
+.sidebar-link.active {
+  outline: 2px solid var(--blue);
+}
+
+.language-form {
+  margin-bottom: 1rem;
+}
+
+.language-form label {
+  display: flex;
+  flex-direction: column;
+  gap: 0.35rem;
+}
+
+.create-form input {
+  flex: 1 1 auto;
+  min-width: 0;
+}
+
+.create-form button {
+  flex: 0 0 auto;
+}
+
+.create-form input,
+input,
+textarea,
+select,
+button {
+  font: inherit;
+}
+
+.announcement-list {
+  display: flex;
+  flex-direction: column;
+  gap: 0.5rem;
+}
+
+.announcement-entry {
+  display: grid;
+  grid-template-columns: 1fr auto;
+  gap: 0.4rem;
+  align-items: stretch;
+}
+
+.announcement-item {
+  display: block;
+  padding: 0.75rem;
+  border: 1px solid var(--border);
+  text-decoration: none;
+  color: inherit;
+  border-radius: 0.35rem;
+  background: color-mix(in srgb, var(--panel) 94%, CanvasText 6%);
+}
+
+.announcement-item span,
+.announcement-item small {
+  display: block;
+}
+
+.announcement-title-row {
+  display: flex !important;
+  align-items: center;
+  justify-content: flex-start;
+  gap: 0.35rem;
+}
+
+.status-primary-marker {
+  flex: 0 0 auto;
+}
+
+.status-primary-marker-pinned {
+  width: 0.8rem;
+  height: 0.8rem;
+  display: inline-block;
+  background-color: currentColor;
+  -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M8 3a1 1 0 0 0-1 1v2.2l2 2V11l-4 3v2h6v6l1-1 1-5h6v-2l-4-3V8.2l2-2V4a1 1 0 0 0-1-1H8Z'/%3E%3C/svg%3E");
+  mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M8 3a1 1 0 0 0-1 1v2.2l2 2V11l-4 3v2h6v6l1-1 1-5h6v-2l-4-3V8.2l2-2V4a1 1 0 0 0-1-1H8Z'/%3E%3C/svg%3E");
+  -webkit-mask-repeat: no-repeat;
+  mask-repeat: no-repeat;
+  -webkit-mask-position: center;
+  mask-position: center;
+  -webkit-mask-size: contain;
+  mask-size: contain;
+}
+
+.status-primary-marker-pinned.status-published {
+  color: var(--green);
+}
+
+.status-primary-marker-pinned.status-failed {
+  color: var(--red);
+}
+
+.status-primary-marker-pinned.status-scheduled {
+  color: var(--yellow);
+}
+
+.status-primary-marker-pinned.status-modified {
+  color: var(--blue);
+}
+
+.announcement-title {
+  flex: 1 1 auto;
+  min-width: 0;
+}
+
+.announcement-meta {
+  margin-top: 0.35rem;
+  opacity: 0.8;
+}
+
+.announcement-item.active {
+  outline: 2px solid var(--blue);
+}
+
+.announcement-item:not(.active) {
+  opacity: 0.78;
+  color: color-mix(in srgb, var(--fg) 82%, transparent 18%);
+}
+
+.announcement-item.disabled-nav {
+  opacity: 0.6;
+  pointer-events: none;
+}
+
+.announcement-order-controls {
+  display: flex;
+  flex-direction: column;
+  gap: 0.3rem;
+}
+
+.announcement-order-controls form {
+  margin: 0;
+}
+
+.announcement-order-controls button {
+  width: 2rem;
+  height: 2rem;
+  padding: 0;
+}
+
+.status-symbol {
+  font-size: 1rem;
+  font-weight: 700;
+  line-height: 1;
+}
+
+.status-symbol.status-published {
+  color: var(--green);
+}
+
+.status-symbol.status-failed {
+  color: var(--red);
+}
+
+.status-symbol.status-scheduled {
+  color: var(--yellow);
+}
+
+.status-symbol.status-modified {
+  color: var(--blue);
+}
+
+.status-symbol.status-symbol-incomplete {
+  color: var(--red);
+}
+
+.editor-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 0.75rem;
+  flex-wrap: wrap;
+  margin-bottom: 1rem;
+}
+
+.editor-header h1 {
+  margin: 0 0 0.2rem;
+}
+
+.editor-header p {
+  margin: 0;
+  opacity: 0.8;
+}
+
+.editor-tabs {
+  margin-bottom: 1rem;
+  display: flex;
+  flex-direction: column;
+}
+
+.editor-tab-list {
+  display: flex;
+  gap: 0.2rem;
+  flex-wrap: wrap;
+  margin-bottom: -1px;
+  padding-left: 0.45rem;
+  position: relative;
+  z-index: 1;
+}
+
+.editor-tab-button {
+  position: relative;
+  padding: 0.5rem 0.9rem;
+  border: 1px solid color-mix(in srgb, var(--border) 92%, CanvasText 8%);
+  border-bottom: none;
+  border-radius: 0.6rem 0.6rem 0 0;
+  background: color-mix(in srgb, var(--panel) 88%, Canvas 12%);
+  color: color-mix(in srgb, var(--fg) 82%, transparent 18%);
+  transform: translateY(0.3rem);
+  box-shadow: inset 0 -0.2rem 0 color-mix(in srgb, CanvasText 6%, transparent);
+  cursor: pointer;
+}
+
+.editor-tab-button.active {
+  background: var(--panel);
+  color: var(--fg);
+  transform: translateY(0);
+  box-shadow: none;
+  border-color: var(--border);
+  z-index: 2;
+}
+
+.editor-tab-button.tab-missing {
+  color: var(--red);
+}
+
+.editor-tab-panel[hidden] {
+  display: none;
+}
+
+.editor-tab-panel {
+  border: 1px solid var(--border);
+  border-radius: 0 0.75rem 0.75rem 0.75rem;
+  background: var(--panel);
+  padding: 1rem;
+}
+
+.editor-tab-panel.tab-panel-disabled {
+  opacity: 0.72;
+}
+
+.meta-panel {
+  display: grid;
+  grid-template-columns: minmax(240px, 2fr) minmax(220px, 1.1fr) minmax(180px, 1fr) minmax(220px, 1.1fr);
+  gap: 0.75rem;
+  align-items: start;
+}
+
+.meta-title-field {
+  grid-column: 1;
+  grid-row: 1;
+}
+
+.meta-global-link-preview {
+  grid-column: 1;
+  grid-row: 2;
+}
+
+.warning-text {
+  grid-column: 1 / -1;
+  margin: 0 0 0.25rem;
+  color: var(--red);
+  font-weight: 600;
+}
+
+.meta-panel label,
+.telegram-target-fields,
+.meta-stack {
+  display: flex;
+  flex-direction: column;
+  gap: 0.35rem;
+  min-width: 0;
+}
+
+.telegram-target-fields {
+  grid-column: 2;
+  grid-row: 1 / span 2;
+  gap: 0.5rem;
+}
+
+.meta-stack {
+  grid-column: 3;
+  grid-row: 1 / span 2;
+  align-self: stretch;
+  justify-content: space-between;
+}
+
+.meta-schedule-field {
+  grid-column: 4;
+  grid-row: 1;
+}
+
+.meta-delete-service-field {
+  grid-column: 4;
+  grid-row: 2;
+  align-self: start;
+}
+
+.meta-panel label.meta-checkbox {
+  flex-direction: row;
+  align-items: center;
+  gap: 0.5rem;
+  white-space: nowrap;
+}
+
+.meta-panel label.meta-checkbox input[type="checkbox"] {
+  align-self: center;
+}
+
+.top-actions,
+.image-controls,
+.image-paste-controls,
+.format-toolbar {
+  display: flex;
+  gap: 0.35rem;
+  flex-wrap: wrap;
+}
+
+.top-actions,
+.image-controls,
+.image-paste-controls {
+  margin-top: 0.5rem;
+  margin-bottom: 0.75rem;
+}
+
+.image-import-modal {
+  position: fixed;
+  inset: 0;
+  display: grid;
+  place-items: center;
+  padding: 1rem;
+  background: color-mix(in srgb, CanvasText 24%, transparent);
+  z-index: 20;
+}
+
+.image-import-modal[hidden] {
+  display: none;
+}
+
+.image-import-dialog {
+  width: min(560px, 100%);
+  display: flex;
+  flex-direction: column;
+  gap: 0.75rem;
+  padding: 1rem;
+  border: 1px solid var(--border);
+  background: var(--panel);
+  box-shadow: 0 18px 48px color-mix(in srgb, CanvasText 24%, transparent);
+}
+
+.image-import-header,
+.image-import-actions,
+.telegram-cell-editor,
+.telegram-cell-actions {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  flex-wrap: wrap;
+}
+
+.image-import-header,
+.image-import-actions {
+  justify-content: space-between;
+}
+
+.image-import-header h2,
+.image-import-help {
+  margin: 0;
+}
+
+.image-import-dialog label {
+  display: flex;
+  flex-direction: column;
+  gap: 0.35rem;
+}
+
+.top-actions {
+  align-items: flex-start;
+}
+
+.top-actions button,
+.top-actions .button-link {
+  padding: 0.3rem 0.55rem;
+  line-height: 1.2;
+  min-height: 0;
+  white-space: pre-line;
+}
+
+.button-link {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid var(--border);
+  color: inherit;
+  text-decoration: none;
+  background: ButtonFace;
+}
+
+.permission-disabled {
+  background-image:
+    linear-gradient(
+      45deg,
+      transparent calc(50% - 1px),
+      color-mix(in srgb, var(--red) 72%, transparent) calc(50% - 1px),
+      color-mix(in srgb, var(--red) 72%, transparent) calc(50% + 1px),
+      transparent calc(50% + 1px)
+    ),
+    linear-gradient(
+      -45deg,
+      transparent calc(50% - 1px),
+      color-mix(in srgb, var(--red) 72%, transparent) calc(50% - 1px),
+      color-mix(in srgb, var(--red) 72%, transparent) calc(50% + 1px),
+      transparent calc(50% + 1px)
+    );
+  background-repeat: no-repeat;
+  background-size: 100% 100%;
+  border-color: color-mix(in srgb, var(--red) 60%, var(--border) 40%);
+}
+
+textarea.permission-disabled,
+input.permission-disabled,
+select.permission-disabled,
+button.permission-disabled,
+.button-link.permission-disabled {
+  background-origin: border-box;
+}
+
+.upload-label {
+  align-self: center;
+  font-size: 0.95rem;
+}
+
+.images-panel > h2 {
+  margin: 0 0 1rem;
+}
+
+.status-box,
+.images-panel,
+.language-panel,
+.instructions-panel,
+.preview-panel,
+.pinning-panel {
+  border: 1px solid var(--border);
+  padding: 1rem;
+  margin-bottom: 1rem;
+}
+
+.instruction-section + .instruction-section {
+  margin-top: 1rem;
+}
+
+.language-panel.disabled {
+  opacity: 0.85;
+}
+
+.language-panel.disabled .language-body {
+  display: none;
+}
+
+.image-list {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.75rem;
+}
+
+.image-card {
+  display: flex;
+  flex-direction: column;
+  width: 140px;
+  border: 1px solid var(--border);
+  padding: 0.5rem;
+  background: var(--panel);
+}
+
+.image-card img {
+  max-width: 100%;
+  max-height: 140px;
+  width: auto;
+  height: auto;
+  object-fit: contain;
+  display: block;
+  margin: 0 auto 0.5rem;
+}
+
+.move-controls {
+  display: flex;
+  gap: 0.5rem;
+  margin: auto 0 0.5rem;
+}
+
+.move-controls button {
+  flex: 1;
+}
+
+.image-card button {
+  width: 100%;
+}
+
+.status-box {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: flex-end;
+  gap: 0.75rem;
+  font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
+}
+
+.status-box-content {
+  flex: 1 1 24rem;
+  min-width: 0;
+  white-space: pre-wrap;
+}
+
+.status-box > button {
+  margin-left: auto;
+  align-self: flex-end;
+  flex: 0 0 auto;
+}
+
+.telegram-binding-panel h2,
+.telegram-binding-help {
+  margin: 0 0 0.75rem;
+}
+
+.telegram-binding-body {
+  overflow-x: auto;
+}
+
+.telegram-mapping-table {
+  display: inline-table;
+  width: max-content;
+  border-collapse: collapse;
+  table-layout: fixed;
+  margin-bottom: 0.75rem;
+}
+
+.telegram-mapping-row {
+  border-top: 1px solid color-mix(in srgb, var(--border) 85%, transparent);
+}
+
+.telegram-mapping-table th,
+.telegram-mapping-table td {
+  padding: 0.55rem 0.65rem;
+  text-align: left;
+  vertical-align: middle;
+  border-right: 1px solid color-mix(in srgb, var(--border) 85%, transparent);
+}
+
+.telegram-mapping-table th:last-child,
+.telegram-mapping-table td:last-child {
+  border-right: none;
+}
+
+.telegram-mapping-label {
+  white-space: nowrap;
+  width: 1%;
+}
+
+.telegram-mapping-table thead th {
+  font-weight: 700;
+  border-bottom: 1px solid var(--border);
+}
+
+.telegram-mapping-col-link {
+  width: 12.5rem;
+}
+
+.telegram-mapping-cell {
+  min-width: 0;
+}
+
+.telegram-cell-editor,
+.telegram-cell-actions {
+  min-width: 0;
+  flex-wrap: nowrap;
+}
+
+.telegram-cell-editor input {
+  flex: 1 1 auto;
+  min-width: 0;
+}
+
+.telegram-cell-actions .button-link,
+.telegram-cell-actions button,
+.telegram-cell-editor button {
+  white-space: nowrap;
+}
+
+.telegram-delete-button {
+  margin-top: 0.25rem;
+}
+
+.pinning-controls {
+  margin-bottom: 1rem;
+}
+
+.unpin-scheduler {
+  display: flex;
+  gap: 0.75rem;
+  align-items: end;
+  flex-wrap: wrap;
+}
+
+.unpin-scheduler label {
+  display: flex;
+  flex-direction: column;
+  gap: 0.35rem;
+}
+
+.preview-panel {
+  margin-bottom: 0;
+  background: color-mix(in srgb, var(--panel) 96%, CanvasText 4%);
+}
+
+.editor-preview-layout {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+  gap: 0.75rem;
+}
+
+.preview-content a {
+  color: var(--blue);
+}
+
+.preview-content {
+  white-space: pre-wrap;
+  line-height: 1.45;
+}
+
+.preview-content pre {
+  padding: 0.75rem;
+  overflow-x: auto;
+  border: 1px solid var(--border);
+  background: color-mix(in srgb, Canvas 92%, CanvasText 8%);
+  border-radius: 0.35rem;
+}
+
+.preview-content code {
+  font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
+  background: color-mix(in srgb, Canvas 88%, CanvasText 12%);
+  border: 1px solid color-mix(in srgb, var(--border) 85%, CanvasText 15%);
+  border-radius: 0.25rem;
+  padding: 0.08rem 0.28rem;
+}
+
+.preview-content pre code {
+  background: transparent;
+  border: 0;
+  border-radius: 0;
+  padding: 0;
+}
+
+.preview-content tg-spoiler {
+  color: color-mix(in srgb, var(--fg) 70%, var(--bg) 30%);
+  background: color-mix(in srgb, var(--fg) 10%, var(--bg) 90%);
+  border: 1px dashed color-mix(in srgb, var(--border) 65%, var(--fg) 35%);
+  border-radius: 0.25rem;
+  padding: 0.08rem 0.28rem;
+  box-decoration-break: clone;
+  -webkit-box-decoration-break: clone;
+}
+
+.preview-content blockquote {
+  margin: 0.75rem 0;
+  padding-left: 0.75rem;
+  border-left: 3px solid var(--border);
+  opacity: 0.9;
+}
+
+@media (max-width: 1200px) {
+  .meta-panel {
+    grid-template-columns: minmax(240px, 1fr) minmax(220px, 1fr);
+  }
+
+  .meta-title-field,
+  .meta-global-link-preview,
+  .telegram-target-fields,
+  .meta-stack,
+  .meta-schedule-field,
+  .meta-delete-service-field {
+    grid-column: auto;
+    grid-row: auto;
+  }
+}
+
+@media (max-width: 760px) {
+  .meta-panel {
+    grid-template-columns: 1fr;
+  }
+
+  .editor-preview-layout {
+    grid-template-columns: 1fr;
+  }
+}
+
+body.busy {
+  cursor: progress;
+}
+
+.language-toolbar {
+  display: flex;
+  align-items: flex-start;
+  gap: 1rem;
+  margin-bottom: 0.75rem;
+}
+
+.language-heading {
+  display: flex;
+  flex-direction: column;
+  gap: 0.35rem;
+}
+
+.language-title-toggle {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.55rem;
+  width: fit-content;
+  cursor: pointer;
+}
+
+.language-title-toggle h2 {
+  margin: 0;
+}
+
+.language-subtoggle {
+  margin-left: 1.9rem;
+}
+
+.checkbox-label {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.4rem;
+  white-space: nowrap;
+}
+
+.secondary-button {
+  padding: 0.35rem 0.7rem;
+}
+
+.editor-input-panel {
+  display: flex;
+  flex-direction: column;
+  gap: 0.5rem;
+}
+
+.format-toolbar button {
+  padding: 0.3rem 0.55rem;
+}
+
+textarea {
+  width: 100%;
+  min-height: 240px;
+  resize: none;
+  overflow-y: hidden;
+}
+
+.danger {
+  border-color: var(--red);
+}
+
+.login-page {
+  display: grid;
+  place-items: center;
+  min-height: 100vh;
+}
+
+.login-card {
+  width: 320px;
+  border: 1px solid var(--border);
+  padding: 1rem;
+  background: var(--panel);
+}
+
+.login-card form,
+.login-card label {
+  display: flex;
+  flex-direction: column;
+  gap: 0.75rem;
+}
+
+.login-language-form {
+  margin-bottom: 1rem;
+}
+
+.error {
+  color: var(--red);
+}
+
+@media (max-width: 900px) {
+  body {
+    overflow: auto;
+  }
+
+  .layout {
+    grid-template-columns: 1fr;
+    height: auto;
+    overflow: visible;
+  }
+
+  .sidebar,
+  .content {
+    overflow: visible;
+  }
+
+  .sidebar {
+    border-right: 0;
+    border-bottom: 1px solid var(--border);
+  }
+}
diff --git a/linkki-tiedotus/webapp/static/app.js b/linkki-tiedotus/webapp/static/app.js
new file mode 100644
index 0000000..1227efc
--- /dev/null
+++ b/linkki-tiedotus/webapp/static/app.js
@@ -0,0 +1,3259 @@
+(function () {
+  const root = document.getElementById("editor-root");
+  if (!root) return;
+
+  const state = {
+    broadcast: JSON.parse(root.dataset.broadcast),
+    savedBroadcast: JSON.parse(root.dataset.broadcast),
+    permissions: JSON.parse(root.dataset.permissions),
+    translations: JSON.parse(root.dataset.translations),
+    busy: false,
+    busyMessage: "",
+    operation: null,
+    pollTimer: null,
+    localScheduleValue: "",
+    localUnpinScheduleValue: "",
+    announcements: [],
+    previewTimers: {},
+    imageImportScope: null,
+    activeTab: "main",
+  };
+
+  const INLINE_STYLE_ORDER = ["bold", "italic", "underline", "strikethrough"];
+  const INLINE_STYLE_MARKERS = {
+    bold: "**",
+    italic: "_",
+    underline: "++",
+    strikethrough: "~~",
+  };
+
+  const titleInput = document.getElementById("title-input");
+  const headerTitle = document.querySelector(".editor-header h1");
+  const headerDirectory = document.querySelector(".editor-header p");
+  const telegramBotSelect = document.getElementById("telegram-bot-select");
+  const telegramChatSelect = document.getElementById("telegram-chat-select");
+  const orderSelect = document.getElementById("language-order-select");
+  const discussionPinningSelect = document.getElementById("discussion-pinning-select");
+  const deletePinServiceMessagesCheckbox = document.getElementById("delete-pin-service-messages");
+  const disableLinkPreviewsGlobal = document.getElementById("disable-link-previews-global");
+  const scheduleInput = document.getElementById("schedule-input");
+  const statusBoxContent = document.getElementById("status-box-content");
+  const clearStatusButton = document.getElementById("clear-status-button");
+  const telegramBindingPanel = document.getElementById("telegram-binding-panel");
+  const tabButtons = Array.from(document.querySelectorAll(".editor-tab-button"));
+  const tabPanels = Array.from(document.querySelectorAll(".editor-tab-panel"));
+  const pinningTabPanel = document.getElementById("editor-tab-panel-pinning");
+  const languagesContainer = document.getElementById("languages-container");
+  const contentContainer = document.querySelector(".content");
+  const sidebarContainer = document.querySelector(".sidebar");
+  const globalUpload = document.getElementById("upload-global");
+  const openGlobalImageImportButton = document.getElementById("open-global-image-import-button");
+  const imageImportModal = document.getElementById("image-import-modal");
+  const imageImportFormatSelect = document.getElementById("image-import-format-select");
+  const imageImportUrlInput = document.getElementById("image-import-url-input");
+  const imageImportSubmitUrlButton = document.getElementById("image-import-submit-url-button");
+  const imageImportCloseButton = document.getElementById("image-import-close-button");
+  const imageImportCancelButton = document.getElementById("image-import-cancel-button");
+
+  const saveButton = document.getElementById("save-button");
+  const publishButton = document.getElementById("publish-button");
+  const scheduleButton = document.getElementById("schedule-button");
+  const deleteTelegramButton = document.getElementById("delete-telegram-button");
+  const deleteStorageButton = document.getElementById("delete-storage-button");
+  const unpinScheduleInput = document.getElementById("unpin-schedule-input");
+  const unpinScheduleButton = document.getElementById("unpin-schedule-button");
+  const SIDEBAR_SCROLL_STORAGE_KEY = "linkki.sidebar.scrollTop";
+
+  function clone(obj) {
+    return JSON.parse(JSON.stringify(obj));
+  }
+
+  function persistSidebarScrollPosition() {
+    if (!sidebarContainer) return;
+    window.sessionStorage.setItem(SIDEBAR_SCROLL_STORAGE_KEY, String(sidebarContainer.scrollTop));
+  }
+
+  function restoreSidebarScrollPosition() {
+    if (!sidebarContainer) return;
+    const savedValue = window.sessionStorage.getItem(SIDEBAR_SCROLL_STORAGE_KEY);
+    if (!savedValue) return;
+    const scrollTop = Number(savedValue);
+    if (!Number.isFinite(scrollTop)) return;
+    sidebarContainer.scrollTop = scrollTop;
+    window.requestAnimationFrame(() => {
+      sidebarContainer.scrollTop = scrollTop;
+    });
+  }
+
+  function renderPreservingContentScroll(callback) {
+    const scroller = contentContainer || document.scrollingElement;
+    if (!scroller) {
+      callback();
+      return;
+    }
+    const scrollTop = scroller.scrollTop;
+    const scrollLeft = scroller.scrollLeft;
+    callback();
+    scroller.scrollTop = scrollTop;
+    scroller.scrollLeft = scrollLeft;
+    window.requestAnimationFrame(() => {
+      scroller.scrollTop = scrollTop;
+      scroller.scrollLeft = scrollLeft;
+    });
+  }
+
+  function t(key, fallback) {
+    return Object.prototype.hasOwnProperty.call(state.translations, key) ? state.translations[key] : (fallback || key);
+  }
+
+  function setActiveTab(tabName) {
+    state.activeTab = tabName;
+    for (const button of tabButtons) {
+      const active = button.dataset.tab === tabName;
+      button.classList.toggle("active", active);
+      button.setAttribute("aria-selected", active ? "true" : "false");
+    }
+    for (const panel of tabPanels) {
+      const active = panel.dataset.tabPanel === tabName;
+      panel.classList.toggle("active", active);
+      panel.hidden = !active;
+    }
+  }
+
+  function renderTabLabels() {
+    for (const button of tabButtons) {
+      const tabName = button.dataset.tab || "";
+      if (tabName === "telegram") {
+        button.textContent = t("editor.tab_telegram");
+        if (hasIncompleteTelegramMapping()) {
+          button.textContent += ` (${t("editor.telegram_binding.incomplete")})`;
+        }
+        button.classList.toggle("tab-missing", hasIncompleteTelegramMapping());
+      } else {
+        button.classList.remove("tab-missing");
+      }
+    }
+  }
+
+  function can(permission) {
+    return !!state.permissions[permission];
+  }
+
+  function setPermissionDisabledState(element, permissionDenied) {
+    if (!element) return;
+    element.classList.toggle("permission-disabled", permissionDenied);
+  }
+
+  function canEditContent() {
+    return can("edit_content");
+  }
+
+  function canEditTargets() {
+    return can("edit_targets");
+  }
+
+  function canSaveAnnouncements() {
+    return can("save_announcements");
+  }
+
+  function canOpenTelegram() {
+    return can("open_telegram");
+  }
+
+  function updateSaveButtonState() {
+    const canSave = canSaveAnnouncements();
+    saveButton.disabled = state.busy || !canSave || !hasUnsavedChanges();
+    saveButton.title = !canSave ? permissionReason("save_announcements") : "";
+  }
+
+  function syncGlobalLinkPreviewToggle() {
+    const previewFlags = state.broadcast.metadata.disable_link_previews || {};
+    const previewValues = state.broadcast.metadata.language_order.map((language) => !!previewFlags[language]);
+    const allPreviewDisabled = previewValues.length > 0 && previewValues.every(Boolean);
+    const noPreviewDisabled = previewValues.every((value) => !value);
+    disableLinkPreviewsGlobal.checked = allPreviewDisabled;
+    disableLinkPreviewsGlobal.indeterminate = !allPreviewDisabled && !noPreviewDisabled;
+  }
+
+  function normalizeComparableText(value) {
+    return String(value || "")
+      .replace(/\r\n/g, "\n")
+      .replace(/\r/g, "\n")
+      .split("\n")
+      .map((line) => line.replace(/\s+$/u, ""))
+      .join("\n")
+      .replace(/\s+$/u, "");
+  }
+
+  function buildEditableSnapshot(broadcast) {
+    const metadata = broadcast.metadata;
+    const includePersistedTarget = !metadata.telegram_binding;
+    return {
+      title: metadata.title,
+      telegram_bot_id: includePersistedTarget ? (metadata.telegram_bot_id || "") : "",
+      telegram_chat_id: includePersistedTarget ? (metadata.telegram_chat_id || "") : "",
+      language_order: [...metadata.language_order],
+      discussion_pinning_mode: metadata.discussion_pinning_mode || "first",
+      delete_pin_service_messages: !!metadata.delete_pin_service_messages,
+      schedule_draft_for: metadata.schedule_draft_for || "",
+      enabled_languages: clone(metadata.enabled_languages),
+      disable_link_previews: clone(metadata.disable_link_previews || {}),
+      texts: {
+        fi: normalizeComparableText(broadcast.texts.fi || ""),
+        sv: normalizeComparableText(broadcast.texts.sv || ""),
+        en: normalizeComparableText(broadcast.texts.en || ""),
+      },
+      images: {
+        global: (broadcast.images.global || []).map((item) => item.name),
+        fi: (broadcast.images.fi || []).map((item) => item.name),
+        sv: (broadcast.images.sv || []).map((item) => item.name),
+        en: (broadcast.images.en || []).map((item) => item.name),
+      },
+    };
+  }
+
+  function scheduleInputValueForMetadata(metadata) {
+    if (metadata.scheduled_for) {
+      return toDatetimeLocal(new Date(metadata.scheduled_for));
+    }
+    return metadata.schedule_draft_for || "";
+  }
+
+  function unpinScheduleInputValueForMetadata(metadata) {
+    if (!metadata.scheduled_unpin_for) {
+      return "";
+    }
+    return toDatetimeLocal(new Date(metadata.scheduled_unpin_for));
+  }
+
+  function hasUnsavedChanges() {
+    return JSON.stringify(buildEditableSnapshot(state.broadcast)) !== JSON.stringify(buildEditableSnapshot(state.savedBroadcast));
+  }
+
+  function applyServerBroadcast(broadcast) {
+    state.broadcast = broadcast;
+    state.savedBroadcast = clone(broadcast);
+    root.dataset.broadcast = JSON.stringify(broadcast);
+    state.localScheduleValue = scheduleInputValueForMetadata(broadcast.metadata);
+    state.localUnpinScheduleValue = unpinScheduleInputValueForMetadata(broadcast.metadata);
+  }
+
+  function applyServerBroadcastPreservingLocalEdits(broadcast) {
+    const localBroadcast = clone(state.broadcast);
+    state.savedBroadcast = clone(broadcast);
+    state.broadcast = clone(broadcast);
+    state.broadcast.metadata.title = localBroadcast.metadata.title;
+    state.broadcast.metadata.telegram_bot_id = localBroadcast.metadata.telegram_bot_id || "";
+    state.broadcast.metadata.telegram_chat_id = localBroadcast.metadata.telegram_chat_id || "";
+    state.broadcast.metadata.language_order = [...localBroadcast.metadata.language_order];
+    state.broadcast.metadata.discussion_pinning_mode = localBroadcast.metadata.discussion_pinning_mode || "first";
+    state.broadcast.metadata.delete_pin_service_messages = !!localBroadcast.metadata.delete_pin_service_messages;
+    state.broadcast.metadata.schedule_draft_for = localBroadcast.metadata.schedule_draft_for || null;
+    state.broadcast.metadata.enabled_languages = clone(localBroadcast.metadata.enabled_languages);
+    state.broadcast.metadata.disable_link_previews = clone(localBroadcast.metadata.disable_link_previews || {});
+    state.broadcast.texts = clone(localBroadcast.texts);
+    state.broadcast.images = clone(localBroadcast.images);
+    root.dataset.broadcast = JSON.stringify(state.broadcast);
+    state.localScheduleValue = scheduleInputValueForMetadata(state.broadcast.metadata);
+    state.localUnpinScheduleValue = unpinScheduleInputValueForMetadata(state.savedBroadcast.metadata);
+  }
+
+  function applyApiPayload(payload, preserveLocalEdits = false) {
+    if (payload.announcements) {
+      state.announcements = payload.announcements;
+    }
+    if (payload.broadcast) {
+      if (preserveLocalEdits) {
+        applyServerBroadcastPreservingLocalEdits(payload.broadcast);
+      } else {
+        applyServerBroadcast(payload.broadcast);
+      }
+    }
+  }
+
+  function confirmUnsavedChanges(messageKey) {
+    if (!hasUnsavedChanges()) return true;
+    return window.confirm(t(messageKey));
+  }
+
+  function isLinked() {
+    return !!state.broadcast.metadata.telegram_binding;
+  }
+
+  function isDeleteFromTelegramExpired() {
+    if (!state.broadcast.telegram_delete_deadline) return false;
+    return Date.now() >= new Date(state.broadcast.telegram_delete_deadline).getTime();
+  }
+
+  function isTextOnlyEditable() {
+    return isLinked();
+  }
+
+  function areTargetsLocked() {
+    return isLinked() || state.broadcast.metadata.status === "scheduled";
+  }
+
+  function linkedLockReason() {
+    return t("locked.text_only");
+  }
+
+  function targetsLockReason() {
+    if (isLinked()) {
+      return linkedLockReason();
+    }
+    if (state.broadcast.metadata.status === "scheduled") {
+      return t("locked.targets_scheduled");
+    }
+    return "";
+  }
+
+  function permissionReason(permission) {
+    return t(`permission.${permission}`, "You do not have permission for this action.");
+  }
+
+  function contentEditReasonFor(locked = isTextOnlyEditable()) {
+    return locked ? linkedLockReason() : (!canEditContent() ? permissionReason("edit_content") : "");
+  }
+
+  function telegramTargetCatalog() {
+    return state.broadcast.telegram_targets || { bots: [], default_bot_id: "" };
+  }
+
+  function selectedBot() {
+    return telegramTargetCatalog().bots.find((bot) => bot.id === (state.broadcast.metadata.telegram_bot_id || "")) || null;
+  }
+
+  function selectedChatTarget() {
+    const bot = selectedBot();
+    if (!bot) {
+      return null;
+    }
+    return bot.chats.find((item) => item.chat_id === (state.broadcast.metadata.telegram_chat_id || "")) || null;
+  }
+
+  function defaultChatIdForBot(botId) {
+    const bot = telegramTargetCatalog().bots.find((item) => item.id === botId);
+    if (!bot) return "";
+    return bot.default_chat_id || (bot.chats[0]?.chat_id || "");
+  }
+
+  function selectedChatHasDiscussionGroup() {
+    return !!selectedChatTarget()?.has_discussion_group;
+  }
+
+  function hasCompleteDiscussionLinks(binding = telegramBinding()) {
+    if (!binding) {
+      return false;
+    }
+    const rows = bindingPrimaryRows(binding);
+    return rows.length > 0 && rows.every((row) => !!row.discussionMessageId);
+  }
+
+  function supportsGroupLinks() {
+    return !!selectedDiscussionLinkTarget().internalId;
+  }
+
+  function discussionPinningAvailability() {
+    if (!selectedChatHasDiscussionGroup()) {
+      return {
+        available: false,
+        reason: t("editor.discussion_pinning_requires_group"),
+      };
+    }
+    if (isLinked() && !hasCompleteDiscussionLinks()) {
+      return {
+        available: false,
+        reason: t("editor.discussion_pinning_requires_links"),
+      };
+    }
+    return {
+      available: true,
+      reason: "",
+    };
+  }
+
+  function hasSelectedTarget() {
+    const bot = selectedBot();
+    if (!bot) {
+      return false;
+    }
+    return bot.chats.some((chat) => chat.chat_id === (state.broadcast.metadata.telegram_chat_id || ""));
+  }
+
+  function hasPersistedTarget() {
+    return !!(state.savedBroadcast.metadata.telegram_bot_id && state.savedBroadcast.metadata.telegram_chat_id);
+  }
+
+  function linkLabelForLanguage(language) {
+    if (language === "fi") return "\uD83C\uDDEB\uD83C\uDDEE t\u00e4st\u00e4";
+    if (language === "sv") return "\uD83C\uDDF8\uD83C\uDDEA h\u00e4r";
+    return "\uD83C\uDDEC\uD83C\uDDE7 here";
+  }
+
+  function flagEmojiForLanguage(language) {
+    if (language === "fi") return "\uD83C\uDDEB\uD83C\uDDEE";
+    if (language === "sv") return "\uD83C\uDDF8\uD83C\uDDEA";
+    return "\uD83C\uDDEC\uD83C\uDDE7";
+  }
+
+  function buildCrossLanguageSnippet(language) {
+    const enabledLanguages = state.broadcast.metadata.language_order.filter(
+      (candidate) => candidate !== language && state.broadcast.metadata.enabled_languages[candidate],
+    );
+    return enabledLanguages.map((candidate) => `[${linkLabelForLanguage(candidate)}](${candidate})`).join("\n");
+  }
+
+  function buildLanguageFlagsSnippet() {
+    return state.broadcast.metadata.language_order
+      .map((language) => flagEmojiForLanguage(language))
+      .join("");
+  }
+
+  function insertTextWithSpacing(textarea, language, textToInsert) {
+    if (!textToInsert) return;
+    const currentText = state.broadcast.texts[language] || "";
+    const start = typeof textarea.selectionStart === "number" ? textarea.selectionStart : currentText.length;
+    const end = typeof textarea.selectionEnd === "number" ? textarea.selectionEnd : start;
+    const before = currentText.slice(0, start);
+    const after = currentText.slice(end);
+
+    let insertion = textToInsert;
+    if (before.length > 0 && !before.endsWith("\n")) {
+      insertion = `\n${insertion}`;
+    }
+    if (after.length > 0 && !after.startsWith("\n")) {
+      insertion = `${insertion}\n`;
+    }
+
+    const updatedText = `${before}${insertion}${after}`;
+    const caretPosition = before.length + insertion.length;
+    updateTextareaValue(textarea, language, updatedText, caretPosition, caretPosition);
+  }
+
+  function emptyInlineStyleState() {
+    return {
+      bold: false,
+      italic: false,
+      underline: false,
+      strikethrough: false,
+    };
+  }
+
+  function cloneInlineStyleState(styles) {
+    return { ...emptyInlineStyleState(), ...(styles || {}) };
+  }
+
+  function updateTextareaValue(textarea, language, value, selectionStart, selectionEnd) {
+    state.broadcast.texts[language] = value;
+    textarea.value = value;
+    textarea.focus();
+    textarea.selectionStart = selectionStart;
+    textarea.selectionEnd = selectionEnd;
+    syncTextareaHeight(textarea);
+    updateSaveButtonState();
+  }
+
+  function replaceTextRange(textarea, language, start, end, replacement, selectionStart, selectionEnd) {
+    const currentText = state.broadcast.texts[language] || "";
+    const updatedText = `${currentText.slice(0, start)}${replacement}${currentText.slice(end)}`;
+    updateTextareaValue(textarea, language, updatedText, selectionStart, selectionEnd);
+  }
+
+  function getSelectionRange(textarea, text) {
+    const fallback = text.length;
+    const start = typeof textarea.selectionStart === "number" ? textarea.selectionStart : fallback;
+    const end = typeof textarea.selectionEnd === "number" ? textarea.selectionEnd : start;
+    return start <= end ? { start, end } : { start: end, end: start };
+  }
+
+  function isWhitespaceCharacter(character) {
+    return /\s/u.test(character || "");
+  }
+
+  function isWordCharacter(character) {
+    return /[\p{L}\p{N}_]/u.test(character || "");
+  }
+
+  function expandRangeToWord(text, range) {
+    if (range.start !== range.end) {
+      return range;
+    }
+    if (!text) {
+      return range;
+    }
+
+    let anchor = range.start;
+    if (anchor < text.length && isWordCharacter(text[anchor])) {
+      // keep current anchor
+    } else if (anchor > 0 && isWordCharacter(text[anchor - 1])) {
+      anchor -= 1;
+    } else {
+      while (anchor < text.length && !isWordCharacter(text[anchor])) {
+        anchor += 1;
+      }
+      if (anchor >= text.length) {
+        anchor = range.start - 1;
+        while (anchor >= 0 && !isWordCharacter(text[anchor])) {
+          anchor -= 1;
+        }
+      }
+      if (anchor < 0 || anchor >= text.length || !isWordCharacter(text[anchor])) {
+        return range;
+      }
+    }
+
+    let start = anchor;
+    let end = anchor + 1;
+    while (start > 0 && isWordCharacter(text[start - 1])) {
+      start -= 1;
+    }
+    while (end < text.length && isWordCharacter(text[end])) {
+      end += 1;
+    }
+    return { start, end };
+  }
+
+  function expandRangeToWholeLines(text, range) {
+    let start = range.start;
+    let end = range.end;
+    while (start > 0 && text[start - 1] !== "\n") {
+      start -= 1;
+    }
+    while (end < text.length && text[end] !== "\n") {
+      end += 1;
+    }
+    return { start, end };
+  }
+
+  function trimRangeWhitespace(text, range) {
+    let { start, end } = range;
+    while (start < end && isWhitespaceCharacter(text[start])) {
+      start += 1;
+    }
+    while (start < end && isWhitespaceCharacter(text[end - 1])) {
+      end -= 1;
+    }
+    return { start, end };
+  }
+
+  function collectOpaqueRanges(text) {
+    const ranges = [];
+    let index = 0;
+
+    while (index < text.length) {
+      if (text.startsWith("```", index)) {
+        const closeIndex = text.indexOf("```", index + 3);
+        const end = closeIndex === -1 ? text.length : closeIndex + 3;
+        ranges.push({ start: index, end, kind: "code_block" });
+        index = end;
+        continue;
+      }
+      if (text[index] === "`") {
+        const closeIndex = text.indexOf("`", index + 1);
+        const end = closeIndex === -1 ? text.length : closeIndex + 1;
+        ranges.push({ start: index, end, kind: "inline_code" });
+        index = end;
+        continue;
+      }
+      if (text.startsWith("||", index)) {
+        const closeIndex = text.indexOf("||", index + 2);
+        const end = closeIndex === -1 ? text.length : closeIndex + 2;
+        ranges.push({ start: index, end, kind: "spoiler" });
+        index = end;
+        continue;
+      }
+
+      const linkMatch = text.slice(index).match(/^\[[^\]\n]+\]\((?:\\.|[^)\n])+\)/u);
+      if (linkMatch) {
+        const end = index + linkMatch[0].length;
+        ranges.push({ start: index, end, kind: "link" });
+        index = end;
+        continue;
+      }
+
+      index += 1;
+    }
+
+    return ranges;
+  }
+
+  function findOpaqueRangeAt(ranges, index) {
+    return ranges.find((range) => range.start <= index && index < range.end) || null;
+  }
+
+  function selectionIntersectsOpaqueRange(ranges, start, end) {
+    return ranges.some((range) => range.start < end && start < range.end);
+  }
+
+  function selectionContainsBlockedLineMarkup(text) {
+    return /(^|\n)\s*(?:>\s|[-+*]\s|\d+\.\s)/u.test(text);
+  }
+
+  function isQuotedLine(line) {
+    return /^\s*>\s?/u.test(line);
+  }
+
+  function removeQuotedLinePrefix(line) {
+    return line.replace(/^(\s*)>\s?/u, "$1");
+  }
+
+  function parseLinkRange(text, range) {
+    const raw = text.slice(range.start, range.end);
+    const match = raw.match(/^\[([^\]\n]+)\]\(((?:\\.|[^)\n])+)\)$/u);
+    if (!match) {
+      return null;
+    }
+    return {
+      label: match[1],
+      url: match[2],
+    };
+  }
+
+  function unwrapLinkRange(text, range) {
+    const parsed = parseLinkRange(text, range);
+    if (!parsed) {
+      return null;
+    }
+    return {
+      prefixLength: 1,
+      suffixLength: parsed.url.length + 3,
+      innerText: parsed.label,
+    };
+  }
+
+  function linkRangeAt(ranges, start, end) {
+    if (start === end) {
+      return ranges.find((range) => range.kind === "link" && range.start <= start && start < range.end) || null;
+    }
+    return ranges.find((range) => range.kind === "link" && range.start < end && start < range.end) || null;
+  }
+
+  function touchedLinkRanges(ranges, start, end) {
+    if (start === end) {
+      const containing = linkRangeAt(ranges, start, end);
+      return containing ? [containing] : [];
+    }
+    return ranges.filter((range) => range.kind === "link" && range.start < end && start < range.end);
+  }
+
+  function looksLikeLinkTarget(text) {
+    const candidate = String(text || "").trim();
+    if (!candidate) {
+      return false;
+    }
+    if (/^(fi|sv|en)$/iu.test(candidate)) {
+      return true;
+    }
+    return /^(?:https?:\/\/|www\.)\S+$/iu.test(candidate);
+  }
+
+  function matchingOpaqueKinds(actionType) {
+    if (actionType === "code") {
+      return new Set(["inline_code", "code_block"]);
+    }
+    if (actionType === "spoiler") {
+      return new Set(["spoiler"]);
+    }
+    return new Set();
+  }
+
+  function isMatchingOpaqueRange(range, actionType) {
+    return matchingOpaqueKinds(actionType).has(range.kind);
+  }
+
+  function opaqueContentBounds(text, range) {
+    const unwrapped = unwrapOpaqueRange(text, range);
+    return {
+      start: range.start + unwrapped.prefixLength,
+      end: range.end - unwrapped.suffixLength,
+    };
+  }
+
+  function findContainingOpaqueRange(text, ranges, start, end, actionType) {
+    return ranges.find((range) => {
+      if (!isMatchingOpaqueRange(range, actionType)) {
+        return false;
+      }
+      const bounds = opaqueContentBounds(text, range);
+      if (start === end) {
+        return bounds.start <= start && start <= bounds.end;
+      }
+      return bounds.start <= start && end <= bounds.end;
+    }) || null;
+  }
+
+  function findIntersectingOpaqueRanges(ranges, start, end) {
+    return ranges.filter((range) => range.start < end && start < range.end);
+  }
+
+  function unwrapOpaqueRange(text, range) {
+    const raw = text.slice(range.start, range.end);
+    if (range.kind === "inline_code") {
+      return {
+        prefixLength: 1,
+        suffixLength: 1,
+        innerText: raw.slice(1, -1),
+      };
+    }
+    if (range.kind === "spoiler") {
+      return {
+        prefixLength: 2,
+        suffixLength: 2,
+        innerText: raw.slice(2, -2),
+      };
+    }
+    if (range.kind === "code_block") {
+      if (raw.startsWith("```\n") && raw.endsWith("\n```")) {
+        return {
+          prefixLength: 4,
+          suffixLength: 4,
+          innerText: raw.slice(4, -4),
+        };
+      }
+      return {
+        prefixLength: 3,
+        suffixLength: 3,
+        innerText: raw.slice(3, -3),
+      };
+    }
+    return {
+      prefixLength: 0,
+      suffixLength: 0,
+      innerText: raw,
+    };
+  }
+
+  function removeContainingOpaqueRange(textarea, language, text, range, selectionStart, selectionEnd) {
+    const unwrapped = unwrapOpaqueRange(text, range);
+    const contentStart = range.start + unwrapped.prefixLength;
+    const relativeStart = Math.max(0, Math.min(selectionStart - contentStart, unwrapped.innerText.length));
+    const relativeEnd = Math.max(0, Math.min(selectionEnd - contentStart, unwrapped.innerText.length));
+    replaceTextRange(
+      textarea,
+      language,
+      range.start,
+      range.end,
+      unwrapped.innerText,
+      range.start + relativeStart,
+      range.start + relativeEnd,
+    );
+    return true;
+  }
+
+  function stripContainedOpaqueRanges(text, start, end, ranges) {
+    const parts = [];
+    let cursor = start;
+    for (const range of ranges) {
+      parts.push(text.slice(cursor, range.start));
+      parts.push(unwrapOpaqueRange(text, range).innerText);
+      cursor = range.end;
+    }
+    parts.push(text.slice(cursor, end));
+    return parts.join("");
+  }
+
+  function buildOpaqueReplacement(selectedText, actionType) {
+    if (actionType === "code") {
+      if (selectedText.includes("\n")) {
+        if (selectedText.includes("```")) {
+          return null;
+        }
+        return {
+          replacement: `\`\`\`\n${selectedText}\n\`\`\``,
+          contentOffset: 4,
+        };
+      }
+      if (selectedText.includes("`")) {
+        return null;
+      }
+      return {
+        replacement: `\`${selectedText}\``,
+        contentOffset: 1,
+      };
+    }
+
+    if (actionType === "spoiler") {
+      return {
+        replacement: `||${selectedText}||`,
+        contentOffset: 2,
+      };
+    }
+
+    return null;
+  }
+
+  function applyOpaqueFormatting(textarea, language, actionType) {
+    const text = state.broadcast.texts[language] || "";
+    const opaqueRanges = collectOpaqueRanges(text);
+    const originalRange = getSelectionRange(textarea, text);
+
+    if (originalRange.start === originalRange.end) {
+      const containingRange = findContainingOpaqueRange(text, opaqueRanges, originalRange.start, originalRange.end, actionType);
+      if (containingRange) {
+        return removeContainingOpaqueRange(textarea, language, text, containingRange, originalRange.start, originalRange.end);
+      }
+    }
+
+    let range = originalRange.start === originalRange.end
+      ? expandRangeToWord(text, originalRange)
+      : originalRange;
+
+    if (range.start === range.end) {
+      return false;
+    }
+
+    if (actionType === "code" && text.slice(range.start, range.end).includes("\n")) {
+      range = expandRangeToWholeLines(text, range);
+    }
+
+    const containingRange = findContainingOpaqueRange(text, opaqueRanges, range.start, range.end, actionType);
+    if (containingRange) {
+      return removeContainingOpaqueRange(textarea, language, text, containingRange, range.start, range.end);
+    }
+
+    const intersectingRanges = findIntersectingOpaqueRanges(opaqueRanges, range.start, range.end);
+    const partialIntersectingRanges = intersectingRanges.filter(
+      (item) => !(range.start <= item.start && item.end <= range.end),
+    );
+    if (partialIntersectingRanges.length > 0) {
+      return false;
+    }
+    if (actionType !== "spoiler") {
+      const otherIntersectingRanges = intersectingRanges.filter((item) => !isMatchingOpaqueRange(item, actionType));
+      if (otherIntersectingRanges.length > 0) {
+        return false;
+      }
+    }
+
+    const containedMatchingRanges = intersectingRanges.filter(
+      (item) => isMatchingOpaqueRange(item, actionType) && range.start <= item.start && item.end <= range.end,
+    );
+    const selectedText = containedMatchingRanges.length > 0
+      ? stripContainedOpaqueRanges(text, range.start, range.end, containedMatchingRanges)
+      : text.slice(range.start, range.end);
+    if (!selectedText) {
+      return false;
+    }
+
+    const built = buildOpaqueReplacement(selectedText, actionType);
+    if (!built) {
+      return false;
+    }
+
+    replaceTextRange(
+      textarea,
+      language,
+      range.start,
+      range.end,
+      built.replacement,
+      range.start + built.contentOffset,
+      range.start + built.contentOffset + selectedText.length,
+    );
+    return true;
+  }
+
+  function inlineMarkerAt(text, index) {
+    if (text.startsWith("**", index) || text.startsWith("__", index)) {
+      return { style: "bold", marker: text.slice(index, index + 2) };
+    }
+    if (text.startsWith("++", index)) {
+      return { style: "underline", marker: "++" };
+    }
+    if (text.startsWith("~~", index)) {
+      return { style: "strikethrough", marker: "~~" };
+    }
+    if (text[index] === "*" || text[index] === "_") {
+      return { style: "italic", marker: text[index] };
+    }
+    return null;
+  }
+
+  function inlineStyleStateAt(text, end, opaqueRanges) {
+    const active = emptyInlineStyleState();
+    const stack = inlineStyleStackAt(text, end, opaqueRanges);
+    for (const style of stack) {
+      active[style] = true;
+    }
+    return active;
+  }
+
+  function inlineStyleStackAt(text, end, opaqueRanges) {
+    const activeStack = [];
+    let index = 0;
+
+    while (index < end) {
+      const opaqueRange = findOpaqueRangeAt(opaqueRanges, index);
+      if (opaqueRange) {
+        index = Math.min(opaqueRange.end, end);
+        continue;
+      }
+      if (text[index] === "\\" && index + 1 < end) {
+        index += 2;
+        continue;
+      }
+      const marker = inlineMarkerAt(text, index);
+      if (marker && index + marker.marker.length <= end) {
+        const existingIndex = activeStack.lastIndexOf(marker.style);
+        if (existingIndex === -1) {
+          activeStack.push(marker.style);
+        } else {
+          activeStack.splice(existingIndex, 1);
+        }
+        index += marker.marker.length;
+        continue;
+      }
+      index += 1;
+    }
+
+    return activeStack;
+  }
+
+  function parseInlineSelection(text, start, end, initialStyles, opaqueRanges) {
+    const characters = [];
+    const active = cloneInlineStyleState(initialStyles);
+    let index = start;
+
+    while (index < end) {
+      const opaqueRange = findOpaqueRangeAt(opaqueRanges, index);
+      if (opaqueRange) {
+        return null;
+      }
+      if (text[index] === "\\" && index + 1 < end) {
+        characters.push({ value: text[index], styles: cloneInlineStyleState(active) });
+        characters.push({ value: text[index + 1], styles: cloneInlineStyleState(active) });
+        index += 2;
+        continue;
+      }
+      const marker = inlineMarkerAt(text, index);
+      if (marker && index + marker.marker.length <= end) {
+        active[marker.style] = !active[marker.style];
+        index += marker.marker.length;
+        continue;
+      }
+      characters.push({ value: text[index], styles: cloneInlineStyleState(active) });
+      index += 1;
+    }
+
+    return {
+      characters,
+      finalStyles: active,
+    };
+  }
+
+  function collectInlineStyleSpans(text, opaqueRanges) {
+    const spans = {
+      bold: [],
+      italic: [],
+      underline: [],
+      strikethrough: [],
+    };
+    const activeStarts = {
+      bold: null,
+      italic: null,
+      underline: null,
+      strikethrough: null,
+    };
+    let index = 0;
+
+    while (index < text.length) {
+      const opaqueRange = findOpaqueRangeAt(opaqueRanges, index);
+      if (opaqueRange) {
+        index = opaqueRange.end;
+        continue;
+      }
+      if (text[index] === "\\" && index + 1 < text.length) {
+        index += 2;
+        continue;
+      }
+      const marker = inlineMarkerAt(text, index);
+      if (marker) {
+        if (activeStarts[marker.style] === null) {
+          activeStarts[marker.style] = index;
+        } else {
+          spans[marker.style].push({
+            start: activeStarts[marker.style],
+            end: index + marker.marker.length,
+          });
+          activeStarts[marker.style] = null;
+        }
+        index += marker.marker.length;
+        continue;
+      }
+      index += 1;
+    }
+
+    return spans;
+  }
+
+  function flattenInlineStyleSpans(spansByStyle) {
+    const spans = [];
+    for (const style of INLINE_STYLE_ORDER) {
+      for (const span of spansByStyle[style] || []) {
+        spans.push({ ...span, style });
+      }
+    }
+    return spans;
+  }
+
+  function inlineSpanContentBounds(text, span) {
+    const marker = inlineMarkerAt(text, span.start);
+    const markerLength = marker ? marker.marker.length : 0;
+    return {
+      start: span.start + markerLength,
+      end: span.end - markerLength,
+    };
+  }
+
+  function expandRangeToInlineStyleSpans(range, spans) {
+    let expanded = { ...range };
+    let changed = true;
+    while (changed) {
+      changed = false;
+      for (const span of spans) {
+        if (span.start < expanded.end && expanded.start < span.end) {
+          const nextStart = Math.min(expanded.start, span.start);
+          const nextEnd = Math.max(expanded.end, span.end);
+          if (nextStart !== expanded.start || nextEnd !== expanded.end) {
+            expanded = { start: nextStart, end: nextEnd };
+            changed = true;
+          }
+        }
+      }
+    }
+    return expanded;
+  }
+
+  function expandRangeToCoveredInlineStyleSpans(text, range, spans) {
+    let expanded = { ...range };
+    let changed = true;
+    while (changed) {
+      changed = false;
+      for (const span of spans) {
+        const bounds = inlineSpanContentBounds(text, span);
+        if (expanded.start <= bounds.start && bounds.end <= expanded.end) {
+          const nextStart = Math.min(expanded.start, span.start);
+          const nextEnd = Math.max(expanded.end, span.end);
+          if (nextStart !== expanded.start || nextEnd !== expanded.end) {
+            expanded = { start: nextStart, end: nextEnd };
+            changed = true;
+          }
+        }
+      }
+    }
+    return expanded;
+  }
+
+  function balanceInlineSelectionEnd(text, range, opaqueRanges) {
+    let expanded = { ...range };
+    while (expanded.end < text.length) {
+      const initialStyles = inlineStyleStateAt(text, expanded.start, opaqueRanges);
+      const parsedSelection = parseInlineSelection(text, expanded.start, expanded.end, initialStyles, opaqueRanges);
+      if (!parsedSelection || inlineStyleStatesMatch(parsedSelection.finalStyles, initialStyles)) {
+        return expanded;
+      }
+      const trailingMarker = inlineMarkerAt(text, expanded.end);
+      if (!trailingMarker) {
+        return expanded;
+      }
+      if (parsedSelection.finalStyles[trailingMarker.style] === initialStyles[trailingMarker.style]) {
+        return expanded;
+      }
+      expanded = {
+        start: expanded.start,
+        end: expanded.end + trailingMarker.marker.length,
+      };
+    }
+    return expanded;
+  }
+
+  function inlineStyleStatesMatch(first, second) {
+    return INLINE_STYLE_ORDER.every((style) => !!first[style] === !!second[style]);
+  }
+
+  function buildTargetStyleStack(activeStack, desiredStyles) {
+    const targetStack = activeStack.filter((style) => !!desiredStyles[style]);
+    for (const style of INLINE_STYLE_ORDER) {
+      if (!!desiredStyles[style] && !targetStack.includes(style)) {
+        targetStack.push(style);
+      }
+    }
+    return targetStack;
+  }
+
+  function transitionInlineStyles(parts, activeStack, desiredStyles) {
+    const targetStack = buildTargetStyleStack(activeStack, desiredStyles);
+    let sharedPrefixLength = 0;
+    while (
+      sharedPrefixLength < activeStack.length
+      && sharedPrefixLength < targetStack.length
+      && activeStack[sharedPrefixLength] === targetStack[sharedPrefixLength]
+    ) {
+      sharedPrefixLength += 1;
+    }
+
+    while (activeStack.length > sharedPrefixLength) {
+      const style = activeStack.pop();
+      parts.push(INLINE_STYLE_MARKERS[style]);
+    }
+
+    while (activeStack.length < targetStack.length) {
+      const style = targetStack[activeStack.length];
+      activeStack.push(style);
+      parts.push(INLINE_STYLE_MARKERS[style]);
+    }
+  }
+
+  function serializeInlineSelection(characters, surroundingStyles, surroundingStack) {
+    const parts = [];
+    const activeStack = [...(surroundingStack || [])];
+
+    for (const character of characters) {
+      transitionInlineStyles(parts, activeStack, character.styles);
+      parts.push(character.value);
+    }
+
+    transitionInlineStyles(parts, activeStack, surroundingStyles);
+    return parts.join("");
+  }
+
+  function toggleInlineStyle(textarea, language, style) {
+    const text = state.broadcast.texts[language] || "";
+    const opaqueRanges = collectOpaqueRanges(text);
+    const inlineStyleSpans = collectInlineStyleSpans(text, opaqueRanges);
+    const allInlineSpans = flattenInlineStyleSpans(inlineStyleSpans);
+    let range = expandRangeToWord(text, getSelectionRange(textarea, text));
+
+    if (range.start === range.end) {
+      return false;
+    }
+
+    range = expandRangeToInlineStyleSpans(range, inlineStyleSpans[style] || []);
+    range = expandRangeToCoveredInlineStyleSpans(text, range, allInlineSpans);
+    range = balanceInlineSelectionEnd(text, range, opaqueRanges);
+
+    const selectedText = text.slice(range.start, range.end);
+    if (selectedText.includes("\n")) {
+      return false;
+    }
+    if (selectionIntersectsOpaqueRange(opaqueRanges, range.start, range.end)) {
+      return false;
+    }
+    if (selectionContainsBlockedLineMarkup(selectedText)) {
+      return false;
+    }
+
+    const surroundingStack = inlineStyleStackAt(text, range.start, opaqueRanges);
+    const initialStyles = cloneInlineStyleState();
+    for (const activeStyle of surroundingStack) {
+      initialStyles[activeStyle] = true;
+    }
+    const parsedSelection = parseInlineSelection(text, range.start, range.end, initialStyles, opaqueRanges);
+    if (!parsedSelection || parsedSelection.characters.length === 0) {
+      return false;
+    }
+    if (!inlineStyleStatesMatch(parsedSelection.finalStyles, initialStyles)) {
+      return false;
+    }
+
+    const shouldApply = parsedSelection.characters.some((character) => !character.styles[style]);
+    for (const character of parsedSelection.characters) {
+      character.styles[style] = shouldApply;
+    }
+
+    const replacement = serializeInlineSelection(parsedSelection.characters, initialStyles, surroundingStack);
+    replaceTextRange(textarea, language, range.start, range.end, replacement, range.start, range.start + replacement.length);
+    return true;
+  }
+
+  function applyCodeFormatting(textarea, language) {
+    return applyOpaqueFormatting(textarea, language, "code");
+  }
+
+  function applySpoilerFormatting(textarea, language) {
+    return applyOpaqueFormatting(textarea, language, "spoiler");
+  }
+
+  function applyLinkFormatting(textarea, language) {
+    const text = state.broadcast.texts[language] || "";
+    const opaqueRanges = collectOpaqueRanges(text);
+    const originalRange = getSelectionRange(textarea, text);
+    const touchedLinks = touchedLinkRanges(opaqueRanges, originalRange.start, originalRange.end);
+    if (touchedLinks.length > 0) {
+      const replaceStart = touchedLinks[0].start;
+      const replaceEnd = touchedLinks[touchedLinks.length - 1].end;
+      const parts = [];
+      let cursor = replaceStart;
+      for (const range of touchedLinks) {
+        const unwrapped = unwrapLinkRange(text, range);
+        if (!unwrapped) {
+          return false;
+        }
+        parts.push(text.slice(cursor, range.start));
+        parts.push(unwrapped.innerText);
+        cursor = range.end;
+      }
+      parts.push(text.slice(cursor, replaceEnd));
+      const replacement = parts.join("");
+      replaceTextRange(textarea, language, replaceStart, replaceEnd, replacement, replaceStart, replaceStart + replacement.length);
+      return true;
+    }
+
+    let range = originalRange.start === originalRange.end
+      ? expandRangeToWord(text, originalRange)
+      : originalRange;
+    range = trimRangeWhitespace(text, range);
+    if (range.start === range.end || selectionIntersectsOpaqueRange(opaqueRanges, range.start, range.end)) {
+      return false;
+    }
+
+    const selectedText = text.slice(range.start, range.end);
+    if (!selectedText || selectedText.includes("\n")) {
+      return false;
+    }
+
+    const selectionLooksLikeLink = looksLikeLinkTarget(selectedText);
+    const replacement = `[${selectedText}](${selectedText})`;
+    const labelStart = range.start + 1;
+    const urlStart = range.start + selectedText.length + 3;
+    if (selectionLooksLikeLink) {
+      replaceTextRange(textarea, language, range.start, range.end, replacement, labelStart, labelStart + selectedText.length);
+    } else {
+      replaceTextRange(textarea, language, range.start, range.end, replacement, urlStart, urlStart + selectedText.length);
+    }
+    return true;
+  }
+
+  function applyBlockquoteFormatting(textarea, language) {
+    const text = state.broadcast.texts[language] || "";
+    let range = expandRangeToWholeLines(text, getSelectionRange(textarea, text));
+    const selectedText = text.slice(range.start, range.end);
+    if (!selectedText) {
+      return false;
+    }
+
+    const lines = selectedText.split("\n");
+    const shouldApply = lines.some((line) => !isQuotedLine(line));
+    const replacement = lines.map((line) => {
+      if (shouldApply) {
+        return isQuotedLine(line) ? line : `> ${line}`;
+      }
+      return isQuotedLine(line) ? removeQuotedLinePrefix(line) : line;
+    }).join("\n");
+    replaceTextRange(textarea, language, range.start, range.end, replacement, range.start, range.start + replacement.length);
+    return true;
+  }
+
+  function applyLinePrefix(textarea, language, prefixBuilder) {
+    const text = state.broadcast.texts[language] || "";
+    const range = expandRangeToWholeLines(text, getSelectionRange(textarea, text));
+    const selectedText = text.slice(range.start, range.end);
+    if (!selectedText) {
+      return false;
+    }
+    const lines = selectedText.split("\n");
+    const replacement = lines.map((line, index) => `${prefixBuilder(index)}${line}`).join("\n");
+    replaceTextRange(textarea, language, range.start, range.end, replacement, range.start, range.start + replacement.length);
+    return true;
+  }
+
+  function applyLanguageLinks(textarea, language, snippet) {
+    if (!snippet) {
+      return false;
+    }
+    insertTextWithSpacing(textarea, language, snippet);
+    return true;
+  }
+
+  function applyFormattingAction(textarea, language, action, previewContent) {
+    let changed = false;
+
+    if (action.type === "inline_style") {
+      changed = toggleInlineStyle(textarea, language, action.style);
+    } else if (action.type === "code") {
+      changed = applyCodeFormatting(textarea, language);
+    } else if (action.type === "spoiler") {
+      changed = applySpoilerFormatting(textarea, language);
+    } else if (action.type === "link") {
+      changed = applyLinkFormatting(textarea, language);
+    } else if (action.type === "blockquote") {
+      changed = applyBlockquoteFormatting(textarea, language);
+    } else if (action.type === "bullet_list") {
+      changed = applyLinePrefix(textarea, language, () => "- ");
+    } else if (action.type === "numbered_list") {
+      changed = applyLinePrefix(textarea, language, (index) => `${index + 1}. `);
+    } else if (action.type === "language_links") {
+      changed = applyLanguageLinks(textarea, language, action.snippet || "");
+    } else if (action.type === "language_flags") {
+      changed = applyLanguageLinks(textarea, language, action.snippet || "");
+    }
+
+    if (changed) {
+      schedulePreviewUpdate(language, previewContent);
+    }
+    return changed;
+  }
+
+  function syncTextareaHeight(textarea) {
+    if (!textarea) {
+      return;
+    }
+    textarea.style.height = "auto";
+    const previewPanel = textarea._previewPanel || null;
+    const previewHeight = previewPanel && !previewPanel.hidden ? previewPanel.offsetHeight : 0;
+    const targetHeight = Math.max(240, textarea.scrollHeight, previewHeight);
+    textarea.style.height = `${targetHeight}px`;
+  }
+
+  function syncAllTextareaHeights() {
+    for (const textarea of languagesContainer.querySelectorAll("textarea")) {
+      syncTextareaHeight(textarea);
+    }
+  }
+
+  function isModifierShortcut(event) {
+    return (event.ctrlKey || event.metaKey) && !event.altKey;
+  }
+
+  function handleTextareaShortcut(event, textarea, language, previewContent) {
+    if (!isModifierShortcut(event)) {
+      return;
+    }
+
+    let action = null;
+    const key = String(event.key || "").toLowerCase();
+    if (key === "b") {
+      action = { type: "inline_style", style: "bold" };
+    } else if (key === "i") {
+      action = { type: "inline_style", style: "italic" };
+    } else if (key === "u") {
+      action = { type: "inline_style", style: "underline" };
+    } else {
+      return;
+    }
+
+    event.preventDefault();
+    applyFormattingAction(textarea, language, action, previewContent);
+  }
+
+  function sourceBroadcastForOutboundAction() {
+    return hasUnsavedChanges() ? state.savedBroadcast : state.broadcast;
+  }
+
+  function collectInactiveLinkWarnings(broadcast) {
+    const issues = [];
+    const seen = new Set();
+    const placeholderPattern = /\[([^\]]+)\]\((fi|sv|en)\)/gi;
+
+    for (const sourceLanguage of broadcast.metadata.language_order) {
+      if (!broadcast.metadata.enabled_languages[sourceLanguage]) continue;
+      const text = String(broadcast.texts[sourceLanguage] || "");
+      let match;
+      while ((match = placeholderPattern.exec(text)) !== null) {
+        const targetLanguage = match[2].toLowerCase();
+        const targetEnabled = !!broadcast.metadata.enabled_languages[targetLanguage];
+        const targetHasText = String(broadcast.texts[targetLanguage] || "").trim().length > 0;
+        if (targetEnabled && targetHasText) {
+          continue;
+        }
+
+        const reason = !targetEnabled ? t("warning.target_disabled") : t("warning.target_empty");
+        const issueKey = `${sourceLanguage}:${targetLanguage}:${reason}`;
+        if (seen.has(issueKey)) {
+          continue;
+        }
+        seen.add(issueKey);
+        issues.push(
+          `${displayName(sourceLanguage)} -> ${displayName(targetLanguage)}: ${reason}`
+        );
+      }
+    }
+
+    return issues;
+  }
+
+  function confirmOutboundAction(actionKey) {
+    const messages = [];
+    if (actionKey === "publish" && hasUnsavedChanges()) {
+      messages.push(t("confirm.publish_unsaved"));
+    }
+    if (actionKey === "schedule" && hasUnsavedChanges()) {
+      messages.push(t("confirm.schedule_unsaved"));
+    }
+
+    const inactiveLinkWarnings = collectInactiveLinkWarnings(sourceBroadcastForOutboundAction());
+    if (inactiveLinkWarnings.length > 0) {
+      messages.push(`${t("confirm.inactive_links")}\n- ${inactiveLinkWarnings.join("\n- ")}`);
+    }
+
+    if (messages.length === 0) {
+      return true;
+    }
+    return window.confirm(messages.join("\n\n"));
+  }
+
+  function announcementUrl(path) {
+    return `/api/announcements/${state.broadcast.directory}${path}`;
+  }
+
+  function announcementPageUrl() {
+    return `/announcements/${state.broadcast.directory}`;
+  }
+
+  async function fetchJson(url, options) {
+    const response = await fetch(url, options);
+    if (response.status === 401) {
+      window.location.href = "/login";
+      throw new Error("Authentication required.");
+    }
+
+    let payload = {};
+    const contentType = response.headers.get("content-type") || "";
+    if (contentType.includes("application/json")) {
+      payload = await response.json();
+    } else {
+      const text = await response.text();
+      if (!response.ok) {
+        throw new Error(text || `Request failed with status ${response.status}.`);
+      }
+    }
+
+    if (!response.ok) {
+      throw new Error(payload.detail || `Request failed with status ${response.status}.`);
+    }
+    return payload;
+  }
+
+  function renderTelegramTargetSelects(locked, canEdit, editReason) {
+    const catalog = telegramTargetCatalog();
+    const currentBotId = state.broadcast.metadata.telegram_bot_id || "";
+    const currentChatId = state.broadcast.metadata.telegram_chat_id || "";
+    const botIds = new Set(catalog.bots.map((bot) => bot.id));
+    const temporaryDeleteSelectionAllowed = isLinked() && !hasPersistedTarget() && can("delete_telegram");
+    const targetControlsLocked = state.busy || (!temporaryDeleteSelectionAllowed && (locked || !canEdit));
+    const allowEmptySelection = !(state.broadcast.metadata.status === "scheduled" && !isLinked());
+
+    telegramBotSelect.innerHTML = "";
+    if (allowEmptySelection || !currentBotId) {
+      telegramBotSelect.appendChild(new Option(t("editor.select_bot"), ""));
+    }
+    for (const bot of catalog.bots) {
+      telegramBotSelect.appendChild(new Option(bot.name, bot.id));
+    }
+    if (currentBotId && !botIds.has(currentBotId)) {
+      telegramBotSelect.appendChild(new Option(currentBotId, currentBotId));
+    }
+    telegramBotSelect.value = currentBotId;
+    telegramBotSelect.disabled = targetControlsLocked;
+    telegramBotSelect.title = temporaryDeleteSelectionAllowed
+      ? t("editor.temporary_delete_target")
+      : editReason;
+    setPermissionDisabledState(telegramBotSelect, !canEdit);
+
+    const activeBot = selectedBot();
+    const chatIds = new Set((activeBot?.chats || []).map((chat) => chat.chat_id));
+    telegramChatSelect.innerHTML = "";
+    if (allowEmptySelection || !currentChatId) {
+      telegramChatSelect.appendChild(new Option(t("editor.select_chat"), ""));
+    }
+    for (const chat of activeBot?.chats || []) {
+      telegramChatSelect.appendChild(new Option(chat.name, chat.chat_id));
+    }
+    if (currentChatId && !chatIds.has(currentChatId)) {
+      telegramChatSelect.appendChild(new Option(currentChatId, currentChatId));
+    }
+    telegramChatSelect.value = currentChatId;
+    telegramChatSelect.disabled = targetControlsLocked || !currentBotId;
+    telegramChatSelect.title = !currentBotId
+      ? t("editor.select_bot_first")
+      : (temporaryDeleteSelectionAllowed ? t("editor.temporary_delete_target") : editReason);
+    setPermissionDisabledState(telegramChatSelect, !canEdit);
+  }
+
+  function renderSidebarAnnouncements() {
+    if (!state.announcements || state.announcements.length === 0) {
+      return;
+    }
+    const container = document.querySelector(".announcement-list");
+    if (!container) {
+      return;
+    }
+
+    container.innerHTML = "";
+    for (const item of state.announcements) {
+      const entry = document.createElement("div");
+      entry.className = "announcement-entry";
+
+      const link = document.createElement("a");
+      link.className = "announcement-item";
+      if (item.directory === state.broadcast.directory) {
+        link.classList.add("active");
+      }
+      link.href = `/announcements/${item.directory}`;
+
+      const titleRow = document.createElement("span");
+      titleRow.className = "announcement-title-row";
+
+      if (item.sidebar_has_pins) {
+        const primaryMarker = document.createElement("span");
+        primaryMarker.className = `status-primary-marker status-${item.sidebar_pin_status || item.status} status-primary-marker-pinned`;
+        primaryMarker.classList.add("status-primary-marker-pinned");
+        primaryMarker.setAttribute("aria-hidden", "true");
+        titleRow.appendChild(primaryMarker);
+      }
+
+      const titleSpan = document.createElement("span");
+      titleSpan.className = "announcement-title";
+      titleSpan.textContent = item.title;
+
+      if (item.status_symbol) {
+        const symbol = document.createElement("span");
+        symbol.className = `status-symbol status-${item.status}`;
+        if (item.sidebar_links_incomplete) {
+          symbol.classList.add("status-symbol-incomplete");
+        }
+        symbol.textContent = item.status_symbol;
+        titleRow.appendChild(symbol);
+      }
+      titleRow.appendChild(titleSpan);
+      link.appendChild(titleRow);
+
+      const targetLine = document.createElement("small");
+      targetLine.textContent = item.target_summary;
+      link.appendChild(targetLine);
+
+      for (const metaLine of item.sidebar_meta_lines || []) {
+        const meta = document.createElement("small");
+        meta.className = "announcement-meta";
+        meta.textContent = metaLine;
+        link.appendChild(meta);
+      }
+      entry.appendChild(link);
+
+      const controls = document.createElement("div");
+      controls.className = "announcement-order-controls";
+      controls.appendChild(buildMoveForm(item, "up", item.can_move_up));
+      controls.appendChild(buildMoveForm(item, "down", item.can_move_down));
+      entry.appendChild(controls);
+
+      container.appendChild(entry);
+    }
+  }
+
+  function buildMoveForm(item, direction, enabled) {
+    const form = document.createElement("form");
+    form.method = "post";
+    form.action = `/announcements/${item.directory}/move`;
+
+    const directionInput = document.createElement("input");
+    directionInput.type = "hidden";
+    directionInput.name = "direction";
+    directionInput.value = direction;
+    form.appendChild(directionInput);
+
+    const returnToInput = document.createElement("input");
+    returnToInput.type = "hidden";
+    returnToInput.name = "return_to";
+    returnToInput.value = window.location.pathname;
+    form.appendChild(returnToInput);
+
+    const button = document.createElement("button");
+    button.type = "submit";
+    button.innerHTML = direction === "up" ? "↑" : "↓";
+    button.disabled = !enabled || !canSaveAnnouncements();
+    form.appendChild(button);
+    return form;
+  }
+
+  async function updatePreview(language, previewContent) {
+    const availableTargets = state.broadcast.metadata.language_order.filter((candidate) => {
+      if (candidate === language) {
+        return false;
+      }
+      if (!state.broadcast.metadata.enabled_languages[candidate]) {
+        return false;
+      }
+      return String(state.broadcast.texts[candidate] || "").trim().length > 0;
+    });
+
+    const payload = await fetchJson("/api/preview", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        text: state.broadcast.texts[language] || "",
+        available_targets: availableTargets,
+      }),
+    });
+    previewContent.innerHTML = payload.html || "";
+    if (previewContent._textarea) {
+      syncTextareaHeight(previewContent._textarea);
+    }
+  }
+
+  function schedulePreviewUpdate(language, previewContent) {
+    if (state.previewTimers[language]) {
+      window.clearTimeout(state.previewTimers[language]);
+    }
+    state.previewTimers[language] = window.setTimeout(() => {
+      updatePreview(language, previewContent).catch((error) => {
+        previewContent.textContent = error.message;
+        if (previewContent._textarea) {
+          syncTextareaHeight(previewContent._textarea);
+        }
+      });
+    }, 150);
+  }
+
+  function applyDisabledState(element, disabled, title, permissionDenied = false) {
+    element.disabled = disabled;
+    element.title = title || "";
+    setPermissionDisabledState(element, permissionDenied);
+  }
+
+  function createSecondaryButton({ label, disabled = false, title = "", permissionDenied = false, onClick = null }) {
+    const button = document.createElement("button");
+    button.type = "button";
+    button.className = "secondary-button";
+    button.textContent = label;
+    applyDisabledState(button, disabled, title, permissionDenied);
+    if (onClick) {
+      button.addEventListener("click", onClick);
+    }
+    return button;
+  }
+
+  function applyContentEditControlState(element, { locked, canContentEdit, contentEditReason }) {
+    applyDisabledState(
+      element,
+      locked || state.busy || !canContentEdit,
+      contentEditReason,
+      !canContentEdit,
+    );
+  }
+
+  function renderContext() {
+    const metadata = state.broadcast.metadata;
+    const locked = isTextOnlyEditable();
+    const targetsLocked = areTargetsLocked();
+    const canContentEdit = canEditContent();
+    const canTargetEdit = canEditTargets();
+    const canSave = canSaveAnnouncements();
+    const hasSelectedTelegramTarget = hasSelectedTarget();
+    const hasSchedule = !!metadata.scheduled_for;
+    const hasScheduledUnpin = !!metadata.scheduled_unpin_for;
+    const pinningAvailability = discussionPinningAvailability();
+    const canUseSchedule = hasSchedule ? can("unschedule") : can("schedule");
+    const canUseUnpinSchedule = hasScheduledUnpin ? can("unschedule") : can("schedule");
+
+    return {
+      metadata,
+      locked,
+      targetsLocked,
+      canContentEdit,
+      canTargetEdit,
+      canSave,
+      hasSelectedTelegramTarget,
+      hasSchedule,
+      hasScheduledUnpin,
+      pinningAvailability,
+      canUseSchedule,
+      canUseUnpinSchedule,
+      contentEditReason: locked ? linkedLockReason() : (!canContentEdit ? permissionReason("edit_content") : ""),
+      targetEditReason: targetsLocked ? targetsLockReason() : (!canTargetEdit ? permissionReason("edit_targets") : ""),
+      titleEditReason: !canContentEdit ? permissionReason("edit_content") : "",
+      scheduleEditReason: !canSave ? permissionReason("save_announcements") : "",
+    };
+  }
+
+  function renderCoreFieldValues(context) {
+    const { metadata } = context;
+    if (headerTitle) {
+      headerTitle.textContent = metadata.title;
+    }
+    if (headerDirectory) {
+      headerDirectory.textContent = state.broadcast.directory;
+    }
+    titleInput.value = metadata.title;
+    orderSelect.value = metadata.language_order.join(",");
+    renderLanguageOrderOptions();
+    discussionPinningSelect.value = metadata.discussion_pinning_mode || "first";
+    deletePinServiceMessagesCheckbox.checked = !!metadata.delete_pin_service_messages;
+    scheduleInput.value = scheduleInputValueForMetadata(metadata);
+    unpinScheduleInput.value = state.localUnpinScheduleValue || unpinScheduleInputValueForMetadata(metadata);
+  }
+
+  function renderPrimaryControls(context) {
+    const {
+      metadata,
+      locked,
+      targetsLocked,
+      canContentEdit,
+      canTargetEdit,
+      canSave,
+      hasSelectedTelegramTarget,
+      hasSchedule,
+      hasScheduledUnpin,
+      pinningAvailability,
+      canUseSchedule,
+      canUseUnpinSchedule,
+      contentEditReason,
+      targetEditReason,
+      titleEditReason,
+      scheduleEditReason,
+    } = context;
+
+    applyDisabledState(titleInput, state.busy || !canContentEdit, titleEditReason, !canContentEdit);
+    applyDisabledState(orderSelect, locked || state.busy || !canContentEdit, contentEditReason, !canContentEdit);
+
+    pinningTabPanel.classList.toggle("tab-panel-disabled", !pinningAvailability.available);
+    pinningTabPanel.setAttribute("aria-disabled", pinningAvailability.available ? "false" : "true");
+    const pinningDisabled = state.busy || !canTargetEdit || !pinningAvailability.available;
+    const pinningTitle = !canTargetEdit ? permissionReason("edit_targets") : pinningAvailability.reason;
+    applyDisabledState(discussionPinningSelect, pinningDisabled, pinningTitle, !canTargetEdit);
+    applyDisabledState(deletePinServiceMessagesCheckbox, pinningDisabled, pinningTitle, !canTargetEdit);
+
+    const canEditScheduleDraft = !hasSchedule && canSave;
+    applyDisabledState(scheduleInput, state.busy || !canEditScheduleDraft, scheduleEditReason, !canSave);
+    applyContentEditControlState(globalUpload, context);
+    applyContentEditControlState(openGlobalImageImportButton, context);
+    syncImageImportModalState();
+    syncGlobalLinkPreviewToggle();
+    applyDisabledState(
+      disableLinkPreviewsGlobal,
+      state.busy || !canContentEdit,
+      !canContentEdit ? permissionReason("edit_content") : "",
+      !canContentEdit,
+    );
+    renderTelegramTargetSelects(targetsLocked, canTargetEdit, targetEditReason);
+    updateSaveButtonState();
+    setPermissionDisabledState(saveButton, !canSave);
+
+    const deleteExpired = isDeleteFromTelegramExpired();
+    let deleteTelegramTitle = "";
+    if (!can("delete_telegram")) {
+      deleteTelegramTitle = permissionReason("delete_telegram");
+    } else if (deleteExpired) {
+      deleteTelegramTitle = t("locked.delete_telegram_expired");
+    }
+    applyDisabledState(
+      deleteTelegramButton,
+      !isLinked() || state.busy || !can("delete_telegram") || deleteExpired,
+      deleteTelegramTitle,
+      !can("delete_telegram"),
+    );
+
+    applyDisabledState(
+      deleteStorageButton,
+      state.busy || !can("delete_storage"),
+      !can("delete_storage") ? permissionReason("delete_storage") : "",
+      !can("delete_storage"),
+    );
+
+    publishButton.disabled = state.busy || hasSchedule || metadata.status === "published" || !can("post_now") || !hasSelectedTelegramTarget;
+    publishButton.title = !can("post_now")
+      ? permissionReason("post_now")
+      : (!hasSelectedTelegramTarget ? t("editor.target_required") : "");
+    publishButton.textContent = metadata.status === "modified" ? t("editor.post_modifications_now") : t("editor.post_now");
+    setPermissionDisabledState(publishButton, !can("post_now"));
+
+    scheduleButton.disabled = state.busy || !canUseSchedule || (!hasSchedule && !hasSelectedTelegramTarget) || (isLinked() && metadata.status !== "modified" && metadata.status !== "scheduled");
+    scheduleButton.title = !canUseSchedule
+      ? permissionReason(hasSchedule ? "unschedule" : "schedule")
+      : ((!hasSchedule && !hasSelectedTelegramTarget) ? t("editor.target_required") : "");
+    scheduleButton.textContent = hasSchedule
+      ? t("editor.unschedule")
+      : (metadata.status === "modified" ? t("editor.schedule_modifications") : t("editor.schedule"));
+    setPermissionDisabledState(scheduleButton, !canUseSchedule);
+
+    const unpinAllowedForState = isLinked() || metadata.status === "scheduled";
+    const unpinTitle = !canTargetEdit
+      ? permissionReason("edit_targets")
+      : (!unpinAllowedForState ? t("editor.discussion_pinning_requires_links") : (!canUseUnpinSchedule ? permissionReason(hasScheduledUnpin ? "unschedule" : "schedule") : pinningAvailability.reason));
+    const unpinDisabled = state.busy || !unpinAllowedForState || !canTargetEdit || !canUseUnpinSchedule || !pinningAvailability.available;
+    applyDisabledState(unpinScheduleInput, unpinDisabled, unpinTitle, !canTargetEdit || !canUseUnpinSchedule);
+    applyDisabledState(unpinScheduleButton, unpinDisabled, unpinTitle, !canTargetEdit || !canUseUnpinSchedule);
+    unpinScheduleButton.textContent = hasScheduledUnpin ? t("editor.cancel_unpin") : t("editor.schedule_unpin");
+  }
+
+  function renderStaticSections(locked) {
+    renderImageList("global", document.querySelector('.image-list[data-scope="global"]'), locked);
+    renderLanguages(locked);
+    renderStatus();
+    renderTelegramBindingPanel();
+    renderTabLabels();
+    setActiveTab(state.activeTab);
+    renderSidebarAnnouncements();
+  }
+
+  function renderSidebarBusyState() {
+    for (const item of document.querySelectorAll(".announcement-item")) {
+      item.classList.toggle("disabled-nav", state.busy);
+      item.title = state.busy ? t("status.wait_finish") : "";
+    }
+  }
+
+  async function loadBroadcastIntoEditor(directoryName, { replaceHistory = false } = {}) {
+    const payload = await fetchJson(`/api/announcements/${encodeURIComponent(directoryName)}`, { method: "GET" });
+    state.operation = null;
+    closeImageImportModal();
+    stopOperationPolling();
+    setBusy(false, "");
+    applyApiPayload(payload, false);
+    render();
+    const url = announcementPageUrl();
+    if (replaceHistory) {
+      window.history.replaceState({ directory: directoryName }, "", url);
+    } else {
+      window.history.pushState({ directory: directoryName }, "", url);
+    }
+    const scroller = contentContainer || document.scrollingElement;
+    if (scroller) {
+      scroller.scrollTop = 0;
+      scroller.scrollLeft = 0;
+    }
+  }
+
+  function navigationDirectoryFromHref(href) {
+    const url = new URL(href, window.location.origin);
+    const match = url.pathname.match(/^\/announcements\/([^/]+)$/);
+    return match ? decodeURIComponent(match[1]) : null;
+  }
+
+  function shouldInterceptSidebarAnnouncementLink(link) {
+    if (!link.closest(".sidebar")) return false;
+    if (link.target === "_blank" || link.hasAttribute("download")) return false;
+    const href = link.getAttribute("href") || "";
+    return /^\/announcements\/[^/]+$/.test(href);
+  }
+
+  function render() {
+    const context = renderContext();
+    renderCoreFieldValues(context);
+    renderPrimaryControls(context);
+    renderStaticSections(context.locked);
+    renderSidebarBusyState();
+  }
+
+  function renderStatus() {
+    const metadata = state.broadcast.metadata;
+    const lines = [`${t("status.prefix")}: ${statusLabel(metadata.status)}`];
+    const canClearStatus = !!(metadata.last_attempt_at || metadata.last_output || metadata.last_result);
+
+    if (state.busy && state.busyMessage) {
+      lines.push(state.busyMessage);
+    }
+    if (metadata.scheduled_for) {
+      lines.push(`${t("status.scheduled_for")} (${state.broadcast.timezone}): ${formatTimestamp(metadata.scheduled_for)}`);
+    }
+    if (metadata.last_attempt_at) {
+      lines.push(`${lastAttemptLabel(metadata)} (${state.broadcast.timezone}): ${formatTimestamp(metadata.last_attempt_at)}`);
+    }
+
+    const output = state.operation && (state.operation.running || state.operation.completed)
+      ? state.operation.output
+      : metadata.last_output;
+    if (output) {
+      lines.push("");
+      lines.push(output);
+    }
+    statusBoxContent.textContent = lines.join("\n");
+    clearStatusButton.hidden = !canClearStatus;
+    clearStatusButton.disabled = state.busy || !canSaveAnnouncements();
+    clearStatusButton.title = !canSaveAnnouncements() ? permissionReason("save_announcements") : "";
+    setPermissionDisabledState(clearStatusButton, !canSaveAnnouncements());
+  }
+
+  function telegramBinding() {
+    const binding = state.broadcast.metadata.telegram_binding;
+    return binding && typeof binding === "object" ? binding : null;
+  }
+
+  function bindingPrimaryRows(binding) {
+    const rows = [];
+    const globalPrimaryMessageId = Number(binding.global_primary_message_id || 0);
+    const globalDiscussionMessageId = Number(binding.global_discussion_message_id || 0);
+    if ((globalPrimaryMessageId || globalDiscussionMessageId) && !binding.global_attached_to_language) {
+      rows.push({
+        kind: "global",
+        label: t("editor.telegram_binding.global"),
+        channelMessageId: globalPrimaryMessageId || "",
+        discussionMessageId: globalDiscussionMessageId || "",
+      });
+    }
+
+    const languageBindings = binding.languages || {};
+    for (const language of state.broadcast.metadata.language_order) {
+      const languageBinding = languageBindings[language];
+      if (!languageBinding || typeof languageBinding !== "object") continue;
+      const primaryMessageId = Number(languageBinding.primary_message_id || 0) || "";
+      const discussionMessageId = Number(languageBinding.discussion_message_id || 0) || "";
+      if (!primaryMessageId && !discussionMessageId) continue;
+      rows.push({
+        kind: "language",
+        language,
+        label: displayName(language),
+        channelMessageId: primaryMessageId,
+        discussionMessageId,
+      });
+    }
+    return rows;
+  }
+
+  function mappingRows() {
+    const binding = telegramBinding();
+    const boundRows = new Map();
+    if (binding) {
+      for (const row of bindingPrimaryRows(binding)) {
+        const rowKey = row.kind === "global" ? "global" : row.language;
+        boundRows.set(rowKey, row);
+      }
+    }
+    const rows = [];
+    if (boundRows.has("global")) {
+      rows.push(boundRows.get("global"));
+    }
+    for (const language of state.broadcast.metadata.language_order) {
+      if (!state.broadcast.metadata.enabled_languages[language] && !boundRows.has(language)) {
+        continue;
+      }
+      rows.push(boundRows.get(language) || {
+        kind: "language",
+        language,
+        label: displayName(language),
+        channelMessageId: "",
+        discussionMessageId: "",
+      });
+    }
+    return rows;
+  }
+
+  function hasIncompleteTelegramMapping(binding = telegramBinding()) {
+    if (!binding) {
+      return false;
+    }
+    return bindingPrimaryRows(binding)
+      .filter((row) => row.kind === "language")
+      .some((row) => !row.channelMessageId || !row.discussionMessageId);
+  }
+
+  function selectedChannelLinkTarget() {
+    const chat = selectedChatTarget();
+    return {
+      username: String(chat?.channel_username || "").trim().replace(/^@/, ""),
+      internalId: String(chat?.channel_internal_id || "").trim(),
+    };
+  }
+
+  function selectedDiscussionLinkTarget() {
+    const chat = selectedChatTarget();
+    return {
+      username: "",
+      internalId: String(chat?.discussion_group_internal_id || state.broadcast.discussion_group_link_chat_id || "").trim(),
+    };
+  }
+
+  function normalizeTelegramLinkValue(value, target) {
+    const text = String(value || "").trim();
+    if (!text) {
+      return "";
+    }
+    if (/^\d+$/.test(text)) {
+      return text;
+    }
+
+    const cLinkMatch = /^(?:https?:\/\/)?(?:www\.)?(?:t\.me|telegram\.me)\/c\/(\d+)\/(\d+)(?:[/?#].*)?$/i.exec(text);
+    if (cLinkMatch) {
+      const [, chatId, messageId] = cLinkMatch;
+      if (target.internalId && chatId !== target.internalId) {
+        return "";
+      }
+      return messageId;
+    }
+
+    const publicLinkMatch = /^(?:https?:\/\/)?(?:www\.)?(?:t\.me|telegram\.me)\/([A-Za-z0-9_]+)\/(\d+)(?:[/?#].*)?$/i.exec(text);
+    if (publicLinkMatch) {
+      const [, username, messageId] = publicLinkMatch;
+      if (target.username && username.toLowerCase() !== target.username.toLowerCase()) {
+        return "";
+      }
+      return messageId;
+    }
+
+    return "";
+  }
+
+  function buildTelegramMessageUrl(messageId, target) {
+    const resolvedId = String(messageId || "").trim();
+    if (!resolvedId) {
+      return "";
+    }
+    if (target.username) {
+      return `https://t.me/${target.username}/${resolvedId}`;
+    }
+    if (target.internalId) {
+      return `https://t.me/c/${target.internalId}/${resolvedId}`;
+    }
+    return "";
+  }
+
+  function telegramMappingRowKey(row) {
+    return row.kind === "global" ? "global" : row.language;
+  }
+
+  function telegramMappingEditReason(canEdit, hasTarget) {
+    if (!canEdit) {
+      return permissionReason("edit_targets");
+    }
+    if (!hasTarget) {
+      return t("editor.target_required");
+    }
+    return "";
+  }
+
+  function telegramMappingGroupBlockReason(groupLinksAvailable, row) {
+    if (groupLinksAvailable && !row.channelMessageId) {
+      return t("editor.group_link_requires_channel");
+    }
+    if (!groupLinksAvailable) {
+      return t("editor.discussion_pinning_requires_group");
+    }
+    return "";
+  }
+
+  function telegramMappingRowsPayload(targetRow, side, normalizedValue) {
+    const targetRowKey = telegramMappingRowKey(targetRow);
+    return mappingRows().map((currentRow) => {
+      const isSameRow = telegramMappingRowKey(currentRow) === targetRowKey;
+      const channelMessageId = isSameRow
+        ? (side === "channel" ? normalizedValue : String(currentRow.channelMessageId || "").trim())
+        : String(currentRow.channelMessageId || "").trim();
+      const discussionMessageId = isSameRow
+        ? (side === "group" ? normalizedValue : String(currentRow.discussionMessageId || "").trim())
+        : String(currentRow.discussionMessageId || "").trim();
+      return {
+        kind: currentRow.kind,
+        language: currentRow.language || null,
+        channel_message_id: channelMessageId || null,
+        discussion_message_id: discussionMessageId || null,
+      };
+    });
+  }
+
+  function saveTelegramMappingCell(row, side, rawValue, channelTarget, discussionTarget) {
+    const target = side === "channel" ? channelTarget : discussionTarget;
+    const normalized = normalizeTelegramLinkValue(rawValue, target);
+    return actionPost("/set-telegram-mapping", {
+      telegram_bot_id: state.broadcast.metadata.telegram_bot_id || "",
+      telegram_chat_id: state.broadcast.metadata.telegram_chat_id || "",
+      rows: telegramMappingRowsPayload(row, side, normalized),
+    }, {
+      busyMessage: t("status.saving_telegram_mapping"),
+      preserveLocalEdits: true,
+    });
+  }
+
+  function buildTelegramMappingActionButton(label, href, disabled, reason) {
+    const button = document.createElement("button");
+    button.type = "button";
+    button.textContent = label;
+    button.disabled = disabled || !href;
+    button.title = reason || "";
+    if (href && !disabled) {
+      button.addEventListener("click", () => {
+        window.open(href, "_blank", "noopener,noreferrer");
+      });
+    }
+    return button;
+  }
+
+  function buildTelegramMappingLinkedCell({
+    row,
+    side,
+    messageId,
+    target,
+    canEdit,
+    canOpen,
+    groupBlocked,
+    groupBlockedReason,
+    channelUnlinkBlocked,
+    channelTarget,
+    discussionTarget,
+  }) {
+    const cell = document.createElement("td");
+    cell.className = "telegram-mapping-cell";
+
+    const actions = document.createElement("div");
+    actions.className = "telegram-cell-actions";
+    actions.appendChild(buildTelegramMappingActionButton(
+      t("editor.open"),
+      buildTelegramMessageUrl(messageId, target),
+      state.busy || !canOpen || groupBlocked,
+      !canOpen
+        ? permissionReason("open_telegram")
+        : (groupBlocked ? groupBlockedReason : ""),
+    ));
+
+    const unlinkCellButton = document.createElement("button");
+    unlinkCellButton.type = "button";
+    unlinkCellButton.textContent = t("editor.unlink_message_id").replace("{id}", String(messageId));
+    unlinkCellButton.disabled = state.busy || !canEdit || groupBlocked || channelUnlinkBlocked;
+    unlinkCellButton.title = !canEdit
+      ? permissionReason("edit_targets")
+      : (groupBlocked ? groupBlockedReason : (channelUnlinkBlocked ? t("editor.unlink_channel_requires_group_clear") : ""));
+    setPermissionDisabledState(unlinkCellButton, !canEdit);
+    unlinkCellButton.addEventListener("click", () => {
+      saveTelegramMappingCell(row, side, "", channelTarget, discussionTarget).catch((error) => alert(error.message));
+    });
+    actions.appendChild(unlinkCellButton);
+
+    cell.appendChild(actions);
+    return cell;
+  }
+
+  function buildTelegramMappingEditableCell({
+    row,
+    side,
+    canEdit,
+    hasTarget,
+    editReason,
+    groupBlocked,
+    groupBlockedReason,
+    channelTarget,
+    discussionTarget,
+  }) {
+    const cell = document.createElement("td");
+    cell.className = "telegram-mapping-cell";
+
+    const editor = document.createElement("div");
+    editor.className = "telegram-cell-editor";
+
+    const input = document.createElement("input");
+    input.type = "text";
+    input.value = "";
+    input.placeholder = t("editor.telegram_binding_manual_placeholder");
+    input.disabled = state.busy || !canEdit || !hasTarget || groupBlocked;
+    input.title = groupBlocked ? groupBlockedReason : editReason;
+
+    const saveButton = document.createElement("button");
+    saveButton.type = "button";
+    saveButton.textContent = t("editor.save");
+    saveButton.disabled = state.busy || !canEdit || !hasTarget || groupBlocked;
+    saveButton.title = input.title;
+    setPermissionDisabledState(saveButton, !canEdit);
+
+    const submit = () => {
+      saveTelegramMappingCell(row, side, input.value, channelTarget, discussionTarget)
+        .catch((error) => alert(error.message));
+    };
+    saveButton.addEventListener("click", submit);
+    input.addEventListener("keydown", (event) => {
+      if (event.key === "Enter") {
+        event.preventDefault();
+        submit();
+      }
+    });
+
+    editor.appendChild(input);
+    editor.appendChild(saveButton);
+    cell.appendChild(editor);
+    return cell;
+  }
+
+  function buildTelegramMappingCell({
+    row,
+    side,
+    channelTarget,
+    discussionTarget,
+    canEdit,
+    canOpen,
+    hasTarget,
+    editReason,
+    groupLinksAvailable,
+  }) {
+    const messageId = side === "channel" ? row.channelMessageId : row.discussionMessageId;
+    const target = side === "channel" ? channelTarget : discussionTarget;
+    const groupBlocked = side === "group" && (!groupLinksAvailable || !row.channelMessageId);
+    const groupBlockedReason = telegramMappingGroupBlockReason(groupLinksAvailable, row);
+
+    if (messageId) {
+      return buildTelegramMappingLinkedCell({
+        row,
+        side,
+        messageId,
+        target,
+        canEdit,
+        canOpen,
+        groupBlocked,
+        groupBlockedReason,
+        channelUnlinkBlocked: side === "channel" && !!row.discussionMessageId,
+        channelTarget,
+        discussionTarget,
+      });
+    }
+
+    return buildTelegramMappingEditableCell({
+      row,
+      side,
+      canEdit,
+      hasTarget,
+      editReason,
+      groupBlocked,
+      groupBlockedReason,
+      channelTarget,
+      discussionTarget,
+    });
+  }
+
+  function renderTelegramBindingPanel() {
+    telegramBindingPanel.innerHTML = "";
+    const canEdit = canEditTargets();
+    const hasTarget = hasSelectedTarget();
+    const editReason = telegramMappingEditReason(canEdit, hasTarget);
+    const body = document.createElement("div");
+    body.className = "telegram-binding-body";
+
+    const help = document.createElement("p");
+    help.className = "telegram-binding-help";
+    help.textContent = t("editor.telegram_binding_help");
+    body.appendChild(help);
+
+    const rows = mappingRows();
+    const table = document.createElement("table");
+    table.className = "telegram-mapping-table";
+
+    const head = document.createElement("thead");
+    head.innerHTML = `
+      
+        
+        ${t("editor.telegram_binding.channel_short")}
+        ${t("editor.telegram_binding.group_short")}
+      
+    `;
+    table.appendChild(head);
+
+    const bodyElement = document.createElement("tbody");
+    const colgroup = document.createElement("colgroup");
+    colgroup.innerHTML = `
+      
+      
+      
+    `;
+    table.appendChild(colgroup);
+    const channelTarget = selectedChannelLinkTarget();
+    const discussionTarget = selectedDiscussionLinkTarget();
+    const groupLinksAvailable = supportsGroupLinks();
+
+    for (const row of rows) {
+      const rowWrap = document.createElement("tr");
+      rowWrap.className = "telegram-mapping-row";
+
+      const label = document.createElement("th");
+      label.className = "telegram-mapping-label";
+      label.textContent = row.label;
+      rowWrap.appendChild(label);
+
+      for (const side of ["channel", "group"]) {
+        const cell = buildTelegramMappingCell({
+          row,
+          side,
+          channelTarget,
+          discussionTarget,
+          canEdit,
+          canOpen: canOpenTelegram(),
+          hasTarget,
+          editReason,
+          groupLinksAvailable,
+        });
+        rowWrap.appendChild(cell);
+      }
+      bodyElement.appendChild(rowWrap);
+    }
+    table.appendChild(bodyElement);
+    body.appendChild(table);
+    telegramBindingPanel.appendChild(body);
+  }
+
+  function createLanguageTitleToggle(language, enabled, locked, editReason) {
+    const toggleLabel = document.createElement("label");
+    toggleLabel.className = "language-title-toggle";
+    toggleLabel.title = editReason;
+
+    const toggle = document.createElement("input");
+    toggle.type = "checkbox";
+    toggle.checked = enabled;
+    toggle.disabled = locked || state.busy || !canEditContent();
+    toggle.title = editReason;
+    setPermissionDisabledState(toggle, !canEditContent());
+    toggle.addEventListener("change", () => {
+      state.broadcast.metadata.enabled_languages[language] = toggle.checked;
+      renderPreservingContentScroll(() => {
+        render();
+      });
+    });
+    toggleLabel.appendChild(toggle);
+
+    const title = document.createElement("h2");
+    title.textContent = displayName(language);
+    toggleLabel.appendChild(title);
+    return toggleLabel;
+  }
+
+  function createLanguagePreviewToggle(language) {
+    const disablePreviewLabel = document.createElement("label");
+    disablePreviewLabel.className = "checkbox-label language-subtoggle";
+    disablePreviewLabel.title = !canEditContent() ? permissionReason("edit_content") : "";
+
+    const disablePreviewToggle = document.createElement("input");
+    disablePreviewToggle.type = "checkbox";
+    disablePreviewToggle.className = "disable-link-preview-toggle";
+    disablePreviewToggle.dataset.language = language;
+    disablePreviewToggle.checked = !!state.broadcast.metadata.disable_link_previews?.[language];
+    disablePreviewToggle.disabled = state.busy || !canEditContent();
+    disablePreviewToggle.title = !canEditContent() ? permissionReason("edit_content") : "";
+    setPermissionDisabledState(disablePreviewToggle, !canEditContent());
+    disablePreviewToggle.addEventListener("change", () => {
+      state.broadcast.metadata.disable_link_previews[language] = disablePreviewToggle.checked;
+      syncGlobalLinkPreviewToggle();
+      updateSaveButtonState();
+    });
+    disablePreviewLabel.appendChild(disablePreviewToggle);
+
+    const disablePreviewText = document.createElement("span");
+    disablePreviewText.textContent = t("editor.disable_link_previews");
+    disablePreviewLabel.appendChild(disablePreviewText);
+    return disablePreviewLabel;
+  }
+
+  function createLanguageHeading(language, enabled, locked, editReason) {
+    const toolbar = document.createElement("div");
+    toolbar.className = "language-toolbar";
+
+    const heading = document.createElement("div");
+    heading.className = "language-heading";
+    heading.appendChild(createLanguageTitleToggle(language, enabled, locked, editReason));
+
+    if (enabled) {
+      heading.appendChild(createLanguagePreviewToggle(language));
+    }
+
+    toolbar.appendChild(heading);
+    return toolbar;
+  }
+
+  function createLanguageUploadControls(language, locked, editReason) {
+    const uploadWrap = document.createElement("div");
+    uploadWrap.className = "image-controls";
+    uploadWrap.title = editReason;
+
+    const uploadLabel = document.createElement("span");
+    uploadLabel.className = "upload-label";
+    uploadLabel.textContent = t("editor.add_images_for");
+    uploadWrap.appendChild(uploadLabel);
+
+    const uploadInput = document.createElement("input");
+    uploadInput.type = "file";
+    uploadInput.accept = "image/*";
+    uploadInput.multiple = true;
+    uploadInput.disabled = locked || state.busy || !canEditContent();
+    uploadInput.title = editReason;
+    setPermissionDisabledState(uploadInput, !canEditContent());
+    uploadInput.addEventListener("change", async () => {
+      if (!uploadInput.files || uploadInput.files.length === 0) return;
+      try {
+        await uploadImages(language, uploadInput.files);
+        uploadInput.value = "";
+      } catch (error) {
+        alert(error.message);
+      }
+    });
+    uploadWrap.appendChild(uploadInput);
+    return uploadWrap;
+  }
+
+  function languageFormattingActions(language, snippet, flagsSnippet) {
+    return [
+      { labelKey: "editor.format_bold", action: { type: "inline_style", style: "bold" } },
+      { labelKey: "editor.format_italic", action: { type: "inline_style", style: "italic" } },
+      { labelKey: "editor.format_underline", action: { type: "inline_style", style: "underline" } },
+      { labelKey: "editor.format_strikethrough", action: { type: "inline_style", style: "strikethrough" } },
+      { labelKey: "editor.format_code", action: { type: "code" } },
+      { labelKey: "editor.format_spoiler", action: { type: "spoiler" } },
+      { labelKey: "editor.format_link", action: { type: "link" } },
+      { labelKey: "editor.insert_language_links", action: { type: "language_links", snippet } },
+      { labelKey: "editor.insert_language_flags", action: { type: "language_flags", snippet: flagsSnippet } },
+      { labelKey: "editor.format_blockquote", action: { type: "blockquote" } },
+      { labelKey: "editor.format_bullets", action: { type: "bullet_list" } },
+      { labelKey: "editor.format_numbered_list", action: { type: "numbered_list" } },
+    ];
+  }
+
+  function createLanguageFormatToolbar(language, textarea, previewContent, snippet, flagsSnippet) {
+    const formatToolbar = document.createElement("div");
+    formatToolbar.className = "format-toolbar";
+
+    for (const item of languageFormattingActions(language, snippet, flagsSnippet)) {
+      const missingLanguageLinks = item.action.type === "language_links" && !snippet;
+      formatToolbar.appendChild(createSecondaryButton({
+        label: t(item.labelKey),
+        disabled: state.busy || !canEditContent() || missingLanguageLinks,
+        title: missingLanguageLinks
+          ? t("editor.no_language_links_available")
+          : (contentEditReasonFor(false) || t(item.labelKey)),
+        permissionDenied: !canEditContent(),
+        onClick: () => applyFormattingAction(textarea, language, item.action, previewContent),
+      }));
+    }
+
+    return formatToolbar;
+  }
+
+  function createLanguageEditorLayout(language, snippet, flagsSnippet) {
+    const splitLayout = document.createElement("div");
+    splitLayout.className = "editor-preview-layout";
+
+    const previewPanel = document.createElement("div");
+    previewPanel.className = "preview-panel";
+    previewPanel.hidden = false;
+
+    const previewContent = document.createElement("div");
+    previewContent.className = "preview-content";
+    previewPanel.appendChild(previewContent);
+
+    const textarea = document.createElement("textarea");
+    textarea.value = state.broadcast.texts[language] || "";
+    textarea.disabled = state.busy || !canEditContent();
+    textarea.title = !canEditContent() ? permissionReason("edit_content") : "";
+    setPermissionDisabledState(textarea, !canEditContent());
+    textarea._previewPanel = previewPanel;
+    previewContent._textarea = textarea;
+    textarea.addEventListener("input", () => {
+      state.broadcast.texts[language] = textarea.value;
+      syncTextareaHeight(textarea);
+      updateSaveButtonState();
+      schedulePreviewUpdate(language, previewContent);
+    });
+    textarea.addEventListener("keydown", (event) => {
+      handleTextareaShortcut(event, textarea, language, previewContent);
+    });
+
+    const inputPanel = document.createElement("div");
+    inputPanel.className = "editor-input-panel";
+    inputPanel.appendChild(createLanguageFormatToolbar(language, textarea, previewContent, snippet, flagsSnippet));
+    inputPanel.appendChild(textarea);
+
+    splitLayout.appendChild(inputPanel);
+    splitLayout.appendChild(previewPanel);
+    return { splitLayout, textarea, previewContent };
+  }
+
+  function createLanguagePanel(language, locked) {
+    const panel = document.createElement("section");
+    panel.className = "language-panel";
+    const enabled = !!state.broadcast.metadata.enabled_languages[language];
+    if (!enabled) {
+      panel.classList.add("disabled");
+    }
+
+    const editReason = contentEditReasonFor(locked);
+    const snippet = buildCrossLanguageSnippet(language);
+    const flagsSnippet = buildLanguageFlagsSnippet();
+
+    panel.appendChild(createLanguageHeading(language, enabled, locked, editReason));
+
+    const body = document.createElement("div");
+    body.className = "language-body";
+    body.appendChild(createLanguageUploadControls(language, locked, editReason));
+    body.appendChild(buildImagePasteControls(language, locked, editReason));
+
+    const imageList = document.createElement("div");
+    imageList.className = "image-list";
+    renderImageList(language, imageList, locked);
+    body.appendChild(imageList);
+
+    const { splitLayout, textarea, previewContent } = createLanguageEditorLayout(language, snippet, flagsSnippet);
+    body.appendChild(splitLayout);
+    panel.appendChild(body);
+
+    return { panel, textarea, previewContent };
+  }
+
+  function renderLanguages(locked) {
+    languagesContainer.innerHTML = "";
+    for (const language of state.broadcast.metadata.language_order) {
+      const { panel, textarea, previewContent } = createLanguagePanel(language, locked);
+      languagesContainer.appendChild(panel);
+      syncTextareaHeight(textarea);
+      schedulePreviewUpdate(language, previewContent);
+    }
+  }
+
+  function renderImageList(scope, container, locked) {
+    container.innerHTML = "";
+    const images = state.broadcast.images[scope] || [];
+    const editReason = contentEditReasonFor(locked);
+
+    images.forEach((image, index) => {
+      const card = document.createElement("div");
+      card.className = "image-card";
+      card.draggable = !locked && !state.busy && canEditContent();
+      card.dataset.index = String(index);
+      card.addEventListener("dragstart", (event) => {
+        event.dataTransfer.setData("text/plain", String(index));
+      });
+      card.addEventListener("dragover", (event) => event.preventDefault());
+      card.addEventListener("drop", (event) => {
+        event.preventDefault();
+        const sourceIndex = Number(event.dataTransfer.getData("text/plain"));
+        reorderImages(scope, sourceIndex, index);
+      });
+
+      const img = document.createElement("img");
+      img.src = image.url;
+      img.alt = image.name;
+      img.draggable = false;
+      card.appendChild(img);
+
+      const label = document.createElement("small");
+      label.textContent = image.name;
+      card.appendChild(label);
+
+      const moveControls = document.createElement("div");
+      moveControls.className = "move-controls";
+      moveControls.title = editReason;
+
+      const moveLeft = document.createElement("button");
+      moveLeft.type = "button";
+      moveLeft.textContent = t("editor.move_left");
+      moveLeft.disabled = locked || state.busy || !canEditContent() || index === 0;
+      moveLeft.title = editReason;
+      setPermissionDisabledState(moveLeft, !canEditContent());
+      moveLeft.addEventListener("click", () => reorderImages(scope, index, index - 1));
+      moveControls.appendChild(moveLeft);
+
+      const moveRight = document.createElement("button");
+      moveRight.type = "button";
+      moveRight.textContent = t("editor.move_right");
+      moveRight.disabled = locked || state.busy || !canEditContent() || index === images.length - 1;
+      moveRight.title = editReason;
+      setPermissionDisabledState(moveRight, !canEditContent());
+      moveRight.addEventListener("click", () => reorderImages(scope, index, index + 1));
+      moveControls.appendChild(moveRight);
+      card.appendChild(moveControls);
+
+      const remove = document.createElement("button");
+      remove.type = "button";
+      remove.textContent = t("editor.remove_image");
+      remove.disabled = locked || state.busy || !canEditContent();
+      remove.title = editReason;
+      setPermissionDisabledState(remove, !canEditContent());
+      remove.addEventListener("click", () => {
+        state.broadcast.images[scope] = images.filter((_, itemIndex) => itemIndex !== index);
+        render();
+      });
+      card.appendChild(remove);
+      container.appendChild(card);
+    });
+  }
+
+  async function uploadImage(scope, file) {
+    const formData = new FormData();
+    formData.append("scope", scope);
+    formData.append("file", file, file.name);
+    const payload = await fetchJson(announcementUrl("/upload-image"), {
+      method: "POST",
+      body: formData,
+    });
+    state.broadcast.images[scope].push(payload);
+    render();
+  }
+
+  async function uploadImages(scope, files) {
+    for (const file of Array.from(files)) {
+      await uploadImage(scope, file);
+    }
+  }
+
+  function buildImagePasteControls(scope, locked, editReason) {
+    const wrap = document.createElement("div");
+    wrap.className = "image-paste-controls";
+    wrap.appendChild(createSecondaryButton({
+      label: t("editor.open_image_import"),
+      disabled: locked || state.busy || !canEditContent(),
+      title: editReason,
+      permissionDenied: !canEditContent(),
+      onClick: () => openImageImportModal(scope),
+    }));
+
+    return wrap;
+  }
+
+  function openImageImportModal(scope) {
+    if (state.busy || !canEditContent() || isTextOnlyEditable()) {
+      return;
+    }
+    state.imageImportScope = scope;
+    imageImportFormatSelect.value = "auto";
+    imageImportUrlInput.value = "";
+    imageImportModal.hidden = false;
+    syncImageImportModalState();
+    imageImportUrlInput.focus();
+  }
+
+  function closeImageImportModal() {
+    state.imageImportScope = null;
+    imageImportModal.hidden = true;
+    imageImportUrlInput.value = "";
+  }
+
+  function syncImageImportModalState() {
+    const disabled = state.busy || !canEditContent() || isTextOnlyEditable() || !state.imageImportScope;
+    const reason = contentEditReasonFor();
+    imageImportFormatSelect.disabled = disabled;
+    imageImportFormatSelect.title = disabled ? reason : "";
+    imageImportUrlInput.disabled = disabled;
+    imageImportUrlInput.title = disabled ? reason : "";
+    imageImportSubmitUrlButton.disabled = disabled;
+    imageImportSubmitUrlButton.title = disabled ? reason : "";
+  }
+
+  async function importImageUrl(scope, url) {
+    const payload = await fetchJson(announcementUrl("/import-image-url"), {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ scope, url }),
+    });
+    state.broadcast.images[scope].push(payload);
+    render();
+  }
+
+  async function submitImageImportUrl() {
+    if (!state.imageImportScope) return;
+    const url = imageImportUrlInput.value.trim();
+    if (!url) return;
+    await importImageUrl(state.imageImportScope, url);
+    closeImageImportModal();
+  }
+
+  function clipboardImageFile(clipboardData) {
+    if (!clipboardData?.items) {
+      return null;
+    }
+    for (const item of Array.from(clipboardData.items)) {
+      if (!item.type.startsWith("image/")) continue;
+      const file = item.getAsFile();
+      if (file) {
+        return file;
+      }
+    }
+    return null;
+  }
+
+  async function uploadPastedImage(scope, blob, format) {
+    const convertedFile = await convertImageBlobToFile(blob, format);
+    await uploadImage(scope, convertedFile);
+  }
+
+  async function detectPreferredImageFormat(blob) {
+    if (blob.type === "image/jpeg") {
+      return "jpeg";
+    }
+    let bitmap;
+    try {
+      bitmap = await createImageBitmap(blob);
+      const maxDimension = 128;
+      const scale = Math.min(1, maxDimension / Math.max(bitmap.width, bitmap.height));
+      const sampleWidth = Math.max(1, Math.round(bitmap.width * scale));
+      const sampleHeight = Math.max(1, Math.round(bitmap.height * scale));
+      const canvas = document.createElement("canvas");
+      canvas.width = sampleWidth;
+      canvas.height = sampleHeight;
+      const context = canvas.getContext("2d", { willReadFrequently: true });
+      if (!context) {
+        return "png";
+      }
+      context.drawImage(bitmap, 0, 0, sampleWidth, sampleHeight);
+      const imageData = context.getImageData(0, 0, sampleWidth, sampleHeight).data;
+      const colorBuckets = new Set();
+      for (let index = 0; index < imageData.length; index += 4) {
+        if (imageData[index + 3] < 250) {
+          return "png";
+        }
+        const bucket =
+          ((imageData[index] >> 4) << 8)
+          | ((imageData[index + 1] >> 4) << 4)
+          | (imageData[index + 2] >> 4);
+        colorBuckets.add(bucket);
+        if (colorBuckets.size > 224) {
+          return "jpeg";
+        }
+      }
+      return "png";
+    } catch {
+      return "png";
+    } finally {
+      bitmap?.close?.();
+    }
+  }
+
+  async function convertImageBlobToFile(blob, format) {
+    const resolvedFormat = format === "auto" ? await detectPreferredImageFormat(blob) : format;
+    const mimeType = resolvedFormat === "png" ? "image/png" : "image/jpeg";
+    const extension = resolvedFormat === "png" ? "png" : "jpg";
+    if (blob.type === mimeType) {
+      return new File([blob], `clipboard-image.${extension}`, { type: mimeType });
+    }
+
+    let bitmap;
+    try {
+      bitmap = await createImageBitmap(blob);
+      const canvas = document.createElement("canvas");
+      canvas.width = bitmap.width;
+      canvas.height = bitmap.height;
+      const context = canvas.getContext("2d");
+      if (!context) {
+        throw new Error(t("alert.image_conversion_failed"));
+      }
+      if (resolvedFormat === "jpeg") {
+        context.fillStyle = "#ffffff";
+        context.fillRect(0, 0, canvas.width, canvas.height);
+      }
+      context.drawImage(bitmap, 0, 0);
+      const convertedBlob = await new Promise((resolve, reject) => {
+        canvas.toBlob(
+          (result) => {
+            if (result) {
+              resolve(result);
+            } else {
+              reject(new Error(t("alert.image_conversion_failed")));
+            }
+          },
+          mimeType,
+          resolvedFormat === "jpeg" ? 0.92 : undefined,
+        );
+      });
+      return new File([convertedBlob], `clipboard-image.${extension}`, { type: mimeType });
+    } catch (error) {
+      if (error instanceof Error) {
+        throw error;
+      }
+      throw new Error(t("alert.image_conversion_failed"));
+    } finally {
+      bitmap?.close?.();
+    }
+  }
+
+  function reorderImages(scope, sourceIndex, targetIndex) {
+    if (sourceIndex === targetIndex) return;
+    const images = clone(state.broadcast.images[scope]);
+    const [moved] = images.splice(sourceIndex, 1);
+    images.splice(targetIndex, 0, moved);
+    state.broadcast.images[scope] = images;
+    render();
+  }
+
+  function buildSavePayload() {
+    const metadata = state.broadcast.metadata;
+    const savedMetadata = state.savedBroadcast.metadata;
+    const payload = { texts: {}, images: {} };
+
+    if (metadata.title !== savedMetadata.title) {
+      payload.title = {
+        base_revision: metadata.field_revisions.title,
+        value: metadata.title,
+      };
+    }
+
+    if (!areTargetsLocked()) {
+      if ((metadata.telegram_bot_id || "") !== (savedMetadata.telegram_bot_id || "")) {
+        payload.telegram_bot_id = {
+          base_revision: metadata.field_revisions.telegram_bot_id,
+          value: metadata.telegram_bot_id || "",
+        };
+      }
+      if ((metadata.telegram_chat_id || "") !== (savedMetadata.telegram_chat_id || "")) {
+        payload.telegram_chat_id = {
+          base_revision: metadata.field_revisions.telegram_chat_id,
+          value: metadata.telegram_chat_id || "",
+        };
+      }
+    }
+
+    if (!isLinked()) {
+      if (JSON.stringify(metadata.language_order) !== JSON.stringify(savedMetadata.language_order)) {
+        payload.language_order = {
+          base_revision: metadata.field_revisions.language_order,
+          value: [...metadata.language_order],
+        };
+      }
+      if (JSON.stringify(metadata.enabled_languages) !== JSON.stringify(savedMetadata.enabled_languages)) {
+        payload.enabled_languages = {
+          base_revision: metadata.field_revisions.enabled_languages,
+          value: clone(metadata.enabled_languages),
+        };
+      }
+      for (const scope of ["global", "fi", "sv", "en"]) {
+        const currentNames = (state.broadcast.images[scope] || []).map((item) => item.name);
+        const savedNames = (state.savedBroadcast.images[scope] || []).map((item) => item.name);
+        if (JSON.stringify(currentNames) !== JSON.stringify(savedNames)) {
+          payload.images[scope] = {
+            base_revision: metadata.field_revisions[`${scope}.images`],
+            value: currentNames,
+          };
+        }
+      }
+    }
+
+    if ((metadata.discussion_pinning_mode || "first") !== (savedMetadata.discussion_pinning_mode || "first")) {
+      payload.discussion_pinning_mode = {
+        base_revision: metadata.field_revisions.discussion_pinning_mode,
+        value: metadata.discussion_pinning_mode || "first",
+      };
+    }
+
+    if (!!metadata.delete_pin_service_messages !== !!savedMetadata.delete_pin_service_messages) {
+      payload.delete_pin_service_messages = {
+        base_revision: metadata.field_revisions.delete_pin_service_messages,
+        value: !!metadata.delete_pin_service_messages,
+      };
+    }
+
+    if ((metadata.schedule_draft_for || "") !== (savedMetadata.schedule_draft_for || "")) {
+      payload.schedule_draft_for = {
+        base_revision: metadata.field_revisions.schedule_draft_for,
+        value: metadata.schedule_draft_for || "",
+      };
+    }
+
+    if (JSON.stringify(metadata.disable_link_previews || {}) !== JSON.stringify(savedMetadata.disable_link_previews || {})) {
+      payload.disable_link_previews = {
+        base_revision: metadata.field_revisions.disable_link_previews,
+        value: clone(metadata.disable_link_previews || {}),
+      };
+    }
+
+    for (const language of ["fi", "sv", "en"]) {
+      const currentText = state.broadcast.texts[language] || "";
+      const savedText = state.savedBroadcast.texts[language] || "";
+      if (normalizeComparableText(currentText) !== normalizeComparableText(savedText)) {
+        payload.texts[language] = {
+          base_revision: metadata.field_revisions[`${language}.text`],
+          value: currentText,
+        };
+      }
+    }
+    return payload;
+  }
+
+  async function saveBroadcast() {
+    const previousDirectory = state.broadcast.directory;
+    const payload = await fetchJson(announcementUrl("/save"), {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify(buildSavePayload()),
+    });
+    applyApiPayload(payload, false);
+    if (state.broadcast.directory !== previousDirectory) {
+      window.history.replaceState({}, "", announcementPageUrl());
+    }
+    render();
+  }
+
+  async function startPublishOperation() {
+    if (!confirmOutboundAction("publish")) return;
+    setBusy(true, state.broadcast.metadata.status === "modified" ? t("status.posting_modifications") : t("status.posting"));
+    try {
+      const payload = await fetchJson(announcementUrl("/publish"), {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: "{}",
+      });
+      applyApiPayload(payload, false);
+      state.operation = payload.operation || null;
+      render();
+      startOperationPolling();
+    } catch (error) {
+      setBusy(false, "");
+      throw error;
+    }
+  }
+
+  function startOperationPolling() {
+    stopOperationPolling();
+    state.pollTimer = window.setInterval(async () => {
+      try {
+        const payload = await fetchJson(announcementUrl("/operation"), { method: "GET" });
+        state.operation = payload.operation || null;
+        applyApiPayload(payload, false);
+        render();
+        if (!state.operation || !state.operation.running) {
+          stopOperationPolling();
+          setBusy(false, "");
+        }
+      } catch (error) {
+        stopOperationPolling();
+        setBusy(false, "");
+        alert(error.message);
+      }
+    }, 1000);
+  }
+
+  function stopOperationPolling() {
+    if (state.pollTimer !== null) {
+      window.clearInterval(state.pollTimer);
+      state.pollTimer = null;
+    }
+  }
+
+  async function actionPost(path, body, { busyMessage = t("status.busy_updating"), preserveLocalEdits = false } = {}) {
+    setBusy(true, busyMessage);
+    try {
+      const payload = await fetchJson(announcementUrl(path), {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: body ? JSON.stringify(body) : "{}",
+      });
+      if (payload.broadcast || payload.announcements) {
+        applyApiPayload(payload, preserveLocalEdits);
+        window.history.replaceState({}, "", announcementPageUrl());
+      }
+      if (payload.warning) {
+        alert(payload.warning);
+      }
+      state.operation = null;
+      render();
+      return payload;
+    } finally {
+      setBusy(false, "");
+    }
+  }
+
+  saveButton.addEventListener("click", async () => {
+    try {
+      await saveBroadcast();
+    } catch (error) {
+      alert(error.message);
+    }
+  });
+
+  publishButton.addEventListener("click", async () => {
+    try {
+      await startPublishOperation();
+    } catch (error) {
+      alert(error.message);
+    }
+  });
+
+  scheduleButton.addEventListener("click", async () => {
+    try {
+      if (state.broadcast.metadata.scheduled_for) {
+        if (!confirmUnsavedChanges("confirm.navigate_unsaved")) {
+          return;
+        }
+        await actionPost("/unschedule", null, { busyMessage: t("status.unscheduling") });
+        return;
+      }
+      if (!scheduleInput.value) {
+        alert(t("alert.pick_schedule"));
+        return;
+      }
+      if (!confirmOutboundAction("schedule")) {
+        return;
+      }
+      await actionPost("/schedule", { scheduled_for: scheduleInput.value }, {
+        busyMessage: state.broadcast.metadata.status === "modified" ? t("status.scheduling_modifications") : t("status.scheduling"),
+        preserveLocalEdits: hasUnsavedChanges(),
+      });
+    } catch (error) {
+      alert(error.message);
+    }
+  });
+
+  for (const button of tabButtons) {
+    button.addEventListener("click", () => setActiveTab(button.dataset.tab || "main"));
+  }
+
+  deleteTelegramButton.addEventListener("click", () => {
+    if (!confirmUnsavedChanges("confirm.navigate_unsaved")) return;
+    if (!confirm(t("confirm.delete_telegram"))) return;
+    actionPost(
+      "/delete-telegram",
+      {
+        telegram_bot_id: state.broadcast.metadata.telegram_bot_id || "",
+        telegram_chat_id: state.broadcast.metadata.telegram_chat_id || "",
+      },
+      { busyMessage: t("status.deleting_telegram") },
+    ).catch((error) => alert(error.message));
+  });
+
+  clearStatusButton.addEventListener("click", () => {
+    actionPost("/clear-status", null, { busyMessage: t("status.clearing_status") }).catch((error) => alert(error.message));
+  });
+
+  unpinScheduleButton.addEventListener("click", async () => {
+    try {
+      if (state.broadcast.metadata.scheduled_unpin_for) {
+        await actionPost("/cancel-unpin", null, { busyMessage: t("status.canceling_unpin") });
+        return;
+      }
+      if (!unpinScheduleInput.value) {
+        alert(t("alert.pick_schedule"));
+        return;
+      }
+      await actionPost("/schedule-unpin", { scheduled_for: unpinScheduleInput.value }, {
+        busyMessage: t("status.scheduling_unpin"),
+      });
+    } catch (error) {
+      alert(error.message);
+    }
+  });
+
+  deleteStorageButton.addEventListener("click", async () => {
+    if (!confirmUnsavedChanges("confirm.navigate_unsaved")) return;
+    if (!confirm(t("confirm.delete_storage"))) return;
+    try {
+      await actionPost("/delete-storage", null, { busyMessage: t("status.deleting_storage") });
+      window.location.href = "/";
+    } catch (error) {
+      alert(error.message);
+    }
+  });
+
+  globalUpload.addEventListener("change", async () => {
+    if (!globalUpload.files || globalUpload.files.length === 0) return;
+    try {
+      await uploadImages("global", globalUpload.files);
+      globalUpload.value = "";
+    } catch (error) {
+      alert(error.message);
+    }
+  });
+
+  openGlobalImageImportButton.addEventListener("click", () => {
+    openImageImportModal("global");
+  });
+
+  imageImportCloseButton.addEventListener("click", closeImageImportModal);
+  imageImportCancelButton.addEventListener("click", closeImageImportModal);
+  imageImportSubmitUrlButton.addEventListener("click", () => {
+    submitImageImportUrl().catch((error) => alert(error.message));
+  });
+  imageImportUrlInput.addEventListener("keydown", (event) => {
+    if (event.key !== "Enter") return;
+    event.preventDefault();
+    submitImageImportUrl().catch((error) => alert(error.message));
+  });
+  imageImportModal.addEventListener("click", (event) => {
+    if (event.target === imageImportModal) {
+      closeImageImportModal();
+    }
+  });
+  imageImportModal.addEventListener("paste", (event) => {
+    const clipboardFile = clipboardImageFile(event.clipboardData);
+    if (!clipboardFile || !state.imageImportScope) {
+      return;
+    }
+    event.preventDefault();
+    uploadPastedImage(state.imageImportScope, clipboardFile, imageImportFormatSelect.value || "auto")
+      .then(() => closeImageImportModal())
+      .catch((error) => alert(error.message));
+  });
+  document.addEventListener("keydown", (event) => {
+    if (event.key === "Escape" && !imageImportModal.hidden) {
+      closeImageImportModal();
+    }
+  });
+
+  titleInput.addEventListener("input", () => {
+    state.broadcast.metadata.title = titleInput.value;
+    updateSaveButtonState();
+  });
+
+  telegramBotSelect.addEventListener("change", () => {
+    state.broadcast.metadata.telegram_bot_id = telegramBotSelect.value;
+    const defaultChatId = defaultChatIdForBot(telegramBotSelect.value);
+    state.broadcast.metadata.telegram_chat_id = defaultChatId;
+    render();
+  });
+
+  telegramChatSelect.addEventListener("change", () => {
+    state.broadcast.metadata.telegram_chat_id = telegramChatSelect.value;
+    updateSaveButtonState();
+    render();
+  });
+
+  orderSelect.addEventListener("change", () => {
+    state.broadcast.metadata.language_order = orderSelect.value.split(",");
+    render();
+  });
+
+  discussionPinningSelect.addEventListener("change", () => {
+    state.broadcast.metadata.discussion_pinning_mode = discussionPinningSelect.value;
+    updateSaveButtonState();
+  });
+
+  deletePinServiceMessagesCheckbox.addEventListener("change", () => {
+    state.broadcast.metadata.delete_pin_service_messages = deletePinServiceMessagesCheckbox.checked;
+    updateSaveButtonState();
+  });
+
+  disableLinkPreviewsGlobal.addEventListener("change", () => {
+    const nextValue = disableLinkPreviewsGlobal.checked;
+    for (const language of state.broadcast.metadata.language_order) {
+      state.broadcast.metadata.disable_link_previews[language] = nextValue;
+      const languageToggle = document.querySelector(`.disable-link-preview-toggle[data-language="${language}"]`);
+      if (languageToggle) {
+        languageToggle.checked = nextValue;
+      }
+    }
+    syncGlobalLinkPreviewToggle();
+    updateSaveButtonState();
+  });
+
+  scheduleInput.addEventListener("input", () => {
+    state.localScheduleValue = scheduleInput.value;
+    state.broadcast.metadata.schedule_draft_for = scheduleInput.value || null;
+    updateSaveButtonState();
+  });
+
+  unpinScheduleInput.addEventListener("input", () => {
+    state.localUnpinScheduleValue = unpinScheduleInput.value;
+  });
+
+  document.addEventListener("click", (event) => {
+    const link = event.target.closest("a");
+    if (!link) return;
+    if (link.closest(".sidebar")) {
+      persistSidebarScrollPosition();
+    }
+    if (
+      shouldInterceptSidebarAnnouncementLink(link)
+      && event.button === 0
+      && !event.metaKey
+      && !event.ctrlKey
+      && !event.shiftKey
+      && !event.altKey
+    ) {
+      event.preventDefault();
+      if (state.busy) {
+        return;
+      }
+      if (!confirmUnsavedChanges("confirm.navigate_unsaved")) {
+        return;
+      }
+      const directoryName = navigationDirectoryFromHref(link.href);
+      if (!directoryName || directoryName === state.broadcast.directory) {
+        return;
+      }
+      loadBroadcastIntoEditor(directoryName).catch((error) => alert(error.message));
+      return;
+    }
+    if (state.busy) {
+      event.preventDefault();
+      return;
+    }
+    if (!hasUnsavedChanges()) return;
+    if (link.target === "_blank") return;
+    const href = link.getAttribute("href") || "";
+    if (!href.startsWith("/")) return;
+    if (!window.confirm(t("confirm.navigate_unsaved"))) {
+      event.preventDefault();
+    }
+  });
+
+  document.addEventListener("submit", (event) => {
+    if (state.busy || !hasUnsavedChanges()) return;
+    const form = event.target;
+    if (!(form instanceof HTMLFormElement)) return;
+    if (form.closest(".sidebar")) {
+      persistSidebarScrollPosition();
+    }
+    if (!window.confirm(t("confirm.navigate_unsaved"))) {
+      event.preventDefault();
+    }
+  });
+
+  window.addEventListener("resize", syncAllTextareaHeights);
+  window.addEventListener("popstate", () => {
+    const path = window.location.pathname;
+    const match = path.match(/^\/announcements\/([^/]+)$/);
+    if (!confirmUnsavedChanges("confirm.navigate_unsaved")) {
+      window.history.pushState({ directory: state.broadcast.directory }, "", announcementPageUrl());
+      return;
+    }
+    if (!match) {
+      window.location.reload();
+      return;
+    }
+    const directoryName = decodeURIComponent(match[1]);
+    if (directoryName === state.broadcast.directory) {
+      return;
+    }
+    if (state.busy) {
+      window.location.reload();
+      return;
+    }
+    loadBroadcastIntoEditor(directoryName, { replaceHistory: true }).catch(() => {
+      window.location.reload();
+    });
+  });
+  if (sidebarContainer) {
+    sidebarContainer.addEventListener("scroll", persistSidebarScrollPosition, { passive: true });
+  }
+  window.addEventListener("beforeunload", persistSidebarScrollPosition);
+
+  state.localScheduleValue = scheduleInputValueForMetadata(state.broadcast.metadata);
+  state.localUnpinScheduleValue = unpinScheduleInputValueForMetadata(state.broadcast.metadata);
+  restoreSidebarScrollPosition();
+  render();
+
+  function displayName(language) {
+    return t(`language.${language}`, language);
+  }
+
+  function renderLanguageOrderOptions() {
+    for (const option of orderSelect.options) {
+      const languages = String(option.value || "").split(",");
+      option.textContent = languages.map((language) => {
+        const label = displayName(language);
+        return state.broadcast.metadata.enabled_languages[language] ? label : `(${label})`;
+      }).join(" / ");
+    }
+  }
+
+  function toDatetimeLocal(date) {
+    const pad = (value) => String(value).padStart(2, "0");
+    return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
+  }
+
+  function formatTimestamp(value) {
+    const date = new Date(value);
+    return new Intl.DateTimeFormat(undefined, {
+      timeZone: state.broadcast.timezone,
+      year: "numeric",
+      month: "2-digit",
+      day: "2-digit",
+      hour: "2-digit",
+      minute: "2-digit",
+      second: "2-digit",
+      hour12: false,
+    }).format(date);
+  }
+
+  function lastAttemptLabel(metadata) {
+    if (metadata.last_result === "published") return t("status.last_published");
+    if (metadata.last_result === "updated") return t("status.last_updated_telegram");
+    if (metadata.last_result === "update_failed") return t("status.last_failed_update");
+    if (metadata.last_result === "publish_failed") return t("status.last_failed_publish");
+    return t("status.last_activity");
+  }
+
+  function statusLabel(status) {
+    return t(`status.${status}`, status);
+  }
+
+  function setBusy(busy, message) {
+    state.busy = busy;
+    state.busyMessage = message;
+    document.body.classList.toggle("busy", busy);
+    render();
+  }
+})();
diff --git a/linkki-tiedotus/webapp/static/favicon.png b/linkki-tiedotus/webapp/static/favicon.png
new file mode 100644
index 0000000..5c3a958
Binary files /dev/null and b/linkki-tiedotus/webapp/static/favicon.png differ
diff --git a/linkki-tiedotus/webapp/storage.py b/linkki-tiedotus/webapp/storage.py
new file mode 100644
index 0000000..08940bf
--- /dev/null
+++ b/linkki-tiedotus/webapp/storage.py
@@ -0,0 +1,1030 @@
+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
diff --git a/linkki-tiedotus/webapp/telegram_targets.py b/linkki-tiedotus/webapp/telegram_targets.py
new file mode 100644
index 0000000..7bab7fd
--- /dev/null
+++ b/linkki-tiedotus/webapp/telegram_targets.py
@@ -0,0 +1,184 @@
+import json
+from dataclasses import dataclass
+from pathlib import Path
+
+from linkki_poster.models import Config
+from webapp.config import ConfigFileError
+
+
+@dataclass(frozen=True)
+class TelegramChatTarget:
+    chat_id: str
+    name: str
+    channel_username: str
+    discussion_group_id: str
+    is_default: bool
+
+
+@dataclass(frozen=True)
+class TelegramBotTarget:
+    bot_id: str
+    name: str
+    token: str
+    chats: list[TelegramChatTarget]
+    is_default: bool
+
+
+@dataclass(frozen=True)
+class TelegramTargetCatalog:
+    bots: list[TelegramBotTarget]
+    default_bot_id: str
+
+
+def load_telegram_target_catalog(config_dir: Path) -> TelegramTargetCatalog:
+    settings_file = config_dir / "telegram_config.json"
+    if not settings_file.exists():
+        return TelegramTargetCatalog(bots=[], default_bot_id="")
+
+    try:
+        payload = json.loads(settings_file.read_text(encoding="utf-8"))
+    except json.JSONDecodeError as error:
+        raise ConfigFileError(settings_file, f"Invalid JSON: {error.msg}") from error
+    if not isinstance(payload, dict) or not isinstance(payload.get("bots"), list):
+        raise ConfigFileError(settings_file, "Expected a top-level 'bots' list.")
+    bots = _load_multi_bot_catalog(payload)
+
+    default_bot_id = ""
+    for bot in bots:
+        if bot.is_default:
+            default_bot_id = bot.bot_id
+            break
+
+    return TelegramTargetCatalog(bots=bots, default_bot_id=default_bot_id)
+
+
+def resolve_target_config(catalog: TelegramTargetCatalog, bot_id: str, chat_id: str) -> Config:
+    bot = next((item for item in catalog.bots if item.bot_id == bot_id), None)
+    if bot is None:
+        raise ValueError("Choose a Telegram bot before posting or scheduling.")
+
+    chat = next((item for item in bot.chats if item.chat_id == chat_id), None)
+    if chat is None:
+        raise ValueError("Choose a Telegram chat before posting or scheduling.")
+
+    return Config(
+        bot_token=bot.token,
+        chat_id=chat.chat_id,
+        channel_username=chat.channel_username,
+        discussion_group_id=chat.discussion_group_id,
+    )
+
+
+def resolve_poster_config(config_dir: Path, bot_id: str, chat_id: str) -> Config:
+    return resolve_target_config(load_telegram_target_catalog(config_dir), bot_id, chat_id)
+
+
+def default_target_selection(catalog: TelegramTargetCatalog) -> tuple[str, str]:
+    if not catalog.default_bot_id:
+        return "", ""
+
+    bot = next((item for item in catalog.bots if item.bot_id == catalog.default_bot_id), None)
+    if bot is None:
+        return "", ""
+
+    for chat in bot.chats:
+        if chat.is_default:
+            return bot.bot_id, chat.chat_id
+    return bot.bot_id, ""
+
+
+def serialise_catalog_for_client(catalog: TelegramTargetCatalog) -> dict:
+    return {
+        "default_bot_id": catalog.default_bot_id,
+        "bots": [
+            {
+                "id": bot.bot_id,
+                "name": bot.name,
+                "default": bot.is_default,
+                "default_chat_id": next((chat.chat_id for chat in bot.chats if chat.is_default), ""),
+                "chats": [
+                    {
+                        "chat_id": chat.chat_id,
+                        "name": chat.name,
+                        "has_discussion_group": bool(chat.discussion_group_id),
+                        "channel_username": chat.channel_username,
+                        "channel_internal_id": extract_internal_chat_id(chat.chat_id),
+                        "discussion_group_internal_id": extract_internal_chat_id(chat.discussion_group_id),
+                        "default": chat.is_default,
+                    }
+                    for chat in bot.chats
+                ],
+            }
+            for bot in catalog.bots
+        ],
+    }
+
+
+def resolve_target_labels(catalog: TelegramTargetCatalog, bot_id: str, chat_id: str) -> tuple[str, str]:
+    bot_label = bot_id
+    chat_label = chat_id
+    for bot in catalog.bots:
+        if bot.bot_id != bot_id:
+            continue
+        bot_label = bot.name
+        for chat in bot.chats:
+            if chat.chat_id == chat_id:
+                chat_label = chat.name
+                break
+        break
+    return bot_label, chat_label
+
+
+def extract_internal_chat_id(chat_id: str) -> str:
+    chat_id = str(chat_id).strip()
+    if chat_id.startswith("-100") and len(chat_id) > 4:
+        return chat_id[4:]
+    return chat_id.lstrip("-")
+
+
+def _load_multi_bot_catalog(payload: dict) -> list[TelegramBotTarget]:
+    bots: list[TelegramBotTarget] = []
+    for bot_payload in payload.get("bots", []):
+        if not isinstance(bot_payload, dict):
+            continue
+        name = str(bot_payload.get("display_name", "")).strip()
+        token = str(bot_payload.get("token", "")).strip()
+        bot_id = str(bot_payload.get("id", "")).strip() or token.split(":", 1)[0].strip()
+        if not name or not token or not bot_id:
+            continue
+        chats = _load_chats(bot_payload.get("channels", []))
+        if not chats:
+            continue
+        bots.append(
+            TelegramBotTarget(
+                bot_id=bot_id,
+                name=name,
+                token=token,
+                chats=chats,
+                is_default=bool(bot_payload.get("default", False)),
+            )
+        )
+    return bots
+
+
+def _load_chats(raw_chats: object) -> list[TelegramChatTarget]:
+    chats: list[TelegramChatTarget] = []
+    if not isinstance(raw_chats, list):
+        return chats
+    for chat_payload in raw_chats:
+        if not isinstance(chat_payload, dict):
+            continue
+        name = str(chat_payload.get("display_name", "")).strip()
+        chat_id = str(chat_payload.get("channel_id", "")).strip()
+        if not name or not chat_id:
+            continue
+        chats.append(
+            TelegramChatTarget(
+                chat_id=chat_id,
+                name=name,
+                channel_username=str(chat_payload.get("channel_username", "")).strip(),
+                discussion_group_id=str(chat_payload.get("discussion_group_id", "")).strip(),
+                is_default=bool(chat_payload.get("default", False)),
+            )
+        )
+    return chats
diff --git a/linkki-tiedotus/webapp/templates/base.html b/linkki-tiedotus/webapp/templates/base.html
new file mode 100644
index 0000000..0c80bc4
--- /dev/null
+++ b/linkki-tiedotus/webapp/templates/base.html
@@ -0,0 +1,121 @@
+
+
+
+  
+  
+  {{ translations["app.title"] }}
+  
+  
+
+
+  
+ + +
+ {% block content %}{% endblock %} +
+
+ + + diff --git a/linkki-tiedotus/webapp/templates/editor.html b/linkki-tiedotus/webapp/templates/editor.html new file mode 100644 index 0000000..cf921bd --- /dev/null +++ b/linkki-tiedotus/webapp/templates/editor.html @@ -0,0 +1,142 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+

{{ broadcast.metadata.title }}

+

{{ broadcast.directory }}

+
+
+ + + + +
+
+ +
+
+ + + + +
+ +
+
+ + +
+ + +
+
+ +
+ +
+
+ + + + + + +
+ +
+

{{ translations["editor.global_images"] }}

+
+ {{ translations["editor.add_global_images"] }} + +
+
+ +
+
+
+ +
+
+ + +{% endblock %} diff --git a/linkki-tiedotus/webapp/templates/instructions.html b/linkki-tiedotus/webapp/templates/instructions.html new file mode 100644 index 0000000..df96740 --- /dev/null +++ b/linkki-tiedotus/webapp/templates/instructions.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} + +{% block content %} +
+

{{ translations["instructions.title"] }}

+ +
+

{{ translations["instructions.quick_start_title"] }}

+
    + {% for item in translations["instructions.quick_start_items"] %} +
  • {{ item }}
  • + {% endfor %} +
+
+ +
+

{{ translations["instructions.states_title"] }}

+
    + {% for item in translations["instructions.states_items"] %} +
  • {{ item }}
  • + {% endfor %} +
+
+ +
+

{{ translations["instructions.formatting_title"] }}

+
    + {% for item in translations["instructions.formatting_items"] %} +
  • {{ item.label }}: {{ item.example }}
  • + {% endfor %} +
+
+ +
+

{{ translations["instructions.posted_title"] }}

+
    + {% for item in translations["instructions.posted_items"] %} +
  • {{ item }}
  • + {% endfor %} +
+
+
+{% endblock %} diff --git a/linkki-tiedotus/webapp/templates/login.html b/linkki-tiedotus/webapp/templates/login.html new file mode 100644 index 0000000..797ad1a --- /dev/null +++ b/linkki-tiedotus/webapp/templates/login.html @@ -0,0 +1,31 @@ + + + + + + {{ translations["app.title"] }} + + + +
+

{{ translations["login.title"] }}

+ + {% if error %} +

{{ error }}

+ {% endif %} +
+ + + +
+
+ + diff --git a/linkki-tiedotus/webapp/translations/catalog.json b/linkki-tiedotus/webapp/translations/catalog.json new file mode 100644 index 0000000..9eafb53 --- /dev/null +++ b/linkki-tiedotus/webapp/translations/catalog.json @@ -0,0 +1,1127 @@ +{ + "alert.pick_schedule": { + "fi": "Valitse ensin ajastusaika.", + "sv": "Välj en schemaläggningstid först.", + "en": "Pick a schedule time first." + }, + "app.title": { + "fi": "Linkki Botti", + "sv": "Linkki Bot", + "en": "Linkki Bot" + }, + "confirm.delete_storage": { + "fi": "Poistetaanko tämä tiedotus tallennuksesta?", + "sv": "Radera detta meddelande från lagringen?", + "en": "Delete this announcement from storage?" + }, + "confirm.delete_telegram": { + "fi": "Poistetaanko tämä tiedotus Telegramista?", + "sv": "Radera detta meddelande från Telegram?", + "en": "Delete this announcement from Telegram?" + }, + "confirm.inactive_links": { + "fi": "Joissakin kielilinkeissä viitataan poissa käytöstä oleviin tai tyhjiin kieliin. Ne julkaistaan pelkkinä teksteinä ilman linkkiä. Jatketaanko?", + "sv": "Vissa språklänkar pekar på inaktiva eller tomma språk. De publiceras då som vanlig text utan klickbar länk. Fortsätt?", + "en": "Some cross-language links point to disabled or empty languages. Those links will be posted as plain labels instead of clickable links. Continue?" + }, + "confirm.navigate_unsaved": { + "fi": "Tällä sivulla on tallentamattomia muutoksia. Poistutaanko tallentamatta?", + "sv": "Den här sidan har osparade ändringar. Lämna utan att spara?", + "en": "This page has unsaved changes. Leave without saving?" + }, + "confirm.publish_unsaved": { + "fi": "Tällä sivulla on tallentamattomia muutoksia. Vain tallennetut muutokset julkaistaan ja tallentamattomat muokkaukset poistuvat. Jatketaanko?", + "sv": "Den här sidan har osparade ändringar. Endast sparade ändringar publiceras, och osparade ändringar försvinner. Fortsätt?", + "en": "This page has unsaved changes. Only saved changes will be posted, and unsaved edits will be discarded. Continue?" + }, + "confirm.schedule_unsaved": { + "fi": "Tällä sivulla on tallentamattomia muutoksia. Ajastus käyttää sitä versiota, joka on tallennettu ajastushetkellä. Jatketaanko?", + "sv": "Den här sidan har osparade ändringar. Schemaläggaren använder den version som är sparad när sändningstiden infaller. Fortsätt?", + "en": "This page has unsaved changes. The scheduler will use whatever is saved when the scheduled time arrives. Continue?" + }, + "confirm.unlink": { + "fi": "Poistetaanko tämän tiedotuksen Telegram-linkitys?", + "sv": "Koppla loss detta meddelande från Telegram?", + "en": "Unlink this announcement from Telegram?" + }, + "create.button": { + "fi": "Luo", + "sv": "Skapa", + "en": "Create" + }, + "create.permission_denied": { + "fi": "Sinulla ei ole oikeutta luoda tai tallentaa tiedotuksia.", + "sv": "Du har inte behörighet att skapa eller spara meddelanden.", + "en": "You do not have permission to create or save announcements." + }, + "create.placeholder": { + "fi": "Uuden tiedotuksen otsikko", + "sv": "Titel för nytt meddelande", + "en": "New announcement title" + }, + "editor.add_global_images": { + "fi": "Lisää yleiskuvia:", + "sv": "Lägg till globala bilder:", + "en": "Add global images:" + }, + "editor.add_images_for": { + "fi": "Lisää kuvia tälle kielelle:", + "sv": "Lägg till bilder för detta språk:", + "en": "Add images for this language:" + }, + "editor.open_image_import": { + "fi": "Lisää kuva", + "sv": "Lägg till bild", + "en": "Add image" + }, + "editor.image_import_title": { + "fi": "Tuo kuva", + "sv": "Importera bild", + "en": "Import image" + }, + "editor.close_image_import": { + "fi": "Sulje", + "sv": "Stäng", + "en": "Close" + }, + "editor.cancel_image_import": { + "fi": "Peruuta", + "sv": "Avbryt", + "en": "Cancel" + }, + "editor.image_import_help": { + "fi": "Liitä kuva mihin tahansa tässä ikkunassa tai anna kuvan URL alla.", + "sv": "Klistra in en bild var som helst i det här fönstret eller ange bildens URL nedan.", + "en": "Paste an image anywhere in this dialog or enter an image URL below." + }, + "editor.image_import_format": { + "fi": "Muoto", + "sv": "Format", + "en": "Format" + }, + "editor.image_import_format.auto": { + "fi": "Automaattinen", + "sv": "Automatisk", + "en": "Auto" + }, + "editor.image_import_format.png": { + "fi": "PNG", + "sv": "PNG", + "en": "PNG" + }, + "editor.image_import_format.jpeg": { + "fi": "JPEG", + "sv": "JPEG", + "en": "JPEG" + }, + "editor.image_import_url": { + "fi": "Kuvan URL", + "sv": "Bildens URL", + "en": "Image URL" + }, + "editor.image_import_url_placeholder": { + "fi": "https://esimerkki.fi/kuva.jpg", + "sv": "https://exempel.fi/bild.jpg", + "en": "https://example.com/image.jpg" + }, + "editor.import_image_url_submit": { + "fi": "Tuo URL", + "sv": "Importera URL", + "en": "Import URL" + }, + "alert.image_conversion_failed": { + "fi": "Kuvan muuntaminen epäonnistui.", + "sv": "Bildkonverteringen misslyckades.", + "en": "Image conversion failed." + }, + "alert.telegram_binding_invalid_line": { + "fi": "Käytä muotoa channel_id=discussion_id jokaisella rivillä.", + "sv": "Använd formatet channel_id=discussion_id på varje rad.", + "en": "Use the format channel_id=discussion_id on each line." + }, + "alert.telegram_binding_unknown_channel_id": { + "fi": "Tuntematon kanavan viesti-id:", + "sv": "Okänt kanalmeddelande-id:", + "en": "Unknown channel message id:" + }, + "editor.format_bold": { + "fi": "Lihavointi", + "sv": "Fetstil", + "en": "Bold" + }, + "editor.format_italic": { + "fi": "Kursivointi", + "sv": "Kursiv", + "en": "Italic" + }, + "editor.format_underline": { + "fi": "Alleviivaus", + "sv": "Understrykning", + "en": "Underline" + }, + "editor.format_strikethrough": { + "fi": "Yliviivaus", + "sv": "Genomstrykning", + "en": "Strikethrough" + }, + "editor.format_code": { + "fi": "Koodi", + "sv": "Kod", + "en": "Code" + }, + "editor.format_spoiler": { + "fi": "Spoileri", + "sv": "Spoiler", + "en": "Spoiler" + }, + "editor.format_link": { + "fi": "Linkki", + "sv": "Länk", + "en": "Link" + }, + "editor.format_blockquote": { + "fi": "Lainaus", + "sv": "Citat", + "en": "Quote" + }, + "editor.format_bullets": { + "fi": "Luettelo", + "sv": "Punktlista", + "en": "Bullets" + }, + "editor.format_numbered_list": { + "fi": "Numerointi", + "sv": "Numrering", + "en": "Numbered" + }, + "editor.delete_storage": { + "fi": "Poista\ntallennuksesta", + "sv": "Radera från\nlagringen", + "en": "Delete from\nStorage" + }, + "editor.delete_telegram": { + "fi": "Poista\nTelegramista", + "sv": "Radera från\nTelegram", + "en": "Delete from\nTelegram" + }, + "editor.enabled": { + "fi": "Käytössä", + "sv": "Aktiverad", + "en": "Enabled" + }, + "editor.global_images": { + "fi": "Yleiskuvat", + "sv": "Globala bilder", + "en": "Global Images" + }, + "editor.insert_language_links": { + "fi": "Lisää kielilinkit", + "sv": "Lägg till språklänkar", + "en": "Insert language links" + }, + "editor.insert_language_flags": { + "fi": "Lisää kieliliput", + "sv": "Lägg till språkflaggor", + "en": "Insert language flags" + }, + "editor.language_order": { + "fi": "Kielten järjestys", + "sv": "Språkordning", + "en": "Language Order" + }, + "editor.disable_link_previews_global": { + "fi": "Poista linkkien esikatselut", + "sv": "Stäng av länkkort", + "en": "Disable link previews" + }, + "editor.disable_link_previews": { + "fi": "Poista linkkien esikatselut", + "sv": "Stäng av länkkort", + "en": "Disable link previews" + }, + "editor.discussion_pinning": { + "fi": "Viestien kiinnitys", + "sv": "Fästning av meddelanden", + "en": "Message pinning" + }, + "editor.discussion_pinning.none": { + "fi": "Ei kiinnitystä", + "sv": "Ingen fästning", + "en": "None" + }, + "editor.discussion_pinning.first": { + "fi": "Vain ensimmäinen viesti", + "sv": "Endast första meddelandet", + "en": "Only first message" + }, + "editor.discussion_pinning.last": { + "fi": "Vain viimeinen viesti (TG oletus)", + "sv": "Endast sista meddelandet (TG standard)", + "en": "Only last message (TG default)" + }, + "editor.discussion_pinning.all": { + "fi": "Kaikki viestit", + "sv": "Alla meddelanden", + "en": "All messages" + }, + "editor.discussion_pinning_requires_group": { + "fi": "Valitulla kanavalla ei ole liitettyä keskusteluryhmää.", + "sv": "Den valda kanalen har ingen länkad diskussionsgrupp.", + "en": "The selected channel has no linked discussion group." + }, + "editor.discussion_pinning_requires_links": { + "fi": "Keskusteluryhmän viestilinkit puuttuvat. Syötä ne käsin ennen kiinnityksen muuttamista.", + "sv": "Diskussionsgruppens meddelandelänkar saknas. Ange dem manuellt innan fästningen ändras.", + "en": "Discussion-group message links are missing. Enter them manually before changing pinning." + }, + "editor.delete_pin_service_messages": { + "fi": "Poista kiinnityksen palveluviestit", + "sv": "Ta bort tjänstemeddelanden för fästning", + "en": "Delete service messages" + }, + "editor.language_order.en_fi_sv": { + "fi": "Englanti -> Suomi -> Ruotsi", + "sv": "Engelska -> Finska -> Svenska", + "en": "English -> Finnish -> Swedish" + }, + "editor.language_order.en_sv_fi": { + "fi": "Englanti -> Ruotsi -> Suomi", + "sv": "Engelska -> Svenska -> Finska", + "en": "English -> Swedish -> Finnish" + }, + "editor.language_order.fi_en_sv": { + "fi": "Suomi -> Englanti -> Ruotsi", + "sv": "Finska -> Engelska -> Svenska", + "en": "Finnish -> English -> Swedish" + }, + "editor.language_order.fi_sv_en": { + "fi": "Suomi -> Ruotsi -> Englanti", + "sv": "Finska -> Svenska -> Engelska", + "en": "Finnish -> Swedish -> English" + }, + "editor.language_order.sv_en_fi": { + "fi": "Ruotsi -> Englanti -> Suomi", + "sv": "Svenska -> Engelska -> Finska", + "en": "Swedish -> English -> Finnish" + }, + "editor.language_order.sv_fi_en": { + "fi": "Ruotsi -> Suomi -> Englanti", + "sv": "Svenska -> Finska -> Engelska", + "en": "Swedish -> Finnish -> English" + }, + "editor.move_left": { + "en": "<" + }, + "editor.move_right": { + "en": ">" + }, + "editor.no_language_links_available": { + "fi": "Muita käytössä olevia kieliä ei ole linkitettäväksi.", + "sv": "Inga andra aktiva språk finns tillgängliga för länkar.", + "en": "No other enabled languages are available for links." + }, + "editor.open_telegram": { + "fi": "Avaa\nTelegramissa", + "sv": "Öppna i\nTelegram", + "en": "Open on\nTelegram" + }, + "editor.open": { + "fi": "Avaa", + "sv": "Öppna", + "en": "Open" + }, + "editor.post_modifications_now": { + "fi": "Julkaise muokkaukset nyt", + "sv": "Publicera ändringar nu", + "en": "Post Modifications Now" + }, + "editor.post_now": { + "fi": "Julkaise nyt", + "sv": "Publicera nu", + "en": "Post Now" + }, + "editor.remove_image": { + "fi": "Poista", + "sv": "Ta bort", + "en": "Remove" + }, + "editor.save": { + "fi": "Tallenna", + "sv": "Spara", + "en": "Save" + }, + "editor.schedule": { + "fi": "Ajasta", + "sv": "Schemalägg", + "en": "Schedule" + }, + "editor.schedule_label": { + "fi": "Ajastus", + "sv": "Schemaläggning", + "en": "Schedule" + }, + "editor.schedule_modifications": { + "fi": "Ajasta muokkaukset", + "sv": "Schemalägg ändringar", + "en": "Schedule Modifications" + }, + "editor.schedule_timezone": { + "fi": "Aikavyöhyke", + "sv": "Tidszon", + "en": "Timezone" + }, + "editor.schedule_unpin": { + "fi": "Ajasta kiinnitysten poisto", + "sv": "Schemalägg avfästning", + "en": "Schedule unpin" + }, + "editor.cancel_unpin": { + "fi": "Peru kiinnitysten poisto", + "sv": "Avbryt avfästning", + "en": "Cancel unpin" + }, + "editor.unpin_schedule_label": { + "fi": "Kiinnitysten poiston ajastus", + "sv": "Schemalägg avfästning", + "en": "Unpin schedule" + }, + "editor.settings_tabs": { + "fi": "Tiedotuksen asetukset", + "sv": "Inställningar för meddelandet", + "en": "Announcement settings" + }, + "editor.tab_main": { + "fi": "Pääasiat", + "sv": "Huvud", + "en": "Main" + }, + "editor.tab_telegram": { + "fi": "Telegram-linkitys", + "sv": "Telegram-länkning", + "en": "Telegram link" + }, + "editor.tab_pinning": { + "fi": "Kiinnitys", + "sv": "Fästning", + "en": "Pinning" + }, + "editor.tab_logs": { + "fi": "Lokit", + "sv": "Loggar", + "en": "Logs" + }, + "editor.title": { + "fi": "Otsikko", + "sv": "Titel", + "en": "Title" + }, + "editor.unlink": { + "fi": "Poista\nTelegram-linkitys", + "sv": "Koppla loss\nfrån Telegram", + "en": "Unlink from\nTelegram" + }, + "editor.unlink_message_id": { + "fi": "Poista linkitys msgID={id}", + "sv": "Ta bort länkning msgID={id}", + "en": "Unlink msgID={id}" + }, + "editor.group_link_requires_channel": { + "fi": "Ryhmän linkki edellyttää ensin kanavalinkkiä.", + "sv": "Grupplänken kräver först en kanallänk.", + "en": "A group link requires a channel link first." + }, + "editor.unlink_channel_requires_group_clear": { + "fi": "Poista ensin tämän rivin ryhmälinkki.", + "sv": "Ta först bort grupplänken på denna rad.", + "en": "Unlink the group link on this row first." + }, + "editor.unschedule": { + "fi": "Poista\najastus", + "sv": "Avbryt\nschemaläggning", + "en": "Unschedule" + }, + "instructions.formatting_items": { + "fi": [ + { + "label": "Lihavointi", + "example": "**teksti** tai __teksti__" + }, + { + "label": "Kursivointi", + "example": "*teksti* tai _teksti_" + }, + { + "label": "Alleviivaus", + "example": "++teksti++" + }, + { + "label": "Yliviivaus", + "example": "~~teksti~~" + }, + { + "label": "Spoileri", + "example": "||teksti||" + }, + { + "label": "Rivikoodi", + "example": "`teksti`" + }, + { + "label": "Koodilohko", + "example": "```\\nteksti```" + }, + { + "label": "Tavallinen linkki", + "example": "[nimi](https://esimerkki.fi)" + }, + { + "label": "Kielilinkki", + "example": "[nimi](fi), [nimi](sv), [nimi](en)" + }, + { + "label": "Otsikko (lihavointi + versaalit)", + "example": "# Otsikko" + }, + { + "label": "Luettelomerkit", + "example": "- kohta" + }, + { + "label": "Sisäkkäiset luettelomerkit", + "example": "[kaksi välilyöntiä]- alakohta" + }, + { + "label": "Numeroitu lista", + "example": "1. kohta" + }, + { + "label": "Sisäkkäiset numeroitu lista", + "example": "[kaksi välilyöntiä]1. alakohta" + }, + { + "label": "Lainaus", + "example": "> lainattu teksti" + }, + { + "label": "Koodinvaihtomerkki", + "example": "\\* \\| \\. \\\\ tai \\`" + } + ], + "sv": [ + { + "label": "Fetstil", + "example": "**text** eller __text__" + }, + { + "label": "Kursiv", + "example": "*text* eller _text_" + }, + { + "label": "Understrykning", + "example": "++text++" + }, + { + "label": "Genomstrykning", + "example": "~~text~~" + }, + { + "label": "Spoiler", + "example": "||text||" + }, + { + "label": "Kod i raden", + "example": "`text`" + }, + { + "label": "Kodblock", + "example": "```\\ntext```" + }, + { + "label": "Vanlig länk", + "example": "[etikett](https://exempel.se)" + }, + { + "label": "Språklänk", + "example": "[etikett](fi), [etikett](sv), [etikett](en)" + }, + { + "label": "Rubrik (fetstil + versaler)", + "example": "# Rubrik" + }, + { + "label": "Punktlista", + "example": "- punkt" + }, + { + "label": "Nästlad punktlista", + "example": "[två mellanslag]- underpunkt" + }, + { + "label": "Numrerad lista", + "example": "1. punkt" + }, + { + "label": "Nästlad numrerad lista", + "example": "[två mellanslag]1. underpunkt" + }, + { + "label": "Citat", + "example": "> citerad text" + }, + { + "label": "Avbrottstecken", + "example": "\\* \\| \\. \\\\ eller \\`" + } + ], + "en": [ + { + "label": "Bold", + "example": "**text** or __text__" + }, + { + "label": "Italic", + "example": "*text* or _text_" + }, + { + "label": "Underline", + "example": "++text++" + }, + { + "label": "Strikethrough", + "example": "~~text~~" + }, + { + "label": "Spoiler", + "example": "||text||" + }, + { + "label": "Inline code", + "example": "`text`" + }, + { + "label": "Code block", + "example": "```\\ntext```" + }, + { + "label": "Regular link", + "example": "[label](https://example.com)" + }, + { + "label": "Cross-language link", + "example": "[label](fi), [label](sv), [label](en)" + }, + { + "label": "Heading (bold + capitalized)", + "example": "# Heading" + }, + { + "label": "Bullet list", + "example": "- item" + }, + { + "label": "Nested bullet list", + "example": "[double-space]- nested item" + }, + { + "label": "Numbered list", + "example": "1. item" + }, + { + "label": "Nested numbered list", + "example": "[double-space]1. nested item" + }, + { + "label": "Blockquote", + "example": "> quoted text" + }, + { + "label": "Escaping", + "example": "\\* \\| \\. \\\\ or \\`" + } + ] + }, + "instructions.formatting_title": { + "fi": "Muotoilu", + "sv": "Formatering", + "en": "Formatting" + }, + "instructions.posted_items": { + "fi": [ + "Julkaistusta tiedotuksesta voi muokata vain tekstejä.", + "Jos haluat julkaista saman tiedotuksen uutena, poista linkitys ensin.", + "\"Poista Telegramista\" poistaa Telegram-viestit ja palauttaa tiedotuksen luonnokseksi.", + "\"Poista Telegram-linkitys\" jättää Telegram-viestit paikalleen mutta poistaa yhteyden verkkosovelluksesta." + ], + "sv": [ + "Ett publicerat meddelande kan bara textredigeras.", + "Om du vill publicera samma meddelande som ett nytt meddelande, koppla loss det först.", + "\"Radera från Telegram\" tar bort Telegram-meddelandena och återställer meddelandet till utkast.", + "\"Koppla loss från Telegram\" lämnar Telegram-meddelandena kvar men tar bort kopplingen i webbappen." + ], + "en": [ + "Published announcements only allow text edits.", + "To post the same announcement again as a fresh announcement, unlink it first.", + "\"Delete from Telegram\" removes the Telegram messages and returns the announcement to draft.", + "\"Unlink from Telegram\" keeps the Telegram messages but removes the connection from the web app." + ] + }, + "instructions.posted_title": { + "fi": "Julkaisun jälkeen", + "sv": "Efter publicering", + "en": "After publishing" + }, + "instructions.quick_start_items": { + "fi": [ + "Luo tiedotus sivupalkista.", + "Kirjoita tarvittavat tekstit suomeksi, ruotsiksi ja/tai englanniksi.", + "Lataa yleiskuvia ja/tai kielikohtaisia kuvia ja tallenna.", + "Julkaise heti tai ajasta tiedotus myöhemmäksi." + ], + "sv": [ + "Skapa ett meddelande i sidofältet.", + "Skriv de texter du behöver på finska, svenska och/eller engelska.", + "Ladda upp globala bilder och/eller språkspecifika bilder och spara.", + "Publicera direkt eller schemalägg meddelandet till senare." + ], + "en": [ + "Create an announcement from the sidebar.", + "Write the texts for Finnish, Swedish, and/or English as needed.", + "Upload global images and/or language-specific images, then save.", + "Post immediately or schedule the announcement for later." + ] + }, + "instructions.quick_start_title": { + "fi": "Pika-aloitus", + "sv": "Snabbstart", + "en": "Quick start" + }, + "instructions.states_items": { + "fi": [ + "Ei väriä: luonnostiedotus, jota ei ole vielä linkitetty Telegramiin.", + "Keltainen kello: ajastettu tiedotus, joka odottaa ajastettua aikaa.", + "Vihreä pukki: julkaistu tiedotus, linkitetty Telegramiin ilman julkaisemattomia muutoksia.", + "Sininen kynä: muokattu tiedotus, linkitetty Telegramiin, mutta julkaisemattomia muutoksia.", + "Punainen rasti: julkaisematon tiedotus, koska julkaisu epäonnistui." + ], + "sv": [ + "Ingen färg: utkast till meddelande som ännu inte är länkat till Telegram.", + "Gul klocka: schemalagt meddelande som väntar på sin schemalagda tid.", + "Grön bock: publicerat meddelande, länkat till Telegram, utan opublicerade ändringar.", + "Blå penna: ändrat meddelande, länkat till Telegram, med opublicerade ändringar.", + "Rött kryss: opublicerat meddelande, eftersom publiceringen misslyckades." + ], + "en": [ + "No colour: draft announcement, not yet linked to Telegram.", + "Yellow clock: scheduled announcement, waiting for its scheduled time.", + "Green checkmark: published announcement, linked to Telegram, with no pending changes.", + "Blue pencil: modified announcement, linked to Telegram, with saved changes waiting to be applied.", + "Red cross: unpublished announcement, because posting failed." + ] + }, + "instructions.states_title": { + "fi": "Tiedotusten tilat sivupalkissa", + "sv": "Meddelandenas statusar sidofältet", + "en": "Announcement states in the sidebar" + }, + "instructions.title": { + "fi": "Ohjeet", + "sv": "Anvisningar", + "en": "Instructions" + }, + "language.en": { + "fi": "englanti", + "sv": "engelska", + "en": "English" + }, + "language.fi": { + "fi": "suomi", + "sv": "finska", + "en": "Finnish" + }, + "language.sv": { + "fi": "ruotsi", + "sv": "svenska", + "en": "Swedish" + }, + "locked.delete_telegram_expired": { + "fi": "Telegram-botit eivät voi poistaa julkaisuja 48 tunnin jälkeen. Poista ne tarvittaessa käsin Telegramissa.", + "sv": "Telegram-botar kan inte radera poster efter 48 timmar. Radera dem manuellt i Telegram vid behov.", + "en": "Telegram bots cannot delete posts after 48 hours. Delete them manually in Telegram if needed." + }, + "locked.text_only": { + "fi": "Julkaistuissa tiedotuksissa voi muuttaa vain tekstejä. Poista linkitys tai poista tiedotus Telegramista ensin, jos haluat muuttaa kieliä tai kuvia.", + "sv": "Publicerade meddelanden kan bara ändra text. Koppla loss eller radera meddelandet från Telegram först om du vill ändra språk eller bilder.", + "en": "Published announcements can only change text. Unlink or delete from Telegram first to change languages or images." + }, + "locked.targets_scheduled": { + "fi": "Ajastetun tiedotuksen Telegram-kohdetta ei voi enää vaihtaa. Peru ajastus ensin, jos haluat vaihtaa botin tai chatin.", + "sv": "Telegram-målet för ett schemalagt meddelande kan inte längre ändras. Avbryt schemaläggningen först om du vill byta bot eller chatt.", + "en": "The Telegram target for a scheduled announcement can no longer be changed. Unschedule it first if you want to change the bot or chat." + }, + "login.invalid_credentials": { + "fi": "Virheellinen käyttäjätunnus tai salasana.", + "sv": "Fel anvandarnamn eller lösenord.", + "en": "Invalid username or password." + }, + "login.password": { + "fi": "Salasana", + "sv": "Lösenord", + "en": "Password" + }, + "login.sign_in": { + "fi": "Kirjaudu sisään", + "sv": "Logga in", + "en": "Sign In" + }, + "login.title": { + "en": "Linkki" + }, + "login.username": { + "fi": "Käyttäjätunnus", + "sv": "Anvandarnamn", + "en": "Username" + }, + "nav.instructions": { + "fi": "Ohjeet", + "sv": "Anvisningar", + "en": "Instructions" + }, + "nav.interface_language": { + "fi": "Käyttöliittymän kieli", + "sv": "Gränssnittsspråk", + "en": "Interface language" + }, + "nav.sign_out": { + "fi": "Kirjaudu ulos", + "sv": "Logga ut", + "en": "Sign out" + }, + "permission.delete_storage": { + "fi": "Sinulla ei ole oikeutta poistaa tiedotuksia tallennuksesta.", + "sv": "Du har inte behörighet att radera meddelanden från lagringen.", + "en": "You do not have permission to delete announcements from storage." + }, + "permission.delete_telegram": { + "fi": "Sinulla ei ole oikeutta poistaa tiedotuksia Telegramista.", + "sv": "Du har inte behörighet att radera meddelanden från Telegram.", + "en": "You do not have permission to delete announcements from Telegram." + }, + "permission.post_now": { + "fi": "Sinulla ei ole oikeutta julkaista Telegramiin.", + "sv": "Du har inte behörighet att publicera till Telegram.", + "en": "You do not have permission to post to Telegram." + }, + "permission.schedule": { + "fi": "Sinulla ei ole oikeutta ajastaa tiedotuksia.", + "sv": "Du har inte behörighet att schemalägga meddelanden.", + "en": "You do not have permission to schedule announcements." + }, + "permission.unlink_telegram": { + "fi": "Sinulla ei ole oikeutta poistaa tiedotusten Telegram-linkitystä.", + "sv": "Du har inte behörighet att koppla loss meddelanden från Telegram.", + "en": "You do not have permission to unlink announcements from Telegram." + }, + "permission.unschedule": { + "fi": "Sinulla ei ole oikeutta poistaa tiedotuksen ajastusta.", + "sv": "Du har inte behörighet att avbryta schemaläggning för meddelanden.", + "en": "You do not have permission to unschedule announcements." + }, + "status.busy_updating": { + "en": "Updating..." + }, + "status.saving_discussion_links": { + "fi": "Tallennetaan keskusteluryhmän linkkejä...", + "sv": "Sparar diskussionsgruppens länkar...", + "en": "Saving discussion-group links..." + }, + "status.saving_telegram_mapping": { + "fi": "Tallennetaan Telegram-kartoitusta...", + "sv": "Sparar Telegram-kartläggning...", + "en": "Saving Telegram mapping..." + }, + "status.scheduling_unpin": { + "fi": "Ajastetaan kiinnitysten poisto...", + "sv": "Schemalägger avfästning...", + "en": "Scheduling unpin..." + }, + "status.canceling_unpin": { + "fi": "Perutaan kiinnitysten poistoa...", + "sv": "Avbryter avfästning...", + "en": "Canceling unpin..." + }, + "status.deleting_storage": { + "fi": "Poistetaan tallennuksesta...", + "sv": "Raderar från lagringen...", + "en": "Deleting from storage..." + }, + "status.deleting_telegram": { + "fi": "Poistetaan Telegramista...", + "sv": "Raderar från Telegram...", + "en": "Deleting from Telegram..." + }, + "status.draft": { + "fi": "Luonnos", + "sv": "Utkast", + "en": "Draft" + }, + "status.failed": { + "fi": "Epäonnistui", + "sv": "Misslyckad", + "en": "Failed" + }, + "status.last_activity": { + "fi": "Viimeisin toiminto", + "sv": "Senaste aktivitet", + "en": "Last activity" + }, + "status.last_failed_publish": { + "fi": "Viimeisin julkaisu epäonnistui", + "sv": "Senaste publiceringen misslyckades", + "en": "Last failed publish" + }, + "status.last_failed_update": { + "fi": "Viimeisin päivitys epäonnistui", + "sv": "Senaste uppdateringen misslyckades", + "en": "Last failed update" + }, + "status.last_published": { + "fi": "Viimeksi julkaistu", + "sv": "Senast publicerad", + "en": "Last published" + }, + "status.last_updated_telegram": { + "fi": "Viimeksi päivitetty Telegramiin", + "sv": "Senast uppdaterad på Telegram", + "en": "Last updated on Telegram" + }, + "status.modified": { + "fi": "Muokattu", + "sv": "Andrad", + "en": "Modified" + }, + "status.posting": { + "fi": "Julkaistaan...", + "sv": "Publicerar...", + "en": "Posting..." + }, + "status.posting_modifications": { + "fi": "Julkaistaan muutoksia...", + "sv": "Publicerar ändringar...", + "en": "Posting modifications..." + }, + "status.prefix": { + "fi": "Tila", + "sv": "Status", + "en": "Status" + }, + "status.published": { + "fi": "Julkaistu", + "sv": "Publicerad", + "en": "Published" + }, + "status.scheduled": { + "fi": "Ajastettu", + "sv": "Schemalagd", + "en": "Scheduled" + }, + "status.scheduled_for": { + "fi": "Ajastettu aikaan", + "sv": "Schemalagd för", + "en": "Scheduled for" + }, + "status.scheduling": { + "fi": "Ajastetaan...", + "sv": "Schemalägger...", + "en": "Scheduling..." + }, + "status.scheduling_modifications": { + "fi": "Ajastetaan muutoksia...", + "sv": "Schemalägger ändringar...", + "en": "Scheduling modifications..." + }, + "status.sidebar_published": { + "fi": "Julkaistu", + "sv": "Publicerad", + "en": "Published" + }, + "status.sidebar_scheduled": { + "fi": "Ajastettu", + "sv": "Schemalagd", + "en": "Scheduled" + }, + "status.sidebar_updated": { + "fi": "Päivitetty Telegramiin", + "sv": "Uppdaterad på Telegram", + "en": "Updated on Telegram" + }, + "status.unlinking": { + "fi": "Poistetaan linkitystä...", + "sv": "Kopplar loss...", + "en": "Unlinking..." + }, + "status.unscheduling": { + "fi": "Poistetaan ajastusta...", + "sv": "Tar bort schemaläggning...", + "en": "Unscheduling..." + }, + "status.wait_finish": { + "fi": "Odota, etta nykyinen toiminto valmistuu.", + "sv": "Vänta tills den pågående operationen är klar.", + "en": "Wait for the current operation to finish." + }, + "ui_language.auto": { + "fi": "Automaattinen", + "sv": "Automatiskt", + "en": "Auto" + }, + "ui_language.en": { + "fi": "Englanti", + "sv": "Engelska", + "en": "English" + }, + "ui_language.fi": { + "fi": "Suomi", + "sv": "Finska", + "en": "Finnish" + }, + "ui_language.sv": { + "fi": "Ruotsi", + "sv": "Svenska", + "en": "Swedish" + }, + "warning.target_disabled": { + "fi": "kohdekieli on poistettu käytöstä", + "sv": "målspråket är inaktiverat", + "en": "target language is disabled" + }, + "warning.target_empty": { + "fi": "kohdekieli on tyhjä", + "sv": "målspråket är tomt", + "en": "target language is empty" + }, + "editor.telegram_bot": { + "fi": "Telegram-botti", + "sv": "Telegram-bot", + "en": "Telegram bot" + }, + "editor.telegram_chat": { + "fi": "Telegram-kanava", + "sv": "Telegram-kanal", + "en": "Telegram chat" + }, + "editor.telegram_binding_title": { + "fi": "Telegram-linkitys", + "sv": "Telegram-länkning", + "en": "Telegram link details" + }, + "editor.telegram_binding_help": { + "fi": "Jokainen solu on yksi Telegram-linkki. Puuttuvan linkin voi tallentaa viestitunnuksena tai Telegram-linkkinä, ja olemassa olevan linkin voi avata tai irrottaa erikseen.", + "sv": "Varje cell är en Telegram-länk. En saknad länk kan sparas som meddelande-id eller Telegram-länk, och en befintlig länk kan öppnas eller kopplas loss separat.", + "en": "Each cell is one Telegram link. Missing links can be saved as message ids or Telegram links, and existing links can be opened or unlinked individually." + }, + "editor.telegram_binding_manual_placeholder": { + "fi": "msgID tai https://t.me/c/.../...", + "sv": "msgID eller https://t.me/c/.../...", + "en": "msgID or https://t.me/c/.../..." + }, + "editor.telegram_binding.channel_short": { + "fi": "Kanava", + "sv": "Kanal", + "en": "Channel" + }, + "editor.telegram_binding.group_short": { + "fi": "Ryhmä", + "sv": "Grupp", + "en": "Group" + }, + "editor.telegram_binding.global": { + "fi": "Yleinen", + "sv": "Global", + "en": "Global" + }, + "editor.telegram_binding.images": { + "fi": "Kuvat", + "sv": "Bilder", + "en": "Images" + }, + "editor.telegram_binding.text": { + "fi": "Teksti", + "sv": "Text", + "en": "Text" + }, + "editor.telegram_binding.incomplete": { + "fi": "puutteellinen", + "sv": "ofullständig", + "en": "incomplete" + }, + "editor.select_bot": { + "fi": "Valitse botti", + "sv": "Välj bot", + "en": "Select bot" + }, + "editor.select_chat": { + "fi": "Valitse kanava", + "sv": "Välj kanal", + "en": "Select chat" + }, + "editor.select_bot_first": { + "fi": "Valitse ensin botti.", + "sv": "Välj först en bot.", + "en": "Select a bot first." + }, + "editor.target_required": { + "fi": "Valitse ensin Telegram-botti ja kanava.", + "sv": "Välj först Telegram-bot och kanal.", + "en": "Select a Telegram bot and chat first." + }, + "sidebar.no_target_selected": { + "fi": "Telegram-kohdetta ei ole valittu", + "sv": "Ingen Telegram-destination vald", + "en": "No Telegram target selected" + }, + "editor.temporary_delete_target": { + "fi": "Väliaikainen botti- ja kanavavalinta vain Telegram-poistoa varten.", + "sv": "Tillfälligt bot- och kanalval endast för Telegram-radering.", + "en": "Temporary bot and chat selection used only for deleting from Telegram." + }, + "editor.clear_status": { + "fi": "Tyhjennä loki", + "sv": "Rensa loggen", + "en": "Clear log" + }, + "status.clearing_status": { + "fi": "Tyhjennetään lokia", + "sv": "Rensar loggen", + "en": "Clearing log" + }, + "editor.show_preview": { + "fi": "Näytä esikatselu", + "sv": "Visa förhandsvisning", + "en": "Show preview" + }, + "editor.hide_preview": { + "fi": "Piilota esikatselu", + "sv": "Dölj förhandsvisning", + "en": "Hide preview" + }, + "permission.edit_content": { + "fi": "Sinulla ei ole oikeutta muokata tiedotuksen sisältöä.", + "sv": "Du har inte behörighet att redigera meddelandets innehåll.", + "en": "You do not have permission to edit announcement content." + }, + "permission.edit_targets": { + "fi": "Sinulla ei ole oikeutta vaihtaa Telegram-bottia tai kanavaa.", + "sv": "Du har inte behörighet att ändra Telegram-bot eller kanal.", + "en": "You do not have permission to change the Telegram bot or chat." + }, + "permission.save_announcements": { + "fi": "Sinulla ei ole oikeutta tallentaa tiedotuksia.", + "sv": "Du har inte behörighet att spara meddelanden.", + "en": "You do not have permission to save announcements." + }, + "permission.open_telegram": { + "fi": "Sinulla ei ole oikeutta avata tätä tiedotusta Telegramissa.", + "sv": "Du har inte behörighet att öppna detta meddelande i Telegram.", + "en": "You do not have permission to open this announcement on Telegram." + } +} diff --git a/linkki-tiedotus/webapp_main.py b/linkki-tiedotus/webapp_main.py new file mode 100644 index 0000000..27ff73e --- /dev/null +++ b/linkki-tiedotus/webapp_main.py @@ -0,0 +1,5 @@ +import uvicorn + + +if __name__ == "__main__": + uvicorn.run("webapp.app:app", host="0.0.0.0", port=8080, reload=False)