Skip to content

Commit 4df22dc

Browse files
Checkpoint before follow-up message
Co-authored-by: mika <mika@elementary-data.com>
1 parent 9ffe357 commit 4df22dc

1 file changed

Lines changed: 269 additions & 0 deletions

File tree

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
from __future__ import annotations
2+
3+
from html import escape
4+
from typing import Iterable, Sequence
5+
6+
from elementary.messages.blocks import (
7+
ActionBlock,
8+
ActionsBlock,
9+
CodeBlock,
10+
DividerBlock,
11+
DropdownActionBlock,
12+
ExpandableBlock,
13+
FactBlock,
14+
FactListBlock,
15+
HeaderBlock,
16+
Icon,
17+
IconBlock,
18+
InlineBlock,
19+
InlineCodeBlock,
20+
LineBlock,
21+
LinesBlock,
22+
LinkBlock,
23+
MentionBlock,
24+
TableBlock,
25+
TextBlock,
26+
TextStyle,
27+
UserSelectActionBlock,
28+
WhitespaceBlock,
29+
)
30+
from elementary.messages.formats.unicode import ICON_TO_UNICODE
31+
from elementary.messages.message_body import Color, MessageBlock, MessageBody
32+
33+
COLOR_MAP = {
34+
Color.RED: "#ff0000",
35+
Color.YELLOW: "#ffcc00",
36+
Color.GREEN: "#33b989",
37+
}
38+
39+
40+
class HTMLFormatter:
41+
_CONTAINER_STYLES = [
42+
"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif",
43+
"font-size:14px",
44+
"line-height:1.5",
45+
"color:#1f2937",
46+
"background-color:#ffffff",
47+
"border:1px solid #e5e7eb",
48+
"border-radius:6px",
49+
"padding:16px",
50+
]
51+
52+
_SECTION_MARGIN = "margin:0 0 12px"
53+
54+
def format(self, message: MessageBody) -> str:
55+
body_html = self.format_message_blocks(message.blocks)
56+
container_style = self._build_container_style(message.color)
57+
return f'<div style="{container_style}">{body_html}</div>'
58+
59+
def format_message_blocks(
60+
self, blocks: Sequence[MessageBlock | ExpandableBlock]
61+
) -> str:
62+
formatted_blocks = [
63+
self.format_message_block(block)
64+
for block in blocks
65+
if (formatted := self.format_message_block(block))
66+
]
67+
return "".join(formatted_blocks)
68+
69+
def format_message_block(self, block: MessageBlock | ExpandableBlock) -> str:
70+
if isinstance(block, HeaderBlock):
71+
return self._wrap_section(
72+
f'<h1 style="margin:0;font-size:18px;line-height:1.4;">{escape(block.text)}</h1>'
73+
)
74+
elif isinstance(block, CodeBlock):
75+
code_html = escape(block.text)
76+
return self._wrap_section(
77+
"<pre style=\"margin:0;padding:12px;"
78+
"background-color:#f8fafc;border-radius:4px;"
79+
"font-family:'SFMono-Regular',Consolas,'Liberation Mono',Menlo,monospace;"
80+
"font-size:13px;line-height:1.5;white-space:pre-wrap;\">"
81+
f"{code_html}</pre>"
82+
)
83+
elif isinstance(block, LinesBlock):
84+
return self._format_lines_block(block)
85+
elif isinstance(block, FactListBlock):
86+
return self._format_fact_list_block(block)
87+
elif isinstance(block, TableBlock):
88+
return self._format_table_block(block)
89+
elif isinstance(block, ExpandableBlock):
90+
return self._format_expandable_block(block)
91+
elif isinstance(block, DividerBlock):
92+
return '<hr style="border:none;border-top:1px solid #e5e7eb;margin:16px 0;" />'
93+
elif isinstance(block, ActionsBlock):
94+
return self._format_actions_block(block)
95+
else:
96+
raise ValueError(f"Unsupported message block type: {type(block)}")
97+
98+
def _wrap_section(self, html: str) -> str:
99+
return f'<div style="{self._SECTION_MARGIN}">{html}</div>'
100+
101+
def _format_icon(self, icon: Icon) -> str:
102+
return f'<span style="margin-right:4px;">{escape(ICON_TO_UNICODE[icon])}</span>'
103+
104+
def _format_text_block(self, block: TextBlock) -> str:
105+
text = escape(block.text)
106+
if block.style == TextStyle.BOLD:
107+
return f"<strong>{text}</strong>"
108+
elif block.style == TextStyle.ITALIC:
109+
return f"<em>{text}</em>"
110+
else:
111+
return text
112+
113+
def _format_inline_block(self, block: InlineBlock) -> str:
114+
if isinstance(block, IconBlock):
115+
return self._format_icon(block.icon)
116+
elif isinstance(block, TextBlock):
117+
return self._format_text_block(block)
118+
elif isinstance(block, LinkBlock):
119+
url = escape(block.url, quote=True)
120+
text = escape(block.text)
121+
return (
122+
'<a style="color:#2563eb;text-decoration:none;" '
123+
f'href="{url}" target="_blank" rel="noopener noreferrer">{text}</a>'
124+
)
125+
elif isinstance(block, InlineCodeBlock):
126+
return (
127+
"<code style=\"font-family:'SFMono-Regular',Consolas,'Liberation Mono',Menlo,monospace;"
128+
"background-color:#eef2ff;border-radius:3px;padding:1px 4px;font-size:12px;\">"
129+
f"{escape(block.code)}</code>"
130+
)
131+
elif isinstance(block, MentionBlock):
132+
return f'<span style="color:#0ea5e9;">@{escape(block.user)}</span>'
133+
elif isinstance(block, LineBlock):
134+
return self._format_line_block(block)
135+
elif isinstance(block, WhitespaceBlock):
136+
return "&nbsp;"
137+
else:
138+
raise ValueError(f"Unsupported inline block type: {type(block)}")
139+
140+
def _format_line_block(self, block: LineBlock) -> str:
141+
inlines = [self._format_inline_block(inline) for inline in block.inlines]
142+
separator = escape(block.sep, quote=False)
143+
return separator.join(inlines)
144+
145+
def _format_lines_block(self, block: LinesBlock) -> str:
146+
lines = [
147+
f'<div style="margin:0;">{self._format_line_block(line_block)}</div>'
148+
for line_block in block.lines
149+
]
150+
if not lines:
151+
return ""
152+
return f'<div style="{self._SECTION_MARGIN}{";".join([])}">' + "".join(lines) + "</div>"
153+
154+
def _format_fact_list_block(self, block: FactListBlock) -> str:
155+
if not block.facts:
156+
return ""
157+
rows = [self._format_fact_row(fact) for fact in block.facts]
158+
table_html = (
159+
'<table style="width:100%;border-collapse:separate;border-spacing:0 6px;">'
160+
+ "".join(rows)
161+
+ "</table>"
162+
)
163+
return self._wrap_section(table_html)
164+
165+
def _format_fact_row(self, fact: FactBlock) -> str:
166+
title_html = self._format_line_block(fact.title)
167+
value_html = self._format_line_block(fact.value)
168+
title_style = (
169+
"padding:4px 12px;font-weight:600;color:#111827;"
170+
"background-color:#f3f4f6;border-radius:4px 0 0 4px;"
171+
"white-space:nowrap;"
172+
)
173+
value_weight = "700" if fact.primary else "400"
174+
value_style = (
175+
"padding:4px 12px;border:1px solid #f3f4f6;border-left:none;"
176+
"border-radius:0 4px 4px 0;font-weight:{weight};"
177+
).format(weight=value_weight)
178+
return (
179+
"<tr>"
180+
f'<td style="{title_style}">{title_html}</td>'
181+
f'<td style="{value_style}">{value_html}</td>'
182+
"</tr>"
183+
)
184+
185+
def _format_table_block(self, block: TableBlock) -> str:
186+
header_html = ""
187+
if block.headers:
188+
header_cells = "".join(
189+
f'<th style="text-align:left;padding:8px;border-bottom:1px solid #e5e7eb;'
190+
f'font-weight:600;background-color:#f8fafc;">{escape(header)}</th>'
191+
for header in block.headers
192+
)
193+
header_html = f"<thead><tr>{header_cells}</tr></thead>"
194+
body_rows = [
195+
"<tr>"
196+
+ "".join(
197+
f'<td style="padding:8px;border-bottom:1px solid #f3f4f6;vertical-align:top;">'
198+
f"{escape(self._coerce_table_cell(cell))}</td>"
199+
for cell in row
200+
)
201+
+ "</tr>"
202+
for row in block.rows
203+
]
204+
body_html = "<tbody>" + "".join(body_rows) + "</tbody>"
205+
table_style = (
206+
"width:100%;border-collapse:collapse;margin:0 0 12px;"
207+
"border:1px solid #e5e7eb;border-radius:6px;overflow:hidden;"
208+
)
209+
return f'<table style="{table_style}">{header_html}{body_html}</table>'
210+
211+
def _format_expandable_block(self, block: ExpandableBlock) -> str:
212+
body_html = self.format_message_blocks(block.body)
213+
title_html = escape(block.title)
214+
container_style = (
215+
"border:1px solid #e5e7eb;border-radius:6px;margin:16px 0;overflow:hidden;"
216+
)
217+
title_style = (
218+
"margin:0;padding:12px 16px;font-weight:600;background-color:#f8fafc;"
219+
)
220+
body_style = "padding:12px 16px;"
221+
return (
222+
f'<div style="{container_style}">'
223+
f'<div style="{title_style}">{title_html}</div>'
224+
f'<div style="{body_style}">{body_html}</div>'
225+
"</div>"
226+
)
227+
228+
def _format_actions_block(self, block: ActionsBlock) -> str:
229+
if not block.actions:
230+
return ""
231+
rendered_actions = [self._format_action_item(action) for action in block.actions]
232+
actions_html = "".join(rendered_actions)
233+
return self._wrap_section(
234+
f'<div style="display:flex;flex-wrap:wrap;gap:8px;">{actions_html}</div>'
235+
)
236+
237+
def _format_action_item(self, block: ActionBlock) -> str:
238+
if isinstance(block, DropdownActionBlock):
239+
options = ", ".join(escape(option.text) for option in block.options)
240+
placeholder = escape(block.placeholder or "Select an option")
241+
return (
242+
'<div style="padding:8px 12px;border:1px solid #d1d5db;border-radius:4px;'
243+
f'background-color:#f9fafb;">{placeholder}: {options}</div>'
244+
)
245+
elif isinstance(block, UserSelectActionBlock):
246+
placeholder = escape(block.placeholder or "Assign user")
247+
return (
248+
'<div style="padding:8px 12px;border:1px solid #d1d5db;border-radius:4px;'
249+
f'background-color:#f9fafb;">{placeholder}</div>'
250+
)
251+
else:
252+
raise ValueError(f"Unsupported action block type: {type(block)}")
253+
254+
def _coerce_table_cell(self, cell: object) -> str:
255+
if cell is None:
256+
return ""
257+
return str(cell)
258+
259+
def _build_container_style(self, color: Color | None) -> str:
260+
styles: list[str] = list(self._CONTAINER_STYLES)
261+
if color and color in COLOR_MAP:
262+
styles.append(f"border-left:4px solid {COLOR_MAP[color]}")
263+
styles.append("padding-left:12px")
264+
return ";".join(styles)
265+
266+
267+
def format_html(message: MessageBody) -> str:
268+
formatter = HTMLFormatter()
269+
return formatter.format(message)

0 commit comments

Comments
 (0)