Skip to content

Commit 9c9cde9

Browse files
committed
feat: add signups CLI command to export newsletter signups as CSV and HTML
1 parent 07a2b6b commit 9c9cde9

1 file changed

Lines changed: 161 additions & 0 deletions

File tree

cli.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
"""Dev CLI for where-the-plow."""
22

3+
import csv
4+
import html as html_mod
5+
import io
36
import re
47
import shutil
58
import subprocess
@@ -13,6 +16,7 @@
1316
"changelog": "Convert CHANGELOG.md to an HTML fragment",
1417
"db-pull": "Pull production DB into data/backups/ (stops/starts prod)",
1518
"db-use-prod": "Copy a backup to data/plow.db for local dev",
19+
"signups": "Export newsletter signups to CSV and HTML",
1620
}
1721

1822
APP = "where_the_plow.main:app"
@@ -247,6 +251,162 @@ def changelog():
247251
print(f"Wrote changelog.html (changelog-id: {changelog_id})")
248252

249253

254+
def signups():
255+
import duckdb
256+
257+
db_path = ROOT / "data" / "plow.db"
258+
if not db_path.exists():
259+
print(f"Database not found: {db_path}", file=sys.stderr)
260+
sys.exit(1)
261+
262+
conn = duckdb.connect(str(db_path), read_only=True)
263+
rows = conn.execute(
264+
"""
265+
SELECT id, timestamp, email, notify_plow, notify_projects,
266+
notify_siliconharbour, note, ip, user_agent
267+
FROM signups
268+
ORDER BY timestamp DESC
269+
"""
270+
).fetchall()
271+
columns = [
272+
"id",
273+
"timestamp",
274+
"email",
275+
"notify_plow",
276+
"notify_projects",
277+
"notify_siliconharbour",
278+
"note",
279+
"ip",
280+
"user_agent",
281+
]
282+
conn.close()
283+
284+
out_dir = ROOT / "data"
285+
out_dir.mkdir(parents=True, exist_ok=True)
286+
csv_path = out_dir / "signups.csv"
287+
html_path = out_dir / "signups.html"
288+
289+
# ── CSV ──
290+
buf = io.StringIO()
291+
writer = csv.writer(buf)
292+
writer.writerow(columns)
293+
writer.writerows(rows)
294+
csv_path.write_text(buf.getvalue())
295+
296+
# ── HTML ──
297+
def _esc(val):
298+
if val is None:
299+
return '<span class="null">-</span>'
300+
if isinstance(val, bool):
301+
return "yes" if val else "no"
302+
return html_mod.escape(str(val))
303+
304+
def _bool_badge(val):
305+
if val:
306+
return '<span class="badge yes">yes</span>'
307+
return '<span class="badge no">no</span>'
308+
309+
cards_html = []
310+
for row in rows:
311+
r = dict(zip(columns, row))
312+
flags = []
313+
if r["notify_plow"]:
314+
flags.append("Plow alerts")
315+
if r["notify_projects"]:
316+
flags.append("Other projects")
317+
if r["notify_siliconharbour"]:
318+
flags.append("Silicon Harbour")
319+
flags_str = ", ".join(flags) if flags else "None"
320+
321+
note_block = ""
322+
if r["note"]:
323+
note_block = (
324+
f'<div class="note">'
325+
f'<div class="note-label">Note</div>'
326+
f"{_esc(r['note'])}"
327+
f"</div>"
328+
)
329+
330+
cards_html.append(f"""\
331+
<div class="card">
332+
<div class="card-header">
333+
<span class="email">{_esc(r["email"])}</span>
334+
<span class="id">#{r["id"]}</span>
335+
</div>
336+
<div class="meta">
337+
<span>{_esc(r["timestamp"])}</span>
338+
<span>{_esc(r["ip"])}</span>
339+
</div>
340+
<div class="flags">Subscriptions: {flags_str}</div>
341+
{note_block}
342+
<div class="ua">{_esc(r["user_agent"])}</div>
343+
</div>""")
344+
345+
page = f"""\
346+
<!doctype html>
347+
<html lang="en">
348+
<head>
349+
<meta charset="utf-8">
350+
<meta name="viewport" content="width=device-width, initial-scale=1">
351+
<title>Signups ({len(rows)})</title>
352+
<style>
353+
:root {{
354+
--bg: #0f172a;
355+
--card-bg: #1e293b;
356+
--border: #334155;
357+
--text: #e2e8f0;
358+
--text-muted: #94a3b8;
359+
--accent: #60a5fa;
360+
}}
361+
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
362+
body {{
363+
font-family: system-ui, -apple-system, sans-serif;
364+
background: var(--bg); color: var(--text);
365+
padding: 24px; line-height: 1.5;
366+
}}
367+
h1 {{ font-size: 20px; margin-bottom: 16px; }}
368+
h1 span {{ color: var(--text-muted); font-weight: 400; }}
369+
.cards {{ display: flex; flex-direction: column; gap: 12px; max-width: 720px; }}
370+
.card {{
371+
background: var(--card-bg); border: 1px solid var(--border);
372+
border-radius: 8px; padding: 14px 18px;
373+
}}
374+
.card-header {{ display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 4px; }}
375+
.email {{ font-weight: 600; color: var(--accent); }}
376+
.id {{ color: var(--text-muted); font-size: 12px; }}
377+
.meta {{ font-size: 12px; color: var(--text-muted); display: flex; gap: 16px; margin-bottom: 6px; }}
378+
.flags {{ font-size: 13px; margin-bottom: 6px; }}
379+
.badge {{
380+
display: inline-block; padding: 1px 6px; border-radius: 4px;
381+
font-size: 11px; font-weight: 600;
382+
}}
383+
.badge.yes {{ background: rgba(34,197,94,0.2); color: #4ade80; }}
384+
.badge.no {{ background: rgba(239,68,68,0.15); color: #f87171; }}
385+
.note {{
386+
background: rgba(255,255,255,0.05); border-radius: 6px;
387+
padding: 8px 10px; margin: 6px 0; font-size: 13px;
388+
white-space: pre-wrap;
389+
}}
390+
.note-label {{ font-size: 11px; color: var(--text-muted); margin-bottom: 2px; text-transform: uppercase; letter-spacing: 0.05em; }}
391+
.ua {{ font-size: 11px; color: var(--text-muted); word-break: break-all; }}
392+
.null {{ color: var(--text-muted); }}
393+
</style>
394+
</head>
395+
<body>
396+
<h1>Newsletter Signups <span>({len(rows)})</span></h1>
397+
<div class="cards">
398+
{"".join(cards_html)}
399+
</div>
400+
</body>
401+
</html>
402+
"""
403+
html_path.write_text(page)
404+
405+
print(f"Exported {len(rows)} signups")
406+
print(f" CSV: {csv_path.relative_to(ROOT)}")
407+
print(f" HTML: {html_path.relative_to(ROOT)}")
408+
409+
250410
def usage():
251411
print("Usage: uv run cli.py <command>\n")
252412
print("Commands:")
@@ -266,6 +426,7 @@ def main():
266426
"changelog": changelog,
267427
"db-pull": db_pull,
268428
"db-use-prod": db_use_prod,
429+
"signups": signups,
269430
}
270431
dispatch[cmd]()
271432

0 commit comments

Comments
 (0)