Files
ajp_anton 8ec3cde4f2
Build custom container images / build (map[base_image:php:8-fpm-alpine build_args:PHP_VERSION=8 context:php8-pgsql fingerprint_command:{ apk info -v | LC_ALL=C sort; find /usr/local/lib/php/extensions /usr/local/etc/php/conf.d -type f -exec sha256sum {} + | LC_ALL=C sort; } name:ph… (push) Successful in 48s
Build custom container images / build (map[base_image:postgres:18 build_args:PG_VERSION=18 POSTGIS_VERSION=3 VCHORD_VERSION=0.5.3 context:postgres fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; find /usr/lib/postgresql -type f -exec sha256su… (push) Successful in 55s
Build custom container images / build (map[base_image:python:3 build_args:PYTHON_VERSION=3 context:python-tools fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; } name:python-tools oci_labels:org.opencontainers.ima… (push) Successful in 54s
Build custom container images / build (map[base_image:python:3.12-slim build_args:PYTHON_VERSION=3.12-slim context:linkki-tiedotus fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; } name:linkki-tiedotus oci_labels:… (push) Successful in 39s
Add Linkki broadcast image
2026-08-19 20:51:26 +00:00

272 lines
9.9 KiB
Python

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"(?<!\\)\[(?P<label>(?:\\.|[^\]\\])*)\](?<!\\)\((?P<target>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'<a href="{escaped_url}">{escaped_label}</a>'
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"(?<!\*)\*([^*\n]+)\*(?!\*)", "i")
escaped_text = _map_style_pairs(escaped_text, r"(?<!_)_([^_\n]+)_(?!_)", "i")
for token, value in escape_token_values.items():
escaped_text = escaped_text.replace(token, value)
for token, value in code_token_values.items():
escaped_text = escaped_text.replace(token, value)
return escaped_text
def _stash_code_segments(markdown_text: str) -> 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"<pre><code>{code}</code></pre>"
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>{code}</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"<b>{heading_match.group(2).upper()}</b>")
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<indent>[ \t]*)(?P<marker>[-+*])\s+(?P<content>.+)$", 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<indent>[ \t]*)(?P<number>\d+)\.\s+(?P<content>.+)$", 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"<blockquote>{'\n'.join(quote_lines)}</blockquote>")
quote_lines.clear()
for line in text.splitlines():
quote_match = re.match(r"^[ \t]*&gt;\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'<a href="{html.escape(url, quote=True)}">{label}</a>'
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</{html_tag}>", 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)