diff --git a/condor/reports/builder.py b/condor/reports/builder.py index a9fd47837..b980b6e91 100644 --- a/condor/reports/builder.py +++ b/condor/reports/builder.py @@ -35,6 +35,41 @@ } +_CELL_LINK_RE = re.compile( + r"^\[([^\]\n]+)\]\((https?://[^)\s]+)\)(\s*\S*)?$" +) +_HTML_ANCHOR_RE = re.compile( + r'^]*>(.*?)$', + re.DOTALL, +) + + +def _escape_cell(value: object) -> str: + """Escape a table cell; a full ``[label](url)`` markdown link or an + existing ```` anchor becomes a clickable link. + + Only a cell whose whole value is a markdown link (an optional trailing + marker such as `` ⚠`` is allowed) or a well-formed http(s) anchor with + tag-free content is converted to an ````. Everything else is escaped + verbatim, so existing plain-text cells render unchanged. + """ + text = str(value) + m = _CELL_LINK_RE.match(text) + if m: + return ( + f'' + f"{html.escape(m.group(1))}{html.escape(m.group(3) or '')}" + ) + m = _HTML_ANCHOR_RE.match(text) + if m and "<" not in m.group(2) and ">" not in m.group(2): + return ( + f'{m.group(2)}' + ) + return html.escape(text) + + class ReportBuilder: """Compose a report section by section, then ``save()`` it. @@ -561,7 +596,7 @@ def _render_table(columns: list[str], rows: list[dict]) -> str: body_rows = [] for row in rows: cells = "".join( - f"{html.escape(str(row.get(column, '')))}" + f"{_escape_cell(row.get(column, ''))}" for column in columns ) body_rows.append(f"{cells}") diff --git a/frontend/src/components/routines/ReportFrame.tsx b/frontend/src/components/routines/ReportFrame.tsx index f6f1f1439..4ca29dbe1 100644 --- a/frontend/src/components/routines/ReportFrame.tsx +++ b/frontend/src/components/routines/ReportFrame.tsx @@ -77,7 +77,7 @@ export function ReportFrame({ srcDoc={html} className={`h-full w-full border-0 ${className}`} title={title} - sandbox="allow-scripts allow-popups allow-downloads" + sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox allow-downloads" /> ); } diff --git a/frontend/src/components/routines/RoutineResultView.tsx b/frontend/src/components/routines/RoutineResultView.tsx index b79e3601b..c8dd79894 100644 --- a/frontend/src/components/routines/RoutineResultView.tsx +++ b/frontend/src/components/routines/RoutineResultView.tsx @@ -7,6 +7,41 @@ interface Props { instance: RoutineInstance; } +const CELL_LINK_RE = /^\[([^\]\n]+)\]\((https?:\/\/[^)\s]+)\)(\s*\S*)?$/; +const HTML_ANCHOR_RE = /^]*>(.*?)<\/a>$/; + +function formatCellValue(val: unknown) { + if (typeof val === "number") { + // Numbers keep their exact formatting — a link can be a numeric-looking + // string while only strings matching the full [label](url) pattern change. + return val.toFixed(val % 1 === 0 ? 0 : 2); + } + const s = String(val ?? ""); + const m = CELL_LINK_RE.exec(s); + if (m) { + // Same [label](url) convention as report table cells (ReportBuilder). + return ( + <> + + {m[1]} + + {m[3] ?? ""} + + ); + } + const m2 = HTML_ANCHOR_RE.exec(s); + if (m2 && !m2[2].includes("<") && !m2[2].includes(">")) { + // Existing HTML anchors (e.g. the pool scanner's Link cell) render as + // links too, mirroring ReportBuilder's _escape_cell. + return ( + + {m2[2]} + + ); + } + return s; +} + interface KpiSection { type: "kpi"; label: string; @@ -141,7 +176,7 @@ export function RoutineResultView({ instance }: Props) { : ""; return ( - {isNum ? (val as number).toFixed(val % 1 === 0 ? 0 : 2) : String(val ?? "")} + {formatCellValue(val)} ); })} diff --git a/handlers/routines/__init__.py b/handlers/routines/__init__.py index 6355064db..c5636b35a 100644 --- a/handlers/routines/__init__.py +++ b/handlers/routines/__init__.py @@ -30,7 +30,11 @@ normalize_result, ) from utils.auth import restricted -from utils.telegram_formatters import escape_markdown_v2, escape_markdown_v2_code +from utils.telegram_formatters import ( + escape_markdown_v2, + escape_markdown_v2_code, + format_routine_result, +) logger = logging.getLogger(__name__) @@ -547,7 +551,7 @@ async def _interval_job_callback(context: CallbackContext) -> None: text = ( f"{icon} *{escape_markdown_v2(_display_name(routine_name))}* `{instance_id}`\n" f"⏱️ {escape_markdown_v2(interval_str)} \\| Run \\#{run_count} \\| {escape_markdown_v2(_format_duration(duration))}\n\n" - f"```\n{escape_markdown_v2_code(result[:400])}\n```" + f"{format_routine_result(result)}" ) try: keyboard = None @@ -614,7 +618,7 @@ async def _oneshot_job_callback(context: CallbackContext) -> None: text = ( f"{icon} *{escape_markdown_v2(_display_name(routine_name))}*\n" f"Duration: {escape_markdown_v2(_format_duration(duration))}\n\n" - f"```\n{escape_markdown_v2_code(result[:400])}\n```" + f"{format_routine_result(result)}" ) try: keyboard = None @@ -688,7 +692,7 @@ async def _daily_job_callback(context: CallbackContext) -> None: icon = "✅" if not result.startswith("Error") else "❌" text = ( f"{icon} *Daily: {escape_markdown_v2(_display_name(routine_name))}*\n" - f"```\n{escape_markdown_v2_code(result[:400])}\n```" + f"{format_routine_result(result)}" ) try: keyboard = None @@ -1340,7 +1344,7 @@ async def _refresh_detail_msg( dur_str = _format_duration(duration) if duration else "" result_section = ( f"\n\n┌─ {icon} Result ─ {escape_markdown_v2(dur_str)} ────\n" - f"```\n{escape_markdown_v2_code(result[:250])}\n```\n" + f"{format_routine_result(result, max_len=250)}\n" f"└────────────────────────────" ) diff --git a/tests/test_report_builder.py b/tests/test_report_builder.py index 77730f083..c50282a66 100644 --- a/tests/test_report_builder.py +++ b/tests/test_report_builder.py @@ -9,6 +9,7 @@ import condor.reports as reports from condor.reports import rendering, store +from condor.reports.builder import _escape_cell from condor.reports.footprint import ( build_estimated_footprint_figure, candle_timestamps, @@ -45,6 +46,37 @@ def test_markdown_preserves_single_newlines(): assert "Market Analysis" in rendered +def test_escape_cell_markdown_link_becomes_anchor(): + out = _escape_cell("[GeckoTerminal](https://www.geckoterminal.com/solana)") + assert 'href="https://www.geckoterminal.com/solana"' in out + assert 'target="_blank"' in out + assert ">GeckoTerminal" in out + + +def test_escape_cell_existing_html_anchor_passes_through(): + # The pool scanner already emits HTML anchors in its Link cell. + out = _escape_cell( + '🔗' + ) + assert 'href="https://www.geckoterminal.com/solana/pools/abc"' in out + assert 'target="_blank"' in out + assert "🔗" in out + + +def test_escape_cell_anchor_with_tags_is_escaped(): + # An anchor whose content carries raw tags must not pass through. + out = _escape_cell('ok') + assert "