11"""Dev CLI for where-the-plow."""
22
3+ import csv
4+ import html as html_mod
5+ import io
36import re
47import shutil
58import subprocess
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
1822APP = "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+
250410def 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