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:
203
src/vm_web.py
203
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 = '<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'
|
||||
' · <a href="%s/">Clear all</a></div></div>'
|
||||
% (" · ".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"])),
|
||||
(" · " + 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"]
|
||||
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 = '<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 · '
|
||||
'<a href="%s/contacts/new">+ New</a> · '
|
||||
'<form method="post" action="%s/contacts/import_vcf" '
|
||||
'style="display:inline"><button type="submit" class="btn">'
|
||||
'Import from VCF</button></form> · '
|
||||
'<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">← Back to contacts</a>'
|
||||
' · %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"])),
|
||||
(" · " + 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)
|
||||
|
||||
Reference in New Issue
Block a user