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{'\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{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)