From 7570de5cb503210d16faae8ce4328d724cd52d54 Mon Sep 17 00:00:00 2001 From: jp Date: Thu, 13 Aug 2026 13:59:29 +0100 Subject: [PATCH] Search/filter voicemails + contacts + per-contact history - Messages: query q, date_from, date_to, unread filters via GET - Contacts: q search across name/number/email - Contact history: /contacts/history/{num} lists all voicemails from caller - Contact names link to history page from message list --- src/vm_web.py | 203 +++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 167 insertions(+), 36 deletions(-) diff --git a/src/vm_web.py b/src/vm_web.py index d6b092f..fe0cea2 100644 --- a/src/vm_web.py +++ b/src/vm_web.py @@ -304,24 +304,73 @@ def logout(vm_session: str = Cookie(default=None)): @app.get("/", response_class=HTMLResponse) -def index(vm_session: str = Cookie(default=None), msg: str = ""): +def index(vm_session: str = Cookie(default=None), msg: str = "", + q: str = "", date_from: str = "", date_to: str = "", unread: str = ""): mb = require(vm_session) boxes = vm_auth.parse_mailboxes() name = boxes.get(mb, {}).get("name", "") con = vm_store.connect() - rows = con.execute( - "SELECT * FROM messages WHERE mailbox=? ORDER BY origtime DESC, id DESC", - (mb,)).fetchall() + + where = ["mailbox = ?"] + params = [mb] + if q: + like = "%" + q.replace("%", "%%") + "%" + where.append("(contact_name LIKE ? OR callerid LIKE ? OR summary LIKE ? OR transcript LIKE ?)") + params.extend([like, like, like, like]) + if date_from: + where.append("origtime >= ?") + params.append(int(date_from)) + if date_to: + where.append("origtime < ?") + params.append(int(date_to) + 86400) + if unread: + where.append("is_read = 0") + + sql = ("SELECT * FROM messages WHERE " + " AND ".join(where) + + " ORDER BY origtime DESC, id DESC") + rows = con.execute(sql, params).fetchall() con.close() - banner = '
%s
' % escape(msg) if msg else "" - if not rows: - return page("Messages", banner + '
' - 'No voicemails yet.
New messages appear ' - 'here automatically once transcribed.
', - mb, name) + # filter bar + filters = "" + if q or date_from or date_to or unread: + parts = [] + if q: + parts.append("search: %s" % escape(q)) + if date_from: + parts.append("from: %s" % escape(date_from)) + if date_to: + parts.append("to: %s" % escape(date_to)) + if unread: + parts.append("unread only") + filters = ('
' + '
Filters: %s' + ' · Clear all
' + % (" · ".join(parts), BASE)) + + banner = "" + if msg: + banner = '
%s
' % escape(msg) + if not rows: + return page("Messages", banner + filters + + '
' + 'No voicemails match your filters.
' + 'Try clearing the search or adjusting the date range.' + '
', mb, name) + + out = [banner, filters, + '
' + '
' + '' + '' + '' + '' + '' + '
' % (BASE, escape(q or ""), escape(date_from), escape(date_to), + " checked" if unread else "")] - out = [banner] for r in rows: tags = "".join('%s' % escape(t) for t in json.loads(r["intents"] or "[]")) @@ -345,6 +394,14 @@ def index(vm_session: str = Cookie(default=None), msg: str = ""): '
%s
' % escape(r["transcript"])) + who_raw = r["contact_name"] or r["callerid"] or "Unknown caller" + num = vm_contacts.extract_number(r["callerid"]) + if num and r.get("contact_name"): + who = ('%s' + % (BASE, num, escape(who_raw))) + else: + who = escape(who_raw) + out.append("""
%s
%s%s
@@ -359,7 +416,7 @@ def index(vm_session: str = Cookie(default=None), msg: str = ""): %s
""" % ( "" if r["is_read"] else " unread", - escape(r["contact_name"] or r["callerid"] or "Unknown caller"), + who, escape(fmt_time(r["origtime"])), (" · " + escape(fmt_dur(r["duration"]))) if r["duration"] else "", escape(r["summary"] or "(no speech detected)"), @@ -368,9 +425,8 @@ def index(vm_session: str = Cookie(default=None), msg: str = ""): BASE, r["id"], "Mark unread" if r["is_read"] else "Mark read", BASE, r["id"], r["id"], ('' - % (vm_contacts.extract_number(r["callerid"]) or "", - (r["contact_name"] or "").strip() or "")) - if not r["contact_name"] and vm_contacts.extract_number(r["callerid"]) else "")) + % (num or "", (r["contact_name"] or "").strip() or "")) + if not r["contact_name"] and num else "")) return page("Messages", "".join(out), mb, name) @@ -526,37 +582,44 @@ def _contact_form(action, c=None, msg=""): @app.get("/contacts", response_class=HTMLResponse) -def contacts_list(vm_session: str = Cookie(default=None), msg: str = ""): +def contacts_list(vm_session: str = Cookie(default=None), msg: str = "", q: str = ""): mb = require(vm_session) con = vm_store.connect() - total = con.execute("SELECT COUNT(*) c FROM contacts").fetchone()["c"] - rows = con.execute( - "SELECT id, number_e164, name, email, updated_at FROM contacts " - "ORDER BY name ASC, id DESC" - ).fetchall() + + if q: + like = "%" + q.replace("%", "%%") + "%" + rows = con.execute( + "SELECT id, number_e164, name, email, updated_at FROM contacts " + "WHERE name LIKE ? OR number_e164 LIKE ? OR email LIKE ? " + "ORDER BY name ASC, id DESC", + (like, like, like), + ).fetchall() + else: + rows = con.execute( + "SELECT id, number_e164, name, email, updated_at FROM contacts " + "ORDER BY name ASC, id DESC" + ).fetchall() + total = len(rows) con.close() banner = '
%s
' % escape(msg) if msg else "" if not rows: return page("Contacts", banner + '
' - 'No contacts yet. Use the button below to import from your ' - 'address book, or add them one by one.
' + 'No contacts yet.' '
' '+ New contact
', mb) - out = [banner, '
' - '
%d contacts · ' - '+ New · ' - '
· ' - '' - '
' % (total, BASE, BASE, total), - '
' % BASE, - ''] + out = [banner, '
' + '' + '' + '%s
' + '
%d contacts
' + '
' % ( + BASE, escape(q or ""), + ('Clear' % BASE) if q else "", + total)] for r in rows: num = escape(r.get("number_e164") or "") @@ -577,6 +640,74 @@ def contacts_list(vm_session: str = Cookie(default=None), msg: str = ""): return page("Contacts", "".join(out), mb) +@app.get("/contacts/history/{num}", response_class=HTMLResponse) +def contact_history(num: str, vm_session: str = Cookie(default=None), msg: str = ""): + mb = require(vm_session) + normalized = vm_contacts.normalize_uk(vm_contacts.digits_of(num)) or num + con = vm_store.connect() + + rows = con.execute( + "SELECT * FROM messages WHERE mailbox=? AND callerid LIKE ? " + "ORDER BY origtime DESC, id DESC", + (mb, "%%%s%%" % normalized), + ).fetchall() + + c = con.execute( + "SELECT id, name, email FROM contacts WHERE number_e164 = ? LIMIT 1", + (normalized,), + ).fetchone() + con.close() + + who = escape(c["name"]) if c and c.get("name") else escape(num) + title = "History: %s" % who + + banner = "" + if msg: + banner = '
%s
' % escape(msg) + + if not rows: + return page(title, banner + '
' + 'No voicemails from this caller yet.
', mb) + + out = [banner, '
' + '← Back to contacts' + ' · %d message(s) from %s' + '
' % (BASE, len(rows), who)] + + for r in rows: + tags = "".join('%s' % escape(t) + for t in json.loads(r["intents"] or "[]")) + audio = "" + if r["audio_sha"]: + audio = ('' + % (BASE, r["id"])) + out.append("""
+
%s
+
%s%s
+
%s
+%s +%s +%s +
+
+ Download + +
""" % ( + "" if r["is_read"] else " unread", + who, + escape(fmt_time(r["origtime"])), + (" · " + escape(fmt_dur(r["duration"]))) if r["duration"] else "", + escape(r["summary"] or "(no speech detected)"), + ('
%s
' % tags) if tags else "", + audio, ("
Full transcript" + '
%s
' % escape(r["transcript"])) + if r["transcript"] else "", + BASE, r["id"], "Mark unread" if r["is_read"] else "Mark read", + BASE, r["id"], r["id"])) + + return page(title, "".join(out), mb) + + @app.get("/contacts/new", response_class=HTMLResponse) def contact_new(vm_session: str = Cookie(default=None)): require(vm_session)