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
272 lines
9.9 KiB
Python
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]*>\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)
|