@sdelsad/commodity-desk-daily 1.0.18 → 1.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build_email.py ADDED
@@ -0,0 +1,773 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Soft Commodity Trading — daily e-mail builder.
4
+
5
+ The e-mail used to be improvised every morning, which meant it drifted. This
6
+ renders it deterministically from the same epNN.md the page is built from, so
7
+ the eight sections are always the same eight sections, in the same order.
8
+
9
+ Usage:
10
+ python3 build_email.py --notes ep05.md --number 5 \
11
+ --title "Wheat: The Map and the Screens" \
12
+ --audio-url "https://.../ep05.mp3" \
13
+ --page-url "https://.../ep05.html" \
14
+ --duration 739 --date "Friday 14 August 2026" \
15
+ --charts URL1,URL2,URL3 --glossary glossary_final.md \
16
+ --drill-index 5 --drill-file conversions.md \
17
+ --out ep05_email.html
18
+
19
+ Writes the HTML to --out and the plain-text alternative alongside it
20
+ (ep05_email.txt), built from the same parse so the two cannot disagree.
21
+
22
+ E-mail HTML is not web HTML, and this file obeys the difference:
23
+
24
+ * layout is tables, every style is inline, nothing depends on a <style>
25
+ block, and there is no flexbox, grid, CSS variable or dark-mode query;
26
+ * the card is a fixed 600px table with the width attribute set, because
27
+ Outlook's Word engine ignores max-width;
28
+ * charts are <img> pointing at the published PNGs — Gmail strips inline SVG
29
+ — each with alt text, an explicit width, and max-width:100%;height:auto;
30
+ * the spoiler gap is a table row with a fixed height, because Gmail
31
+ collapses stacked <br> and would hand the reader the answers.
32
+
33
+ Zero runtime dependencies: standard library only.
34
+ """
35
+ import argparse
36
+ import html
37
+ import json
38
+ import os
39
+ import re
40
+ import sys
41
+ import textwrap
42
+
43
+ try:
44
+ from build_page import classify_move, is_numeric, MOVE_HEADS, SIGNED, \
45
+ FLAT_WORDS, BLANKS, _clean
46
+ except ImportError: # build_page.py sits next to us
47
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
48
+ from build_page import classify_move, is_numeric, MOVE_HEADS, SIGNED, \
49
+ FLAT_WORDS, BLANKS, _clean
50
+
51
+ SHOW = "Soft Commodity Trading"
52
+ SITE_URL = "https://storage.googleapis.com/podcast-audio-2647223968/index.html"
53
+ FEED_URL = ("https://storage.googleapis.com/podcast-audio-2647223968/"
54
+ "commodity-desk-daily/feed.xml")
55
+
56
+ # palette, spelled out because an e-mail cannot have CSS variables
57
+ INK = "#16110c"
58
+ SOFT = "#4a4238"
59
+ FAINT = "#8b8375"
60
+ LINE = "#e3ddd2"
61
+ PAPER = "#faf7f1"
62
+ OUTER = "#ece7db"
63
+ GOLD = "#a8813c"
64
+ ACCENT = "#1d4032"
65
+ UP = "#215c44"
66
+ DOWN = "#8a2f2f"
67
+ SPOILER = "#8a2f2f"
68
+ TINT = "#f4efe4"
69
+
70
+ SERIF = "Georgia,'Times New Roman',Times,serif"
71
+ SANS = "'Helvetica Neue',Helvetica,Arial,sans-serif"
72
+
73
+ PAD = "0 28px" # the content gutter, used on every row
74
+ BODY = f"font-family:{SERIF};font-size:16px;line-height:1.62;color:{INK};"
75
+ KICKER = (f"font-family:{SANS};font-size:11px;line-height:1.4;font-weight:bold;"
76
+ f"letter-spacing:.16em;text-transform:uppercase;color:{GOLD};")
77
+
78
+
79
+ def esc(s):
80
+ return html.escape(s or "", quote=True)
81
+
82
+
83
+ # ---------------------------------------------------------------- markdown --
84
+
85
+ FENCE = "```"
86
+
87
+
88
+ def inline(text):
89
+ """Inline markdown → HTML with inline styles only."""
90
+ text = re.sub(r"`([^`]+)`",
91
+ rf'<code style="font-family:Consolas,Menlo,monospace;'
92
+ rf'font-size:14px;background:{TINT};padding:1px 4px;">\1</code>',
93
+ text)
94
+ text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text)
95
+ text = re.sub(r"(?<![\w*])\*(?!\s)(.+?)(?<!\s)\*(?![\w*])", r"<em>\1</em>", text)
96
+ text = re.sub(r"(?<![\w_])_(?!\s)(.+?)(?<!\s)_(?![\w_])", r"<em>\1</em>", text)
97
+ text = re.sub(r"\[(.+?)\]\((.+?)\)",
98
+ rf'<a href="\2" style="color:{ACCENT};">\1</a>', text)
99
+ return text
100
+
101
+
102
+ def plain(text):
103
+ """Inline markdown → plain text."""
104
+ text = re.sub(r"`([^`]+)`", r"\1", text)
105
+ text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
106
+ text = re.sub(r"(?<![\w*])\*(?!\s)(.+?)(?<!\s)\*(?![\w*])", r"\1", text)
107
+ text = re.sub(r"(?<![\w_])_(?!\s)(.+?)(?<!\s)_(?![\w_])", r"\1", text)
108
+ text = re.sub(r"\[(.+?)\]\((.+?)\)", r"\1 (\2)", text)
109
+ return text
110
+
111
+
112
+ def blocks(md):
113
+ """Markdown → a list of (kind, payload) blocks. One parse feeds both the
114
+ HTML and the plain-text renderer, so they cannot drift apart."""
115
+ out, lines, i = [], md.split("\n"), 0
116
+ while i < len(lines):
117
+ stripped = lines[i].strip()
118
+ if not stripped:
119
+ i += 1
120
+ continue
121
+
122
+ if stripped.startswith(FENCE + "chart"):
123
+ spec, i = [], i + 1
124
+ while i < len(lines) and not lines[i].strip().startswith(FENCE):
125
+ spec.append(lines[i])
126
+ i += 1
127
+ i += 1
128
+ try:
129
+ spec = json.loads("\n".join(spec))
130
+ except ValueError:
131
+ spec = {}
132
+ # the spec's own title and caption become the alt text: half the
133
+ # readers of any e-mail have images turned off
134
+ out.append(("chart", spec))
135
+ continue
136
+
137
+ if stripped.startswith("<") or re.match(r"^(&nbsp;\s*)+$", stripped):
138
+ i += 1 # raw HTML / e-mail spacers
139
+ continue
140
+
141
+ if re.match(r"^(-{3,}|\*{3,})$", stripped):
142
+ out.append(("rule", None))
143
+ i += 1
144
+ continue
145
+
146
+ m = re.match(r"^(#{1,6})\s+(.*)$", stripped)
147
+ if m:
148
+ out.append(("h", (len(m.group(1)), m.group(2))))
149
+ i += 1
150
+ continue
151
+
152
+ if stripped.startswith("|") and i + 1 < len(lines) and \
153
+ re.match(r"^\|[\s:|-]+\|$", lines[i + 1].strip()):
154
+ head = [c.strip() for c in stripped.strip("|").split("|")]
155
+ i += 2
156
+ rows = []
157
+ while i < len(lines) and lines[i].strip().startswith("|"):
158
+ rows.append([c.strip() for c in lines[i].strip().strip("|").split("|")])
159
+ i += 1
160
+ out.append(("table", (head, rows)))
161
+ continue
162
+
163
+ if re.match(r"^[-*]\s+", stripped):
164
+ items = []
165
+ while i < len(lines) and re.match(r"^[-*]\s+", lines[i].strip()):
166
+ items.append(re.sub(r"^[-*]\s+", "", lines[i].strip()))
167
+ i += 1
168
+ out.append(("ul", items))
169
+ continue
170
+
171
+ if re.match(r"^\d+[.)]\s+", stripped):
172
+ items = []
173
+ while i < len(lines) and re.match(r"^\d+[.)]\s+", lines[i].strip()):
174
+ items.append(re.sub(r"^\d+[.)]\s+", "", lines[i].strip()))
175
+ i += 1
176
+ out.append(("ol", items))
177
+ continue
178
+
179
+ if stripped.startswith(">"):
180
+ quote = []
181
+ while i < len(lines) and lines[i].strip().startswith(">"):
182
+ quote.append(lines[i].strip().lstrip("> ").rstrip())
183
+ i += 1
184
+ out.append(("quote", quote))
185
+ continue
186
+
187
+ para = []
188
+ while i < len(lines) and lines[i].strip() and \
189
+ not re.match(r"^(#{1,6}\s|[-*]\s|\d+[.)]\s|\||>|<|-{3,}|```)",
190
+ lines[i].strip()):
191
+ para.append(lines[i].strip())
192
+ i += 1
193
+ if para:
194
+ out.append(("p", " ".join(para)))
195
+ else:
196
+ i += 1
197
+ return out
198
+
199
+
200
+ # ------------------------------------------------------------------ tables --
201
+
202
+ def column_kinds(head, rows):
203
+ ncol = max([len(head)] + [len(r) for r in rows]) if rows else len(head)
204
+ numeric, moves = [], []
205
+ for c in range(ncol):
206
+ col = [r[c] for r in rows if c < len(r)]
207
+ filled = [x for x in col if x.strip().lower() not in BLANKS]
208
+ title = (head[c].lower().strip(" .:") if c < len(head) else "")
209
+ hits = sum(1 for x in filled if is_numeric(x))
210
+ numeric.append(bool(filled) and hits >= max(1, int(len(filled) * 0.6)))
211
+ signed = sum(1 for x in filled if SIGNED.match(x.strip())
212
+ or any(w in x.lower() for w in FLAT_WORDS))
213
+ moves.append(title in MOVE_HEADS or (bool(filled) and signed == len(filled)))
214
+ return ncol, numeric, moves
215
+
216
+
217
+ def table_html(head, rows):
218
+ ncol, numeric, moves = column_kinds(head, rows)
219
+ th = []
220
+ for c in range(ncol):
221
+ cell = head[c] if c < len(head) else ""
222
+ align = "right" if (numeric[c] or moves[c]) else "left"
223
+ th.append(
224
+ f'<th align="{align}" style="font-family:{SANS};font-size:11px;'
225
+ f'font-weight:bold;letter-spacing:.09em;text-transform:uppercase;'
226
+ f'color:{SOFT};text-align:{align};padding:0 8px 8px 0;'
227
+ f'border-bottom:1px solid {INK};">{inline(esc(cell))}</th>')
228
+ body = []
229
+ for r in rows:
230
+ tds = []
231
+ for c in range(ncol):
232
+ cell = r[c] if c < len(r) else ""
233
+ align = "right" if (numeric[c] or moves[c]) else "left"
234
+ style = (f"font-family:{SANS};font-size:14px;line-height:1.45;"
235
+ f"color:{INK};padding:9px 8px 9px 0;text-align:{align};"
236
+ f"border-bottom:1px solid {LINE};vertical-align:top;")
237
+ if c == 0:
238
+ style += "font-weight:bold;"
239
+ rendered = inline(esc(cell))
240
+ if moves[c]:
241
+ shown, direction = classify_move(cell)
242
+ if direction:
243
+ if shown != cell:
244
+ rendered = inline(esc(shown))
245
+ colour = {"up": UP, "down": DOWN, "flat": SOFT}[direction]
246
+ rendered = (f'<span style="color:{colour};font-weight:bold;">'
247
+ f"{rendered}</span>")
248
+ tds.append(f'<td align="{align}" style="{style}">{rendered}</td>')
249
+ body.append("<tr>" + "".join(tds) + "</tr>")
250
+ return ('<table role="presentation" width="100%" cellpadding="0" cellspacing="0" '
251
+ 'border="0" style="width:100%;border-collapse:collapse;margin:0 0 20px;">'
252
+ f'<thead><tr>{"".join(th)}</tr></thead>'
253
+ f'<tbody>{"".join(body)}</tbody></table>')
254
+
255
+
256
+ def table_text(head, rows, width=76):
257
+ ncol, numeric, moves = column_kinds(head, rows)
258
+ grid = [[plain(head[c]) if c < len(head) else "" for c in range(ncol)]]
259
+ for r in rows:
260
+ line = []
261
+ for c in range(ncol):
262
+ cell = r[c] if c < len(r) else ""
263
+ if moves[c]:
264
+ shown, direction = classify_move(cell)
265
+ cell = shown if direction else cell
266
+ line.append(plain(cell))
267
+ grid.append(line)
268
+ # Fit the columns inside `width` by shaving the widest one, then wrap
269
+ # rather than truncate: a clipped contract name is worse than a long table.
270
+ widths = [max(len(row[c]) for row in grid) for c in range(ncol)]
271
+ gap = 2
272
+ while sum(widths) + gap * (ncol - 1) > width and max(widths) > 12:
273
+ widths[widths.index(max(widths))] -= 1
274
+
275
+ out = []
276
+ for n, row in enumerate(grid):
277
+ wrapped = [textwrap.wrap(row[c], widths[c]) or [""] for c in range(ncol)]
278
+ for line in range(max(len(w) for w in wrapped)):
279
+ cells = []
280
+ for c in range(ncol):
281
+ txt = wrapped[c][line] if line < len(wrapped[c]) else ""
282
+ right = (numeric[c] or moves[c]) and c > 0
283
+ cells.append(txt.rjust(widths[c]) if right else txt.ljust(widths[c]))
284
+ out.append((" " * gap).join(cells).rstrip())
285
+ if n == 0:
286
+ out.append("-" * (sum(widths) + gap * (ncol - 1)))
287
+ return "\n".join(out)
288
+
289
+
290
+ # ------------------------------------------------------------ block render --
291
+
292
+ def render_html(bs, charts, chart_at, base_h=3):
293
+ """Blocks → e-mail HTML. `chart_at` is the running global chart index."""
294
+ out = []
295
+ for kind, payload in bs:
296
+ if kind == "chart":
297
+ n = chart_at[0]
298
+ chart_at[0] += 1
299
+ url = charts[n] if n < len(charts) else ""
300
+ if not url:
301
+ print(f"[email] no URL for chart {n + 1}; skipped", file=sys.stderr)
302
+ continue
303
+ spec = payload or {}
304
+ # the PNG already carries its own title, caption and source, drawn
305
+ # by chart.py — only the alt text has to be supplied here
306
+ alt = " — ".join(x for x in (spec.get("title"), spec.get("caption"),
307
+ spec.get("source")) if x) \
308
+ or f"Chart {n + 1}"
309
+ out.append(
310
+ f'<table role="presentation" width="100%" cellpadding="0" '
311
+ f'cellspacing="0" border="0" style="margin:6px 0 22px;">'
312
+ f'<tr><td align="center" style="border:1px solid {LINE};'
313
+ f'background:{PAPER};padding:10px;">'
314
+ f'<img src="{esc(url)}" width="522" alt="{esc(alt)}" '
315
+ f'title="{esc(alt)}" '
316
+ f'style="display:block;width:100%;max-width:522px;height:auto;'
317
+ f'border:0;outline:none;text-decoration:none;">'
318
+ f"</td></tr></table>")
319
+ elif kind == "h":
320
+ level, text = payload
321
+ if level <= 3:
322
+ out.append(
323
+ f'<h3 style="margin:26px 0 10px;font-family:{SERIF};'
324
+ f'font-size:19px;line-height:1.3;font-weight:normal;'
325
+ f'color:{INK};">{inline(esc(text))}</h3>')
326
+ else:
327
+ out.append(
328
+ f'<h4 style="margin:20px 0 6px;font-family:{SANS};'
329
+ f'font-size:15px;line-height:1.35;color:{INK};">'
330
+ f"{inline(esc(text))}</h4>")
331
+ elif kind == "p":
332
+ out.append(f'<p style="margin:0 0 16px;{BODY}">{inline(esc(payload))}</p>')
333
+ elif kind in ("ul", "ol"):
334
+ tag = "ul" if kind == "ul" else "ol"
335
+ items = "".join(
336
+ f'<li style="margin:0 0 9px;{BODY}">{inline(esc(it))}</li>'
337
+ for it in payload)
338
+ out.append(f'<{tag} style="margin:0 0 16px;padding-left:22px;">'
339
+ f"{items}</{tag}>")
340
+ elif kind == "quote":
341
+ inner = "<br>".join(inline(esc(q)) for q in payload)
342
+ out.append(
343
+ f'<table role="presentation" width="100%" cellpadding="0" '
344
+ f'cellspacing="0" border="0" style="margin:0 0 20px;"><tr>'
345
+ f'<td style="border-left:3px solid {GOLD};padding:4px 0 4px 16px;'
346
+ f'font-family:{SERIF};font-size:16px;line-height:1.55;'
347
+ f'font-style:italic;color:{SOFT};">{inner}</td></tr></table>')
348
+ elif kind == "table":
349
+ out.append(table_html(*payload))
350
+ elif kind == "rule":
351
+ out.append(
352
+ f'<table role="presentation" width="100%" cellpadding="0" '
353
+ f'cellspacing="0" border="0"><tr><td height="1" '
354
+ f'style="height:1px;line-height:1px;font-size:1px;'
355
+ f'background:{LINE};">&nbsp;</td></tr>'
356
+ f'<tr><td height="22" style="height:22px;line-height:22px;'
357
+ f'font-size:1px;">&nbsp;</td></tr></table>')
358
+ return "\n".join(out)
359
+
360
+
361
+ def render_text(bs, charts, chart_at, width=76):
362
+ out = []
363
+ for kind, payload in bs:
364
+ if kind == "chart":
365
+ n = chart_at[0]
366
+ chart_at[0] += 1
367
+ spec = payload or {}
368
+ bits = [x for x in (spec.get("title"), spec.get("caption"),
369
+ spec.get("source")) if x]
370
+ if n < len(charts) and charts[n]:
371
+ bits.append(charts[n])
372
+ if bits:
373
+ out.append(textwrap.fill("[chart] " + " — ".join(bits), width,
374
+ subsequent_indent=" "))
375
+ elif kind == "h":
376
+ text = plain(payload[1])
377
+ out.append(text + "\n" + "-" * min(len(text), width))
378
+ elif kind == "p":
379
+ out.append(textwrap.fill(plain(payload), width))
380
+ elif kind == "ul":
381
+ for it in payload:
382
+ out.append(textwrap.fill(plain(it), width,
383
+ initial_indent=" * ",
384
+ subsequent_indent=" "))
385
+ elif kind == "ol":
386
+ for n, it in enumerate(payload, 1):
387
+ out.append(textwrap.fill(plain(it), width,
388
+ initial_indent=f" {n}. ",
389
+ subsequent_indent=" "))
390
+ elif kind == "quote":
391
+ for q in payload:
392
+ out.append(textwrap.fill(plain(q), width - 2,
393
+ initial_indent=" | ",
394
+ subsequent_indent=" | "))
395
+ elif kind == "table":
396
+ out.append(table_text(*payload, width=width))
397
+ elif kind == "rule":
398
+ out.append("-" * width)
399
+ return "\n\n".join(out)
400
+
401
+
402
+ # --------------------------------------------------------------- sections ---
403
+
404
+ def split_sections(md):
405
+ """[(title, level, markdown)] for every H2/H3 section, in document order."""
406
+ md = re.sub(r"^#{1,6}\s*.*SOLUTIONS.*$", "## Solutions", md,
407
+ flags=re.MULTILINE | re.IGNORECASE)
408
+ md = re.sub(r"^\s*(▼|▲).*$", "", md, flags=re.MULTILINE)
409
+ md = re.sub(r"\A\s*#\s+.*?\n(##\s+.*?\n)?", "", md, count=1)
410
+ parts, cur = [], None
411
+ for line in md.split("\n"):
412
+ m = re.match(r"^(#{2,3})\s+(.*)$", line.strip())
413
+ if m:
414
+ cur = [m.group(2).strip(), len(m.group(1)), []]
415
+ parts.append(cur)
416
+ elif cur is not None:
417
+ cur[2].append(line)
418
+ # anything before the first heading is the title block: dropped
419
+ return [(t, lv, "\n".join(body).strip()) for t, lv, body in parts]
420
+
421
+
422
+ def classify(title):
423
+ t = title.lower()
424
+ if "market pulse" in t:
425
+ return "pulse"
426
+ if "quiz" in t:
427
+ return "quiz"
428
+ if "solution" in t:
429
+ return "solutions"
430
+ if "in writing" in t or "the episode" in t:
431
+ return "written"
432
+ return "extra"
433
+
434
+
435
+ def group_sections(md):
436
+ """The notes, sorted into the e-mail's eight fixed slots.
437
+
438
+ Sections are recognised by their heading, not their position, because the
439
+ early episodes ordered them differently. Anything unrecognised (key
440
+ takeaways, vocabulary) stays with the pulse, where the notes put it.
441
+ """
442
+ out = {"pulse": [], "extra": [], "quiz": [], "solutions": [], "written": []}
443
+ current = None
444
+ for title, level, body in split_sections(md):
445
+ kind = classify(title)
446
+ if kind == "extra" and current in ("written", "quiz", "solutions"):
447
+ kind = current # an H3 inside a big section stays there
448
+ else:
449
+ current = kind
450
+ out[kind].append((title, level, body))
451
+ return out
452
+
453
+
454
+ def drill(path, index):
455
+ """One conversion drill out of conversions.md."""
456
+ if not path or not os.path.exists(path):
457
+ return None
458
+ text = open(path, encoding="utf-8").read()
459
+ found = re.split(r"^##\s+Drill\s+(\d+)\s*[—–-]\s*(.*)$", text,
460
+ flags=re.MULTILINE)
461
+ # [preamble, num, title, body, num, title, body, ...]
462
+ for i in range(1, len(found) - 2, 3):
463
+ if int(found[i]) == index:
464
+ return found[i + 1].strip(), found[i + 2].strip()
465
+ return None
466
+
467
+
468
+ def glossary(path):
469
+ terms = []
470
+ if not path or not os.path.exists(path):
471
+ return terms
472
+ for line in open(path, encoding="utf-8").read().splitlines():
473
+ m = re.match(r"^-\s+\*\*(.+?)\*\*\s+—\s+(.+?)(?:\s+_\(ep (\d+)\)_)?$",
474
+ line.strip())
475
+ if m:
476
+ terms.append((m.group(1).strip(), m.group(2).strip(), m.group(3) or ""))
477
+ return terms
478
+
479
+
480
+ # ---------------------------------------------------------------- assembly --
481
+
482
+ def row(inner, pad=PAD, bg=PAPER):
483
+ return (f'<tr><td style="padding:{pad};background:{bg};">{inner}</td></tr>')
484
+
485
+
486
+ def spacer(height, bg=PAPER):
487
+ return (f'<tr><td height="{height}" style="height:{height}px;'
488
+ f'line-height:{height}px;font-size:1px;background:{bg};">'
489
+ f"&nbsp;</td></tr>")
490
+
491
+
492
+ def kicker(text):
493
+ return f'<p style="margin:0 0 12px;{KICKER}">{esc(text)}</p>'
494
+
495
+
496
+ def button(url, label, bg=ACCENT, fg=PAPER):
497
+ return ('<table role="presentation" cellpadding="0" cellspacing="0" border="0">'
498
+ f'<tr><td bgcolor="{bg}" style="border-radius:6px;">'
499
+ f'<a href="{esc(url)}" style="display:inline-block;padding:14px 28px;'
500
+ f'font-family:{SANS};font-size:15px;font-weight:bold;color:{fg};'
501
+ f'text-decoration:none;border-radius:6px;">{esc(label)}</a>'
502
+ "</td></tr></table>")
503
+
504
+
505
+ def build(args):
506
+ md = open(args.notes, encoding="utf-8").read()
507
+ groups = group_sections(md)
508
+ charts = [c.strip() for c in (args.charts or "").split(",") if c.strip()]
509
+ terms = glossary(args.glossary)
510
+ idx = args.drill_index or ((args.number - 1) % 12) + 1
511
+ dr = drill(args.drill_file, idx)
512
+ mins, secs = divmod(args.duration, 60)
513
+ length = f"{mins} min {secs:02d}"
514
+
515
+ # charts are numbered across the whole document, in document order, which
516
+ # is the order --charts lists them and the order build_page.py printed them
517
+ at = [0]
518
+
519
+ def sec(items):
520
+ return "\n".join(
521
+ (f'<h3 style="margin:26px 0 10px;font-family:{SERIF};font-size:19px;'
522
+ f'line-height:1.3;font-weight:normal;color:{INK};">{esc(t)}</h3>'
523
+ if lv >= 3 else "") +
524
+ render_html(blocks(body), charts, at)
525
+ for t, lv, body in items)
526
+
527
+ dek = args.dek.strip() or ""
528
+ preheader = dek or f"Episode {args.number}: {args.title}"
529
+ parts = []
530
+
531
+ # 1 — header, with the listen button
532
+ parts.append(row(
533
+ f'<p style="margin:0 0 6px;{KICKER}">{esc(SHOW)}</p>'
534
+ f'<p style="margin:0 0 14px;font-family:{SANS};font-size:11px;'
535
+ f'font-weight:bold;letter-spacing:.14em;text-transform:uppercase;'
536
+ f'color:{FAINT};">Episode {args.number:02d} · {esc(args.date)} '
537
+ f"· {length}</p>"
538
+ f'<h1 style="margin:0 0 12px;font-family:{SERIF};font-size:29px;'
539
+ f'line-height:1.2;font-weight:normal;color:{INK};">{esc(args.title)}</h1>'
540
+ + (f'<p style="margin:0 0 22px;font-family:{SERIF};font-size:17px;'
541
+ f'line-height:1.5;color:{SOFT};">{esc(dek)}</p>' if dek else "")
542
+ + button(args.audio_url, f"▶ Listen — {mins} min"),
543
+ pad="30px 28px 24px"))
544
+
545
+ # 2 — read online
546
+ parts.append(row(
547
+ f'<p style="margin:0;font-family:{SANS};font-size:13px;line-height:1.6;'
548
+ f'color:{SOFT};">'
549
+ f'<a href="{esc(args.page_url)}" style="color:{ACCENT};font-weight:bold;">'
550
+ f"Read this episode online &rarr;</a>"
551
+ f'<span style="color:{FAINT};"> &nbsp;·&nbsp; charts, the quiz and the '
552
+ f'running glossary</span></p>',
553
+ pad="0 28px 22px"))
554
+ parts.append(rule_row())
555
+
556
+ # 3 — market pulse, with its chart
557
+ if groups["pulse"] or groups["extra"]:
558
+ parts.append(row(kicker("Market pulse") + sec(groups["pulse"])
559
+ + sec(groups["extra"]), pad="24px 28px 4px"))
560
+ parts.append(rule_row())
561
+
562
+ # 4 — the conversion drill
563
+ if dr:
564
+ title, body = dr
565
+ parts.append(row(
566
+ kicker(f"Conversion drill {idx} of 12")
567
+ + f'<h3 style="margin:0 0 12px;font-family:{SERIF};font-size:19px;'
568
+ f'line-height:1.3;font-weight:normal;color:{INK};">{esc(title)}</h3>'
569
+ + render_html(blocks(body), charts, at),
570
+ pad="24px 28px 4px", bg=TINT))
571
+ parts.append(rule_row())
572
+
573
+ # 5 — the quiz
574
+ if groups["quiz"]:
575
+ parts.append(row(kicker("Quiz") + sec(groups["quiz"]),
576
+ pad="24px 28px 4px"))
577
+
578
+ # 6 — the spoiler separator: a fixed-height row, not a stack of <br>
579
+ if groups["solutions"]:
580
+ parts.append(
581
+ f'<tr><td align="center" style="padding:20px 28px;background:{TINT};'
582
+ f'border-top:1px solid {LINE};border-bottom:1px solid {LINE};">'
583
+ f'<p style="margin:0;font-family:{SANS};font-size:12px;'
584
+ f'font-weight:bold;letter-spacing:.12em;text-transform:uppercase;'
585
+ f'color:{SPOILER};">Solutions below &mdash; '
586
+ f"answer first</p></td></tr>")
587
+ parts.append(spacer(240))
588
+ parts.append(spacer(240))
589
+
590
+ # 7 — the solutions
591
+ parts.append(row(kicker("Solutions") + sec(groups["solutions"]),
592
+ pad="10px 28px 4px"))
593
+ parts.append(rule_row())
594
+
595
+ # 8 — the written edition, lesson charts inline
596
+ if groups["written"]:
597
+ parts.append(row(kicker("The episode, in writing")
598
+ + sec(groups["written"]), pad="24px 28px 4px"))
599
+ parts.append(rule_row())
600
+
601
+ # 9 — the cumulative glossary
602
+ if terms:
603
+ rows = []
604
+ for term, definition, ep in terms:
605
+ tag = (f'<span style="font-family:{SANS};font-size:10px;'
606
+ f'letter-spacing:.08em;text-transform:uppercase;'
607
+ f'color:{FAINT};">&nbsp;ep {ep}</span>' if ep else "")
608
+ rows.append(
609
+ f'<p style="margin:0 0 9px;font-family:{SERIF};font-size:15px;'
610
+ f'line-height:1.5;color:{SOFT};"><strong style="color:{INK};">'
611
+ f"{esc(term)}</strong> &mdash; {inline(esc(definition))}{tag}</p>")
612
+ parts.append(row(
613
+ kicker(f"Glossary · {len(terms)} terms")
614
+ + f'<p style="margin:0 0 16px;font-family:{SANS};font-size:13px;'
615
+ f'line-height:1.6;color:{FAINT};">Everything the show has '
616
+ f"introduced so far.</p>" + "".join(rows),
617
+ pad="24px 28px 8px"))
618
+
619
+ # footer
620
+ parts.append(
621
+ f'<tr><td style="padding:22px 28px 30px;background:{OUTER};'
622
+ f'border-top:1px solid {LINE};">'
623
+ f'<p style="margin:0 0 6px;font-family:{SANS};font-size:12px;'
624
+ f'line-height:1.7;color:{SOFT};"><strong>{esc(SHOW)}</strong> '
625
+ f"&mdash; a daily briefing on physical commodity trading.</p>"
626
+ f'<p style="margin:0;font-family:{SANS};font-size:12px;line-height:1.7;'
627
+ f'color:{FAINT};">'
628
+ f'<a href="{esc(SITE_URL)}" style="color:{SOFT};">All episodes</a> '
629
+ f'&nbsp;·&nbsp; <a href="{esc(FEED_URL)}" style="color:{SOFT};">'
630
+ f"Subscribe by RSS</a> &nbsp;·&nbsp; "
631
+ f'<a href="{esc(args.page_url)}" style="color:{SOFT};">This episode '
632
+ f"online</a></p></td></tr>")
633
+
634
+ doc = f"""<!DOCTYPE html>
635
+ <html lang="en">
636
+ <head>
637
+ <meta charset="utf-8">
638
+ <meta name="viewport" content="width=device-width,initial-scale=1">
639
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
640
+ <meta name="x-apple-disable-message-reformatting">
641
+ <meta name="color-scheme" content="light">
642
+ <meta name="supported-color-schemes" content="light">
643
+ <title>{esc(SHOW)} — Ep {args.number}: {esc(args.title)}</title>
644
+ </head>
645
+ <body style="margin:0;padding:0;background:{OUTER};-webkit-text-size-adjust:100%;">
646
+ <div style="display:none;max-height:0;overflow:hidden;mso-hide:all;">{esc(preheader)}</div>
647
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:{OUTER};">
648
+ <tr><td align="center" style="padding:20px 10px;">
649
+ <table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" align="center" style="width:100%;max-width:600px;background:{PAPER};border:1px solid {LINE};">
650
+ {chr(10).join(parts)}
651
+ </table>
652
+ </td></tr>
653
+ </table>
654
+ </body>
655
+ </html>
656
+ """
657
+ return doc, build_text(args, groups, charts, terms, dr, idx, length)
658
+
659
+
660
+ def rule_row():
661
+ return (f'<tr><td style="padding:0 28px;background:{PAPER};">'
662
+ f'<table role="presentation" width="100%" cellpadding="0" '
663
+ f'cellspacing="0" border="0"><tr><td height="1" style="height:1px;'
664
+ f'line-height:1px;font-size:1px;background:{LINE};">&nbsp;</td>'
665
+ f"</tr></table></td></tr>")
666
+
667
+
668
+ def build_text(args, groups, charts, terms, dr, idx, length):
669
+ """The plain-text alternative, from the same parse and the same order."""
670
+ at = [0]
671
+ W = 76
672
+ L = []
673
+
674
+ def head(title):
675
+ L.append("")
676
+ L.append(title.upper())
677
+ L.append("=" * min(len(title), W))
678
+ L.append("")
679
+
680
+ def body(items):
681
+ for t, lv, md in items:
682
+ if lv >= 3:
683
+ L.append(plain(t))
684
+ L.append("-" * min(len(t), W))
685
+ L.append("")
686
+ L.append(render_text(blocks(md), charts, at, W))
687
+ L.append("")
688
+
689
+ L.append(f"{SHOW.upper()}")
690
+ L.append(f"Episode {args.number:02d} · {args.date} · {length}")
691
+ L.append("")
692
+ L.append(args.title)
693
+ if args.dek.strip():
694
+
695
+ L.append(textwrap.fill(args.dek.strip(), W))
696
+ L.append("")
697
+ L.append(f"Listen: {args.audio_url}")
698
+ L.append(f"Read online: {args.page_url}")
699
+
700
+ if groups["pulse"] or groups["extra"]:
701
+ head("Market pulse")
702
+ body(groups["pulse"])
703
+ body(groups["extra"])
704
+ if dr:
705
+ title, md = dr
706
+ head(f"Conversion drill {idx} of 12 — {title}")
707
+ L.append(render_text(blocks(md), charts, at, W))
708
+ L.append("")
709
+ if groups["quiz"]:
710
+ head("Quiz")
711
+ body(groups["quiz"])
712
+ if groups["solutions"]:
713
+ L.append("")
714
+ L.append("=" * W)
715
+ L.append("SOLUTIONS BELOW — ANSWER FIRST".center(W))
716
+ L.append("=" * W)
717
+ L.extend([""] * 25)
718
+ head("Solutions")
719
+ body(groups["solutions"])
720
+ if groups["written"]:
721
+ head("The episode, in writing")
722
+ body(groups["written"])
723
+ if terms:
724
+ head(f"Glossary — {len(terms)} terms")
725
+ for term, definition, ep in terms:
726
+ L.append(textwrap.fill(
727
+ f"{term} — {plain(definition)}" + (f" (ep {ep})" if ep else ""),
728
+ W, subsequent_indent=" "))
729
+ L.append("")
730
+ L.append("-" * W)
731
+ L.append(f"{SHOW} — a daily briefing on physical commodity trading.")
732
+ L.append(f"All episodes: {SITE_URL}")
733
+ L.append(f"RSS: {FEED_URL}")
734
+ return "\n".join(L) + "\n"
735
+
736
+
737
+ def main():
738
+ ap = argparse.ArgumentParser(
739
+ description="Render the daily e-mail (HTML + plain text) from epNN.md.")
740
+ ap.add_argument("--notes", required=True)
741
+ ap.add_argument("--number", type=int, required=True)
742
+ ap.add_argument("--title", required=True)
743
+ ap.add_argument("--dek", default="", help="one sentence under the headline")
744
+ ap.add_argument("--audio-url", required=True)
745
+ ap.add_argument("--page-url", required=True)
746
+ ap.add_argument("--duration", type=int, required=True, help="seconds")
747
+ ap.add_argument("--date", default="")
748
+ ap.add_argument("--charts", default="",
749
+ help="comma-separated published PNG URLs, in the order "
750
+ "build_page.py printed CHART_PNG= lines")
751
+ ap.add_argument("--glossary", default="",
752
+ help="glossary.md, for the cumulative glossary at the foot")
753
+ ap.add_argument("--drill-index", type=int, default=0,
754
+ help="which conversion drill; defaults to ((N-1) mod 12)+1")
755
+ ap.add_argument("--drill-file", default="conversions.md")
756
+ ap.add_argument("--out", required=True)
757
+ ap.add_argument("--text-out", default="",
758
+ help="plain-text alternative; defaults to --out with .txt")
759
+ args = ap.parse_args()
760
+
761
+ doc, text = build(args)
762
+ with open(args.out, "w", encoding="utf-8") as fh:
763
+ fh.write(doc)
764
+ txt_path = args.text_out or re.sub(r"\.html?$", "", args.out) + ".txt"
765
+ with open(txt_path, "w", encoding="utf-8") as fh:
766
+ fh.write(text)
767
+ print(f"[email] {args.out} | {len(doc):,} bytes")
768
+ print(f"[email] {txt_path} | {len(text):,} bytes")
769
+ return 0
770
+
771
+
772
+ if __name__ == "__main__":
773
+ sys.exit(main())