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
This commit is contained in:
jp
2026-08-13 13:59:29 +01:00
parent 456d5f96f2
commit 7570de5cb5

View File

@ -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 = '<div class="ok">%s</div>' % escape(msg) if msg else ""
if not rows:
return page("Messages", banner + '<div class="card empty">'
'No voicemails yet.<br><span class="hint">New messages appear '
'here automatically once transcribed.</span></div>',
mb, name)
# filter bar
filters = ""
if q or date_from or date_to or unread:
parts = []
if q:
parts.append("search: <b>%s</b>" % escape(q))
if date_from:
parts.append("from: <b>%s</b>" % escape(date_from))
if date_to:
parts.append("to: <b>%s</b>" % escape(date_to))
if unread:
parts.append("unread only")
filters = ('<div class="card" style="padding:12px 16px">'
'<div class="meta">Filters: %s'
' &middot; <a href="%s/">Clear all</a></div></div>'
% (" &middot; ".join(parts), BASE))
banner = ""
if msg:
banner = '<div class="ok">%s</div>' % escape(msg)
if not rows:
return page("Messages", banner + filters +
'<div class="card empty">'
'No voicemails match your filters.<br><span class="hint">'
'Try clearing the search or adjusting the date range.</span>'
'</div>', mb, name)
out = [banner, filters,
'<form method="get" action="%s/" class="card" style="padding:12px 16px">'
'<div class="row" style="align-items:center;gap:8px">'
'<input type="text" name="q" value="%s" placeholder="Search caller, transcript..."'
' style="flex:1;min-width:180px">'
'<input type="date" name="date_from" value="%s" style="width:auto">'
'<input type="date" name="date_to" value="%s" style="width:auto">'
'<label style="font-weight:400;margin:0;display:flex;align-items:center;gap:5px">'
'<input type="checkbox" name="unread" value="1"%s> unread</label>'
'<button type="submit" class="primary">Filter</button>'
'</div></form>' % (BASE, escape(q or ""), escape(date_from), escape(date_to),
" checked" if unread else "")]
out = [banner]
for r in rows:
tags = "".join('<span class="tag">%s</span>' % escape(t)
for t in json.loads(r["intents"] or "[]"))
@ -345,6 +394,14 @@ def index(vm_session: str = Cookie(default=None), msg: str = ""):
'<div class="tr">%s</div></details>'
% 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 = ('<a href="%s/contacts/history/%s">%s</a>'
% (BASE, num, escape(who_raw)))
else:
who = escape(who_raw)
out.append("""<div class="card%s">
<div class="who">%s</div>
<div class="meta">%s%s</div>
@ -359,7 +416,7 @@ def index(vm_session: str = Cookie(default=None), msg: str = ""):
%s
</div></div>""" % (
"" if r["is_read"] else " unread",
escape(r["contact_name"] or r["callerid"] or "Unknown caller"),
who,
escape(fmt_time(r["origtime"])),
(" &middot; " + 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"],
('<button type="button" class="add-btn" onclick="openAddContact(%r, %r)">+ Add to contacts</button>'
% (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"]
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 = '<div class="ok">%s</div>' % escape(msg) if msg else ""
if not rows:
return page("Contacts", banner + '<div class="card empty">'
'No contacts yet. Use the button below to import from your '
'address book, or add them one by one.</div>'
'No contacts yet.</div>'
'<div class="row"><a class="btn" href="%s/contacts/new">'
'+ New contact</a></div>', mb)
out = [banner, '<div class="card">'
'<div class="meta">%d contacts &middot; '
'<a href="%s/contacts/new">+ New</a> &middot; '
'<form method="post" action="%s/contacts/import_vcf" '
'style="display:inline"><button type="submit" class="btn">'
'Import from VCF</button></form> &middot; '
'<button type="button" class="danger" '
'onclick="if(confirm(\'Delete ALL %d contacts? This cannot be undone.\'))'
' document.getElementById(\'delAllForm\').submit()">Delete all</button>'
'</form></div>' % (total, BASE, BASE, total),
'<form id="delAllForm" method="post" action="%s/contacts/delete_all" '
'style="display:none"></form>' % BASE,
'<table style="width:100%%;border-collapse:collapse">']
out = [banner, '<form method="get" action="%s/contacts" class="card" '
'style="padding:12px 16px"><div class="row" style="align-items:center;gap:8px">'
'<input type="text" name="q" value="%s" placeholder="Search contacts..." '
'style="flex:1;min-width:180px">'
'<button type="submit" class="primary">Search</button>'
'%s</div></form>'
'<div class="card"><div class="meta">%d contacts</div>'
'<table style="width:100%%;border-collapse:collapse">' % (
BASE, escape(q or ""),
('<a class="btn" href="%s/contacts">Clear</a>' % 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 = '<div class="ok">%s</div>' % escape(msg)
if not rows:
return page(title, banner + '<div class="card empty">'
'No voicemails from this caller yet.</div>', mb)
out = [banner, '<div class="card" style="padding:12px 16px"><div class="meta">'
'<a href="%s/contacts">&larr; Back to contacts</a>'
' &middot; %d message(s) from <b>%s</b>'
'</div></div>' % (BASE, len(rows), who)]
for r in rows:
tags = "".join('<span class="tag">%s</span>' % escape(t)
for t in json.loads(r["intents"] or "[]"))
audio = ""
if r["audio_sha"]:
audio = ('<audio controls preload="none" src="%s/audio/%d"></audio>'
% (BASE, r["id"]))
out.append("""<div class="card%s">
<div class="who">%s</div>
<div class="meta">%s%s</div>
<div class="sum">%s</div>
%s
%s
%s
<div class="row">
<form method="post" action="%s/read/%d"><button>%s</button></form>
<a class="btn" href="%s/audio/%d?dl=1">Download</a>
<button type="button" class="danger" onclick="deleteVoicemail(%d, this)">Delete</button>
</div></div>""" % (
"" if r["is_read"] else " unread",
who,
escape(fmt_time(r["origtime"])),
(" &middot; " + escape(fmt_dur(r["duration"]))) if r["duration"] else "",
escape(r["summary"] or "(no speech detected)"),
('<div style="margin-top:8px">%s</div>' % tags) if tags else "",
audio, ("<details><summary>Full transcript</summary>"
'<div class="tr">%s</div></details>' % 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)