lovelace v0.1 — lace-clad lead tracker for Glitch University outreach
Standalone Python CLI + SQLite (stdlib only). Tracks creator leads, which letters went to which address, replies, and the funnel (prospect → … → live). Commands: init/add/list/show/set/note/sent/reply/next/stats/export. Lead data (emails/PII) stays local and is gitignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+11
@@ -0,0 +1,11 @@
|
|||||||
|
# Lead data lives in SQLite and is NOT version-controlled (it holds emails / PII).
|
||||||
|
*.db
|
||||||
|
.lovelace/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
venv/
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# lovelace
|
||||||
|
|
||||||
|
*A beautiful, enticing, lace-clad system for tracking creator leads — and the
|
||||||
|
letters we send to lure them into putting their content on Glitch University.*
|
||||||
|
|
||||||
|
Named for Ada: the first programmer, in lace. **lovelace** is the Phase 3 engine
|
||||||
|
of the WP6 influencer campaign — it remembers **who we wrote to, at which address,
|
||||||
|
what we said, who wrote back, and how close each creator is to a live subtree.**
|
||||||
|
|
||||||
|
No server, no dependencies — Python stdlib + a SQLite ledger. The lead data
|
||||||
|
(emails, PII) stays local and is never committed.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e . # then `lovelace ...`
|
||||||
|
# or, without installing:
|
||||||
|
python3 -m lovelace ...
|
||||||
|
```
|
||||||
|
|
||||||
|
The ledger lives at `~/.lovelace/lovelace.db` (override with `LOVELACE_DB`).
|
||||||
|
|
||||||
|
## Use
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lovelace init # open the book
|
||||||
|
|
||||||
|
lovelace add --name "Jane Deepdive" --platform youtube \
|
||||||
|
--topic "heterodox economics" --email jane@example.com \
|
||||||
|
--difficulty low --status shortlist --fit "long-form, paid audience"
|
||||||
|
|
||||||
|
lovelace list # the whole book
|
||||||
|
lovelace list --status shortlist # filtered
|
||||||
|
lovelace next # who to approach next (easiest graft first)
|
||||||
|
|
||||||
|
lovelace sent 1 --subject "Not a pitch..." --variant quince-A # log a letter
|
||||||
|
lovelace reply 1 "Intrigued — what's the split?" --disposition interested
|
||||||
|
lovelace show 1 # full history for one lead
|
||||||
|
|
||||||
|
lovelace stats # the funnel
|
||||||
|
lovelace export --csv leads.csv # hand-off / backup
|
||||||
|
```
|
||||||
|
|
||||||
|
## The funnel
|
||||||
|
|
||||||
|
`prospect → longlist → shortlist → contacted → replied → onboarding → live`
|
||||||
|
(plus `declined`). `sent` auto-advances a lead to *contacted*; `reply` to *replied*.
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
- **Phase 3 (this):** lead DB + send/reply tracking + funnel. ✅
|
||||||
|
- **Phase 4:** actually send the emails and handle replies — lovelace already
|
||||||
|
records the templates/variants so attribution is ready.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
__version__ = "0.1.0"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from lovelace.cli import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+325
@@ -0,0 +1,325 @@
|
|||||||
|
"""lovelace — a lace-clad CLI for tracking creator leads and the letters sent to them.
|
||||||
|
|
||||||
|
Ada would approve: the first programmer, dressed in lace. We track who we wrote to,
|
||||||
|
what we said, who wrote back, and how close each creator is to a live subtree.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
|
||||||
|
# ── lace ────────────────────────────────────────────────────────────────────
|
||||||
|
_TTY = sys.stdout.isatty()
|
||||||
|
_C = {"grey": "90", "red": "31", "green": "32", "yellow": "33", "blue": "34",
|
||||||
|
"magenta": "35", "cyan": "36", "bgreen": "92", "bold": "1", "dim": "2"}
|
||||||
|
|
||||||
|
|
||||||
|
def c(s, *codes):
|
||||||
|
if not _TTY or not codes:
|
||||||
|
return s
|
||||||
|
return "\033[" + ";".join(_C[x] for x in codes) + "m" + str(s) + "\033[0m"
|
||||||
|
|
||||||
|
|
||||||
|
STATUS_COLOR = {
|
||||||
|
"prospect": "grey", "longlist": "blue", "shortlist": "cyan",
|
||||||
|
"contacted": "yellow", "replied": "magenta", "onboarding": "green",
|
||||||
|
"live": "bgreen", "declined": "red",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def st(status):
|
||||||
|
return c(status or "", STATUS_COLOR.get(status, "grey"))
|
||||||
|
|
||||||
|
|
||||||
|
def padc(text, width, *codes):
|
||||||
|
"""Colorize then pad to a visible width (padding ignores ANSI codes)."""
|
||||||
|
text = (text or "")[:width]
|
||||||
|
return c(text, *codes) + " " * max(0, width - len(text))
|
||||||
|
|
||||||
|
|
||||||
|
def banner():
|
||||||
|
print(c(" ╭─◦❀◦───────────────────◦❀◦─╮", "magenta"))
|
||||||
|
print(c(" │", "magenta") + c(" L O V E L A C E", "bold") + c(" │", "magenta"))
|
||||||
|
print(c(" │", "magenta") + c(" lead tracking, lace-clad", "dim") + c(" │", "magenta"))
|
||||||
|
print(c(" ╰─◦❀◦───────────────────◦❀◦─╯", "magenta"))
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers ───────────────────────────────────────────────────────────────--
|
||||||
|
def get_lead(conn, lead_id):
|
||||||
|
row = conn.execute("SELECT * FROM leads WHERE id=?", (lead_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
sys.exit(c(f"No lead #{lead_id}. She is not in the book.", "red"))
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def touch(conn, lead_id):
|
||||||
|
conn.execute("UPDATE leads SET updated_at=? WHERE id=?", (db.now(), lead_id))
|
||||||
|
|
||||||
|
|
||||||
|
# ── commands ──────────────────────────────────────────────────────────────--
|
||||||
|
def cmd_init(args):
|
||||||
|
db.init_db()
|
||||||
|
banner()
|
||||||
|
print(c(" Book opened.", "green"), f"Ledger at {c(db.DB_PATH, 'dim')}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_add(args):
|
||||||
|
conn = db.init_db()
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO leads(name,handle,email,platform,topic,audience,fit,difficulty,status,source,created_at,updated_at)"
|
||||||
|
" VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
(args.name, args.handle, args.email, args.platform, args.topic, args.audience,
|
||||||
|
args.fit, args.difficulty, args.status, args.source, db.now(), db.now()))
|
||||||
|
conn.commit()
|
||||||
|
print(c(f" ✶ #{cur.lastrowid}", "bgreen"), c(args.name, "bold"),
|
||||||
|
"added as", st(args.status))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_list(args):
|
||||||
|
conn = db.init_db()
|
||||||
|
q, params = "SELECT * FROM leads", []
|
||||||
|
where = []
|
||||||
|
if args.status:
|
||||||
|
where.append("status=?"); params.append(args.status)
|
||||||
|
if args.difficulty:
|
||||||
|
where.append("difficulty=?"); params.append(args.difficulty)
|
||||||
|
if where:
|
||||||
|
q += " WHERE " + " AND ".join(where)
|
||||||
|
q += " ORDER BY id"
|
||||||
|
rows = conn.execute(q, params).fetchall()
|
||||||
|
if not rows:
|
||||||
|
print(c(" The book is empty. Go find someone worth grafting.", "dim"))
|
||||||
|
return
|
||||||
|
print(c(" id name status diff platform email", "dim"))
|
||||||
|
print(c(" ─── ───────────────────── ─────────── ────── ─────────────── ─────────────────", "dim"))
|
||||||
|
for r in rows:
|
||||||
|
print(" " + padc(str(r["id"]), 5, "bold") +
|
||||||
|
padc(r["name"], 23) +
|
||||||
|
padc(r["status"], 13, STATUS_COLOR.get(r["status"], "grey")) +
|
||||||
|
padc(r["difficulty"], 8) +
|
||||||
|
padc(r["platform"], 17) +
|
||||||
|
(r["email"] or ""))
|
||||||
|
print(c(f"\n {len(rows)} lead(s).", "dim"))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_show(args):
|
||||||
|
conn = db.init_db()
|
||||||
|
r = get_lead(conn, args.id)
|
||||||
|
print()
|
||||||
|
print(" " + c(f"#{r['id']} {r['name']}", "bold") + f" {st(r['status'])}")
|
||||||
|
for label, key in [("handle", "handle"), ("email", "email"), ("platform", "platform"),
|
||||||
|
("topic", "topic"), ("audience", "audience"), ("difficulty", "difficulty"),
|
||||||
|
("source", "source"), ("fit", "fit")]:
|
||||||
|
if r[key]:
|
||||||
|
print(f" {c(label + ':', 'dim'):<14} {r[key]}")
|
||||||
|
notes = conn.execute("SELECT * FROM notes WHERE lead_id=? ORDER BY id", (r["id"],)).fetchall()
|
||||||
|
if notes:
|
||||||
|
print(c("\n notes", "dim"))
|
||||||
|
for n in notes:
|
||||||
|
print(f" {c(n['created_at'], 'dim')} {n['body']}")
|
||||||
|
out = conn.execute("SELECT * FROM outreach WHERE lead_id=? ORDER BY id", (r["id"],)).fetchall()
|
||||||
|
if out:
|
||||||
|
print(c("\n letters sent", "dim"))
|
||||||
|
for o in out:
|
||||||
|
v = f" [{o['variant']}]" if o["variant"] else ""
|
||||||
|
print(f" {c(o['sent_at'], 'dim')} → {c(o['to_address'] or '?', 'yellow')}{v} \"{o['subject'] or ''}\"")
|
||||||
|
rep = conn.execute("SELECT * FROM replies WHERE lead_id=? ORDER BY id", (r["id"],)).fetchall()
|
||||||
|
if rep:
|
||||||
|
print(c("\n replies", "dim"))
|
||||||
|
for rp in rep:
|
||||||
|
d = c(rp["disposition"] or "", "magenta")
|
||||||
|
print(f" {c(rp['received_at'], 'dim')} {d} {(rp['body'] or '')[:80]}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_set(args):
|
||||||
|
conn = db.init_db()
|
||||||
|
get_lead(conn, args.id)
|
||||||
|
fields = {k: getattr(args, k) for k in
|
||||||
|
("name", "handle", "email", "platform", "topic", "audience", "fit", "difficulty", "status", "source")
|
||||||
|
if getattr(args, k) is not None}
|
||||||
|
if args.status and args.status not in db.STATUSES:
|
||||||
|
sys.exit(c(f"Unknown status '{args.status}'. Pick from: {', '.join(db.STATUSES)}", "red"))
|
||||||
|
if not fields:
|
||||||
|
sys.exit(c("Nothing to set. Pass --status / --email / etc.", "red"))
|
||||||
|
sets = ", ".join(f"{k}=?" for k in fields) + ", updated_at=?"
|
||||||
|
conn.execute(f"UPDATE leads SET {sets} WHERE id=?", (*fields.values(), db.now(), args.id))
|
||||||
|
conn.commit()
|
||||||
|
print(c(f" #{args.id} updated:", "green"), ", ".join(f"{k}={v}" for k, v in fields.items()))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_note(args):
|
||||||
|
conn = db.init_db()
|
||||||
|
get_lead(conn, args.id)
|
||||||
|
conn.execute("INSERT INTO notes(lead_id,body,created_at) VALUES(?,?,?)",
|
||||||
|
(args.id, args.text, db.now()))
|
||||||
|
touch(conn, args.id)
|
||||||
|
conn.commit()
|
||||||
|
print(c(f" noted on #{args.id}.", "green"))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_sent(args):
|
||||||
|
conn = db.init_db()
|
||||||
|
lead = get_lead(conn, args.id)
|
||||||
|
to = args.to or lead["email"]
|
||||||
|
if not to:
|
||||||
|
sys.exit(c("No address. Pass --to or set the lead's email first.", "red"))
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO outreach(lead_id,channel,to_address,subject,variant,template,sent_at,status,notes)"
|
||||||
|
" VALUES(?,?,?,?,?,?,?,?,?)",
|
||||||
|
(args.id, args.channel, to, args.subject, args.variant, args.template, db.now(), "sent", args.notes))
|
||||||
|
# advance the funnel if this is the first contact
|
||||||
|
if lead["status"] in ("prospect", "longlist", "shortlist"):
|
||||||
|
conn.execute("UPDATE leads SET status='contacted' WHERE id=?", (args.id,))
|
||||||
|
touch(conn, args.id)
|
||||||
|
conn.commit()
|
||||||
|
print(c(f" ✉ letter logged", "yellow"), "to", c(to, "bold"),
|
||||||
|
f"(#{args.id} {lead['name']})", "→", st("contacted"))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_reply(args):
|
||||||
|
conn = db.init_db()
|
||||||
|
lead = get_lead(conn, args.id)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO replies(lead_id,outreach_id,received_at,disposition,body,handled)"
|
||||||
|
" VALUES(?,?,?,?,?,0)",
|
||||||
|
(args.id, args.outreach, db.now(), args.disposition, args.text))
|
||||||
|
if lead["status"] in ("contacted", "longlist", "shortlist", "prospect"):
|
||||||
|
conn.execute("UPDATE leads SET status='replied' WHERE id=?", (args.id,))
|
||||||
|
touch(conn, args.id)
|
||||||
|
conn.commit()
|
||||||
|
d = c(args.disposition, "magenta") if args.disposition else ""
|
||||||
|
print(c(f" ✎ reply logged", "magenta"), f"from #{args.id} {lead['name']}", d, "→", st("replied"))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_next(args):
|
||||||
|
conn = db.init_db()
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM leads WHERE status IN ('shortlist','longlist')"
|
||||||
|
" AND id NOT IN (SELECT lead_id FROM outreach)"
|
||||||
|
" ORDER BY CASE difficulty WHEN 'low' THEN 0 WHEN 'med' THEN 1 WHEN 'high' THEN 2 ELSE 3 END,"
|
||||||
|
" CASE status WHEN 'shortlist' THEN 0 ELSE 1 END, id LIMIT ?", (args.limit,)).fetchall()
|
||||||
|
if not rows:
|
||||||
|
print(c(" No one waiting. Longlist some creators, or go plant a subtree.", "dim"))
|
||||||
|
return
|
||||||
|
print(c(" next to approach (easiest graft first):", "dim"))
|
||||||
|
for r in rows:
|
||||||
|
print(" " + c(f"#{r['id']}", "bold") + f" {r['name']:<22} {st(r['status'])} "
|
||||||
|
+ c(f"graft:{r['difficulty']}", "dim") + f" {r['platform'] or ''}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_stats(args):
|
||||||
|
conn = db.init_db()
|
||||||
|
banner()
|
||||||
|
total = conn.execute("SELECT COUNT(*) FROM leads").fetchone()[0]
|
||||||
|
if not total:
|
||||||
|
print(c("\n No leads yet. The lace is laid; now bring the creators.", "dim"))
|
||||||
|
return
|
||||||
|
print(c("\n funnel", "dim"))
|
||||||
|
counts = {s: 0 for s in db.STATUSES}
|
||||||
|
for row in conn.execute("SELECT status, COUNT(*) n FROM leads GROUP BY status"):
|
||||||
|
counts[row["status"]] = row["n"]
|
||||||
|
width = 28
|
||||||
|
for s in db.STATUSES:
|
||||||
|
n = counts.get(s, 0)
|
||||||
|
bar = "▰" * min(n, width)
|
||||||
|
print(f" {st(s):<19} {c(str(n), 'bold'):>3} {c(bar, STATUS_COLOR.get(s, 'grey'))}")
|
||||||
|
sent = conn.execute("SELECT COUNT(DISTINCT lead_id) FROM outreach").fetchone()[0]
|
||||||
|
replied = conn.execute("SELECT COUNT(DISTINCT lead_id) FROM replies").fetchone()[0]
|
||||||
|
rate = (100.0 * replied / sent) if sent else 0.0
|
||||||
|
print(c("\n campaign", "dim"))
|
||||||
|
print(f" {c('total leads', 'dim'):<22} {total}")
|
||||||
|
print(f" {c('contacted', 'dim'):<22} {sent}")
|
||||||
|
print(f" {c('replied', 'dim'):<22} {replied} ({c(f'{rate:.0f}%', 'bold')} response)")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_export(args):
|
||||||
|
conn = db.init_db()
|
||||||
|
rows = conn.execute("SELECT * FROM leads ORDER BY id").fetchall()
|
||||||
|
cols = ["id", "name", "handle", "email", "platform", "topic", "audience",
|
||||||
|
"fit", "difficulty", "status", "source", "created_at", "updated_at"]
|
||||||
|
out = open(args.csv, "w", newline="") if args.csv else sys.stdout
|
||||||
|
w = csv.writer(out)
|
||||||
|
w.writerow(cols)
|
||||||
|
for r in rows:
|
||||||
|
w.writerow([r[col] for col in cols])
|
||||||
|
if args.csv:
|
||||||
|
out.close()
|
||||||
|
print(c(f" exported {len(rows)} lead(s) → {args.csv}", "green"))
|
||||||
|
|
||||||
|
|
||||||
|
# ── parser ────────────────────────────────────────────────────────────────--
|
||||||
|
def build_parser():
|
||||||
|
p = argparse.ArgumentParser(prog="lovelace", description="Lace-clad lead tracking for Glitch University outreach.")
|
||||||
|
sub = p.add_subparsers(dest="command")
|
||||||
|
|
||||||
|
sub.add_parser("init", help="Create the ledger.").set_defaults(func=cmd_init)
|
||||||
|
|
||||||
|
a = sub.add_parser("add", help="Add a lead.")
|
||||||
|
a.add_argument("--name", required=True)
|
||||||
|
for f in ("handle", "email", "platform", "topic", "audience", "fit", "source"):
|
||||||
|
a.add_argument(f"--{f}")
|
||||||
|
a.add_argument("--difficulty", default="unknown", choices=["low", "med", "high", "unknown"])
|
||||||
|
a.add_argument("--status", default="prospect", choices=db.STATUSES)
|
||||||
|
a.set_defaults(func=cmd_add)
|
||||||
|
|
||||||
|
li = sub.add_parser("list", help="List leads.")
|
||||||
|
li.add_argument("--status", choices=db.STATUSES)
|
||||||
|
li.add_argument("--difficulty", choices=["low", "med", "high", "unknown"])
|
||||||
|
li.set_defaults(func=cmd_list)
|
||||||
|
|
||||||
|
sh = sub.add_parser("show", help="Show one lead in full.")
|
||||||
|
sh.add_argument("id", type=int)
|
||||||
|
sh.set_defaults(func=cmd_show)
|
||||||
|
|
||||||
|
se = sub.add_parser("set", help="Update lead fields.")
|
||||||
|
se.add_argument("id", type=int)
|
||||||
|
for f in ("name", "handle", "email", "platform", "topic", "audience", "fit", "difficulty", "status", "source"):
|
||||||
|
se.add_argument(f"--{f}")
|
||||||
|
se.set_defaults(func=cmd_set)
|
||||||
|
|
||||||
|
no = sub.add_parser("note", help="Add a note to a lead.")
|
||||||
|
no.add_argument("id", type=int)
|
||||||
|
no.add_argument("text")
|
||||||
|
no.set_defaults(func=cmd_note)
|
||||||
|
|
||||||
|
sn = sub.add_parser("sent", help="Log a letter sent to a lead's address.")
|
||||||
|
sn.add_argument("id", type=int)
|
||||||
|
sn.add_argument("--to", help="Address (defaults to the lead's email).")
|
||||||
|
sn.add_argument("--subject")
|
||||||
|
sn.add_argument("--variant", help="A/B variant tag (subject/sender).")
|
||||||
|
sn.add_argument("--template")
|
||||||
|
sn.add_argument("--channel", default="email")
|
||||||
|
sn.add_argument("--notes")
|
||||||
|
sn.set_defaults(func=cmd_sent)
|
||||||
|
|
||||||
|
rp = sub.add_parser("reply", help="Log a reply from a lead.")
|
||||||
|
rp.add_argument("id", type=int)
|
||||||
|
rp.add_argument("text")
|
||||||
|
rp.add_argument("--disposition", choices=["interested", "maybe", "question", "declined"])
|
||||||
|
rp.add_argument("--outreach", type=int, help="Which outreach id this answers.")
|
||||||
|
rp.set_defaults(func=cmd_reply)
|
||||||
|
|
||||||
|
nx = sub.add_parser("next", help="Who to approach next (easiest graft first).")
|
||||||
|
nx.add_argument("--limit", type=int, default=10)
|
||||||
|
nx.set_defaults(func=cmd_next)
|
||||||
|
|
||||||
|
sub.add_parser("stats", help="Campaign funnel.").set_defaults(func=cmd_stats)
|
||||||
|
|
||||||
|
ex = sub.add_parser("export", help="Export leads to CSV.")
|
||||||
|
ex.add_argument("--csv", help="File path (default: stdout).")
|
||||||
|
ex.set_defaults(func=cmd_export)
|
||||||
|
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
if not getattr(args, "command", None):
|
||||||
|
banner()
|
||||||
|
parser.print_help()
|
||||||
|
return 0
|
||||||
|
args.func(args)
|
||||||
|
return 0
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""lovelace data layer — SQLite, stdlib only."""
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
DEFAULT_DIR = pathlib.Path(os.environ.get("LOVELACE_HOME", pathlib.Path.home() / ".lovelace"))
|
||||||
|
DB_PATH = pathlib.Path(os.environ.get("LOVELACE_DB", DEFAULT_DIR / "lovelace.db"))
|
||||||
|
|
||||||
|
# Funnel stages, in order. A lead's status is one of these.
|
||||||
|
STATUSES = [
|
||||||
|
"prospect", # spotted, not yet vetted
|
||||||
|
"longlist", # worth approaching
|
||||||
|
"shortlist", # top picks for the launch cohort
|
||||||
|
"contacted", # a letter has gone out
|
||||||
|
"replied", # they wrote back
|
||||||
|
"onboarding", # grafting their subtree
|
||||||
|
"live", # subtree is up
|
||||||
|
"declined", # not interested / not a fit
|
||||||
|
]
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS leads (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
handle TEXT,
|
||||||
|
email TEXT,
|
||||||
|
platform TEXT,
|
||||||
|
topic TEXT,
|
||||||
|
audience TEXT,
|
||||||
|
fit TEXT,
|
||||||
|
difficulty TEXT DEFAULT 'unknown',
|
||||||
|
status TEXT DEFAULT 'prospect',
|
||||||
|
source TEXT,
|
||||||
|
created_at TEXT,
|
||||||
|
updated_at TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS outreach (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
lead_id INTEGER NOT NULL REFERENCES leads(id) ON DELETE CASCADE,
|
||||||
|
channel TEXT DEFAULT 'email',
|
||||||
|
to_address TEXT,
|
||||||
|
subject TEXT,
|
||||||
|
variant TEXT,
|
||||||
|
template TEXT,
|
||||||
|
sent_at TEXT,
|
||||||
|
status TEXT DEFAULT 'sent',
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS replies (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
lead_id INTEGER NOT NULL REFERENCES leads(id) ON DELETE CASCADE,
|
||||||
|
outreach_id INTEGER REFERENCES outreach(id) ON DELETE SET NULL,
|
||||||
|
received_at TEXT,
|
||||||
|
disposition TEXT,
|
||||||
|
body TEXT,
|
||||||
|
handled INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS notes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
lead_id INTEGER NOT NULL REFERENCES leads(id) ON DELETE CASCADE,
|
||||||
|
body TEXT,
|
||||||
|
created_at TEXT
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def now():
|
||||||
|
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||||
|
|
||||||
|
|
||||||
|
def connect():
|
||||||
|
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(str(DB_PATH))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
conn = connect()
|
||||||
|
conn.executescript(SCHEMA)
|
||||||
|
conn.commit()
|
||||||
|
return conn
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from setuptools import setup, find_packages
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name="lovelace",
|
||||||
|
version="0.1.0",
|
||||||
|
description="Lace-clad CLI for tracking creator leads and outreach for Glitch University.",
|
||||||
|
packages=find_packages(),
|
||||||
|
install_requires=[], # stdlib only
|
||||||
|
entry_points={"console_scripts": ["lovelace=lovelace.cli:main"]},
|
||||||
|
python_requires=">=3.9",
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user