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)