M5 Contacts plugin (builtin) + collapsed icon-rail for Calendar

Contacts (SPEC Appendix B, phase B1) — new builtin plugin `webmail_plugin_contacts`:
- Tables plg_contacts_{books,contacts,groups,group_members} (RLS-isolated,
  migration contacts0001): vCard-style structured contact fields, address books,
  groups with membership.
- API /api/plugins/contacts/: list (filter all|vip|recent|nogroup, group, q),
  get/create/update/delete, star toggle, groups CRUD, and the recipients.suggest
  capability endpoint (enriches the composer). Display name derived server-side
  from name parts so search + suggestions work regardless of client.
- Frontend ui/page.js: three-pane app — shared .wm-side-* sidebar template with a
  "Contacts" header, alphabetically grouped list, detail pane (avatar, VIP star,
  Call/Message/Compose/Invite, Contact details, live Mail search, Events stub) and
  a full edit form (name, org, birthday, repeatable emails/phones, address, notes).

Collapsed sidebar: both Contacts and Calendar now show a 60px icon-only rail (like
Mail) with expand toggle, primary button, quick nav and Settings/Sign out — instead
of hiding the panel (Contacts) / a top-bar button (Calendar).

Deferred: Gravatar photos, vCard import/export + merge (B2), CardDAV sync (B3),
"Add sender to contacts".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
2026-08-02 10:12:45 +02:00
co-authored by Claude Opus 4.8
parent 57a1f1562f
commit a985b112b1
8 changed files with 1304 additions and 2 deletions
@@ -201,6 +201,16 @@ const CSS = `
.cal-secadd{opacity:0;border:0;background:none;color:rgb(var(--app-muted));cursor:pointer;font-size:15px;line-height:1;padding:2px 4px;border-radius:4px} .cal-secadd{opacity:0;border:0;background:none;color:rgb(var(--app-muted));cursor:pointer;font-size:15px;line-height:1;padding:2px 4px;border-radius:4px}
.cal-secrow:hover .cal-secadd{opacity:1} .cal-secrow:hover .cal-secadd{opacity:1}
.cal-secadd:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))} .cal-secadd:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
/* collapsed icon-only rail (matches Mail's 60px rail) */
.cal-rail{width:60px;align-items:center}
.cal-railtop{display:flex;flex-direction:column;align-items:center;gap:4px;padding:12px 0 6px}
.cal-railprimary{padding:2px 0 8px}
.cal-railnav{flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column;align-items:center;gap:4px;padding:2px 0}
.cal-railfoot{width:100%;display:flex;flex-direction:column;align-items:center;gap:4px;padding:8px 0;border-top:1px solid rgb(var(--app-border))}
.cal-railbtn{display:flex;align-items:center;justify-content:center;width:38px;height:38px;border-radius:9px;color:rgb(var(--app-muted));cursor:pointer;background:none;border:0}
.cal-railbtn:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
.cal-railnew{display:flex;align-items:center;justify-content:center;width:40px;height:40px;border-radius:11px;background:rgb(var(--app-accent));color:rgb(var(--app-accent-fg));cursor:pointer;border:0}
.cal-railnew:hover{opacity:.9}
.cal-resizer{flex:0 0 auto;width:5px;margin:0 -2px;cursor:col-resize;position:relative;z-index:10} .cal-resizer{flex:0 0 auto;width:5px;margin:0 -2px;cursor:col-resize;position:relative;z-index:10}
.cal-resizer::after{content:"";position:absolute;top:0;bottom:0;left:50%;width:1px;transform:translateX(-50%);background:transparent} .cal-resizer::after{content:"";position:absolute;top:0;bottom:0;left:50%;width:1px;transform:translateX(-50%);background:transparent}
.cal-resizer:hover::after{background:#3b82f6} .cal-resizer:hover::after{background:#3b82f6}
@@ -813,7 +823,7 @@ export default function mount(el, ctx) {
// ---- render ---------------------------------------------------------------- // ---- render ----------------------------------------------------------------
function render() { function render() {
el.innerHTML = `<div class="cal-app"> el.innerHTML = `<div class="cal-app">
${state.leftCollapsed ? "" : `<div class="wm-side cal-left" style="width:${state.leftW}px;flex:0 0 auto">${leftPanel()}</div><div class="cal-resizer" data-resizer></div>`} ${state.leftCollapsed ? railPanel() : `<div class="wm-side cal-left" style="width:${state.leftW}px;flex:0 0 auto">${leftPanel()}</div><div class="cal-resizer" data-resizer></div>`}
<div class="cal-maincol"> <div class="cal-maincol">
${topbar()} ${topbar()}
<div class="cal-mainarea"> <div class="cal-mainarea">
@@ -858,7 +868,6 @@ export default function mount(el, ctx) {
function topbar() { function topbar() {
return `<div class="cal-topbar"> return `<div class="cal-topbar">
${state.leftCollapsed ? `<button class="cal-iconbtn" data-act="left-toggle" title="Show sidebar">${SHOW_ICON}</button>` : ""}
<div class="cal-searchbox"> <div class="cal-searchbox">
<svg class="ic" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg> <svg class="ic" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
<input class="cal-search" placeholder="Search calendar" value="${esc(state.search ? state.search.q : "")}"/> <input class="cal-search" placeholder="Search calendar" value="${esc(state.search ? state.search.q : "")}"/>
@@ -867,6 +876,26 @@ export default function mount(el, ctx) {
</div>`; </div>`;
} }
// Collapsed sidebar: a narrow icon-only rail (like Mail) that keeps the expand
// toggle, so it can always be re-opened.
function railPanel() {
const TODAY_IC = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/><circle cx="12" cy="15.5" r="2.3" fill="currentColor" stroke="none"/></svg>`;
return `<div class="wm-side cal-rail" style="flex:0 0 auto">
<div class="cal-railtop">
<button class="cal-railbtn" data-act="left-toggle" title="Expand sidebar">${SHOW_ICON}</button>
<button class="cal-railbtn" data-act="theme" title="Toggle theme">${themeGlyph()}</button>
</div>
<div class="cal-railprimary"><button class="cal-railnew" data-act="new" title="New event">${COMPOSE_ICON}</button></div>
<div class="cal-railnav">
<button class="cal-railbtn" data-act="today" title="Today">${TODAY_IC}</button>
</div>
<div class="cal-railfoot">
<button class="cal-railbtn" data-act="settings" title="Settings">${SETTINGS_ICON}</button>
<button class="cal-railbtn" data-act="signout" title="Sign out">${LOGOUT_ICON}</button>
</div>
</div>`;
}
// Left panel = the shared sidebar-shell template (.wm-side-*, see plugin-ui.css): // Left panel = the shared sidebar-shell template (.wm-side-*, see plugin-ui.css):
// switcher + collapse/theme, primary button, an app-specific scrollable body // switcher + collapse/theme, primary button, an app-specific scrollable body
// (mini-month + calendars here), and the shared foot. Identical to Mail. // (mini-month + calendars here), and the shared foot. Identical to Mail.
@@ -0,0 +1,20 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "webmail-plugin-contacts"
version = "0.1.0"
description = "Contacts — the second heavy plugin (SPEC Appendix B). Local contact books, groups, recipients.suggest; vCard + CardDAV later."
requires-python = ">=3.11"
# B1 (local contacts) needs no external deps; vobject is added at B2 (vCard I/O).
dependencies = []
[project.entry-points."webmail.plugins"]
contacts = "webmail_plugin_contacts:plugin"
[tool.setuptools]
packages = ["webmail_plugin_contacts"]
[tool.setuptools.package-data]
webmail_plugin_contacts = ["ui/*.js", "migrations/versions/*.py"]
@@ -0,0 +1,37 @@
"""``contacts`` — the second heavy plugin (SPEC Appendix B).
A **core, non-removable** plugin (``builtin=True``) that ships a `/contacts` page
beside Mail and Calendar. Its own ``plg_contacts_*`` tables (RLS-isolated) hold
address books, contacts (structured vCard-style fields), and groups. Core owns the
*implicit* recipient history (``known_recipients``); this plugin owns the *explicit*,
user-curated contacts and enriches the composer via the ``recipients.suggest``
capability. Uses the SDK only — zero core diffs. Phase B1 (local contacts) is the
current scope; vCard import/export (B2) and CardDAV sync (B3) build on the same model.
"""
from __future__ import annotations
from pathlib import Path
from app.plugins.sdk import Page, PageNav, Plugin, UiManifest
from webmail_plugin_contacts.router import router
_HERE = Path(__file__).resolve().parent
plugin = Plugin(
name="contacts",
version="0.1.0",
builtin=True,
router=router,
ui_manifest=UiManifest(
pages=[
Page(
route="/contacts",
nav=PageNav(icon="contacts", label="Contacts", order=20),
bundle="/api/plugins/contacts/ui/page.js",
)
],
capabilities={"recipients.suggest": "/api/plugins/contacts/suggest"},
),
migrations=str(_HERE / "migrations" / "versions"),
)
@@ -0,0 +1,108 @@
"""contacts plugin: books + contacts + groups tables with RLS (SPEC §B.3)
Revision ID: contacts0001
Revises:
Create Date: 2026-08-02
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
from app.plugins.rls import enable_rls
revision: str = "contacts0001"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"""
CREATE TABLE plg_contacts_books (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL,
name text NOT NULL,
kind text NOT NULL DEFAULT 'local', -- local|carddav
source_config jsonb NOT NULL DEFAULT '{}',
secret_ct bytea, -- CardDAV creds (vault, §10.2)
sync_token text,
read_only boolean NOT NULL DEFAULT false,
UNIQUE (user_id, name)
)
"""
)
op.execute(
"""
CREATE TABLE plg_contacts_contacts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL,
book_id uuid NOT NULL
REFERENCES plg_contacts_books(id) ON DELETE CASCADE,
vcard_uid text NOT NULL,
etag text,
name_display text NOT NULL DEFAULT '', -- FN
name_parts jsonb NOT NULL DEFAULT '{}', -- {prefix,given,middle,family,suffix}
nickname text NOT NULL DEFAULT '',
emails jsonb NOT NULL DEFAULT '[]', -- [{value,type,pref}]
default_email text NOT NULL DEFAULT '',
phones jsonb NOT NULL DEFAULT '[]', -- [{value,type}]
addresses jsonb NOT NULL DEFAULT '[]', -- [{type,country,street,postal_code,city}]
urls jsonb NOT NULL DEFAULT '[]', -- [{value,type}]
org text NOT NULL DEFAULT '',
department text NOT NULL DEFAULT '',
title text NOT NULL DEFAULT '',
birthday date,
notes text NOT NULL DEFAULT '',
starred boolean NOT NULL DEFAULT false, -- VIP / favourite
photo_sha256 text,
raw_vcard text, -- round-trip fidelity (B2/B3)
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (book_id, vcard_uid)
)
"""
)
op.execute(
"CREATE INDEX ix_plg_contacts_contacts_user_name "
"ON plg_contacts_contacts (user_id, name_display)"
)
op.execute(
"CREATE INDEX ix_plg_contacts_contacts_emails "
"ON plg_contacts_contacts USING gin (emails jsonb_path_ops)"
)
op.execute(
"""
CREATE TABLE plg_contacts_groups (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL,
name text NOT NULL,
UNIQUE (user_id, name)
)
"""
)
op.execute(
"""
CREATE TABLE plg_contacts_group_members (
user_id uuid NOT NULL,
group_id uuid NOT NULL
REFERENCES plg_contacts_groups(id) ON DELETE CASCADE,
contact_id uuid NOT NULL
REFERENCES plg_contacts_contacts(id) ON DELETE CASCADE,
PRIMARY KEY (group_id, contact_id)
)
"""
)
enable_rls("plg_contacts_books")
enable_rls("plg_contacts_contacts")
enable_rls("plg_contacts_groups")
enable_rls("plg_contacts_group_members")
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS plg_contacts_group_members")
op.execute("DROP TABLE IF EXISTS plg_contacts_groups")
op.execute("DROP TABLE IF EXISTS plg_contacts_contacts")
op.execute("DROP TABLE IF EXISTS plg_contacts_books")
@@ -0,0 +1,458 @@
"""Contacts plugin API (SPEC §B.5). Local address book: contact CRUD with
structured fields, groups, alphabetically grouped listing + search, and the
``recipients.suggest`` capability endpoint used by the composer.
Uses raw SQL through the RLS-scoped ``sdk.session`` (plugin tables aren't in the
core ORM). jsonb columns are written as ``json.dumps`` strings and read back as
already-parsed Python objects — same discipline as the calendar plugin.
"""
from __future__ import annotations
import json
import logging
import uuid
from datetime import date
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.plugins.sdk import CurrentUserId, require_csrf, sdk
from webmail_plugin_contacts.schemas import ContactIn, GroupIn
_log = logging.getLogger("plg.contacts")
_SDK = sdk("contacts")
router = APIRouter()
_UI = Path(__file__).resolve().parent / "ui"
# Columns selected for both the list and detail responses.
_COLS = (
"id, book_id, name_display, name_parts, nickname, emails, default_email, phones, "
"urls, addresses, org, department, title, birthday, notes, starred, updated_at"
)
def _json(value: object) -> Any:
"""Read a jsonb column defensively (SQLAlchemy usually returns parsed objects,
but tolerate a raw JSON string too)."""
if isinstance(value, str):
try:
return json.loads(value)
except ValueError:
return []
return value if value is not None else []
def _display_name(row: dict[str, object]) -> str:
if row.get("name_display"):
return str(row["name_display"])
parts = _json(row.get("name_parts")) or {}
if isinstance(parts, dict):
joined = " ".join(
str(parts.get(k, "")).strip()
for k in ("prefix", "given", "middle", "family", "suffix")
if str(parts.get(k, "")).strip()
)
if joined:
return joined
emails = _json(row.get("emails"))
if emails and isinstance(emails, list):
return str(emails[0].get("value", "")) if isinstance(emails[0], dict) else ""
return ""
def _primary_email(row: dict[str, object]) -> str:
if row.get("default_email"):
return str(row["default_email"])
emails = _json(row.get("emails"))
if isinstance(emails, list) and emails:
pref = next((e for e in emails if isinstance(e, dict) and e.get("pref")), None)
chosen = pref or emails[0]
if isinstance(chosen, dict):
return str(chosen.get("value", ""))
return ""
def _detail(row: dict[str, object], groups: list[dict[str, str]]) -> dict[str, object]:
bday = row.get("birthday")
return {
"id": str(row["id"]),
"display_name": _display_name(row),
"name_parts": _json(row.get("name_parts")) or {},
"nickname": row.get("nickname") or "",
"org": row.get("org") or "",
"department": row.get("department") or "",
"title": row.get("title") or "",
"birthday": bday.isoformat() if isinstance(bday, date) else None,
"emails": _json(row.get("emails")),
"default_email": _primary_email(row),
"phones": _json(row.get("phones")),
"urls": _json(row.get("urls")),
"addresses": _json(row.get("addresses")),
"notes": row.get("notes") or "",
"starred": bool(row.get("starred")),
"groups": groups,
}
async def _ensure_book(s: AsyncSession, user_id: uuid.UUID) -> str:
"""Return the user's default local book id, creating it (and an 'Autosaved'
group) on first use so the page always has somewhere to put new contacts."""
row = (
await s.execute(
text(
"SELECT id FROM plg_contacts_books "
"WHERE user_id = :u ORDER BY name LIMIT 1"
),
{"u": str(user_id)},
)
).first()
if row is not None:
book_id = str(row[0])
else:
book_id = str(
(
await s.execute(
text(
"INSERT INTO plg_contacts_books (user_id, name) "
"VALUES (:u, 'Contacts') RETURNING id"
),
{"u": str(user_id)},
)
).scalar_one()
)
await s.execute(
text(
"INSERT INTO plg_contacts_groups (user_id, name) VALUES (:u, 'Autosaved') "
"ON CONFLICT (user_id, name) DO NOTHING"
),
{"u": str(user_id)},
)
return book_id
async def _group_map(s: AsyncSession) -> dict[str, list[dict[str, str]]]:
"""contact_id -> [{id, name}] for every membership (RLS-scoped)."""
rows = (
await s.execute(
text(
"SELECT m.contact_id, g.id AS gid, g.name FROM plg_contacts_group_members m "
"JOIN plg_contacts_groups g ON g.id = m.group_id"
)
)
).mappings().all()
out: dict[str, list[dict[str, str]]] = {}
for r in rows:
out.setdefault(str(r["contact_id"]), []).append(
{"id": str(r["gid"]), "name": str(r["name"])}
)
return out
# --- bundle ------------------------------------------------------------------
@router.get("/ui/{filename}")
async def bundle(filename: str) -> FileResponse:
if filename != "page.js":
raise HTTPException(status_code=404, detail="not_found")
return FileResponse(
_UI / filename,
media_type="text/javascript",
headers={"Cache-Control": "no-cache"},
)
# --- groups ------------------------------------------------------------------
@router.get("/groups")
async def list_groups(user_id: CurrentUserId) -> list[dict[str, object]]:
async with _SDK.session(user_id) as s:
await _ensure_book(s, user_id)
rows = (
await s.execute(
text(
"SELECT g.id, g.name, count(m.contact_id) AS n "
"FROM plg_contacts_groups g "
"LEFT JOIN plg_contacts_group_members m ON m.group_id = g.id "
"GROUP BY g.id, g.name ORDER BY lower(g.name)"
)
)
).mappings().all()
return [{"id": str(r["id"]), "name": str(r["name"]), "count": int(r["n"])} for r in rows]
@router.post("/groups", dependencies=[Depends(require_csrf)])
async def create_group(body: GroupIn, user_id: CurrentUserId) -> dict[str, str]:
name = body.name.strip()
if not name:
raise HTTPException(status_code=400, detail="empty_name")
async with _SDK.session(user_id) as s:
gid = (
await s.execute(
text(
"INSERT INTO plg_contacts_groups (user_id, name) VALUES (:u, :n) "
"ON CONFLICT (user_id, name) DO UPDATE SET name = EXCLUDED.name "
"RETURNING id"
),
{"u": str(user_id), "n": name},
)
).scalar_one()
return {"id": str(gid), "name": name}
@router.delete("/groups/{group_id}", dependencies=[Depends(require_csrf)])
async def delete_group(group_id: uuid.UUID, user_id: CurrentUserId) -> dict[str, str]:
async with _SDK.session(user_id) as s:
await s.execute(
text("DELETE FROM plg_contacts_groups WHERE id = :id"), {"id": str(group_id)}
)
return {"status": "ok"}
# --- contacts ----------------------------------------------------------------
@router.get("/contacts")
async def list_contacts(
user_id: CurrentUserId,
filter: str = Query("all"), # all | vip | recent | nogroup
group: str = Query(""),
q: str = Query("", max_length=200),
) -> dict[str, object]:
async with _SDK.session(user_id) as s:
await _ensure_book(s, user_id)
where = ["c.user_id = :u"]
params: dict[str, object] = {"u": str(user_id)}
if filter == "vip":
where.append("c.starred")
elif filter == "nogroup":
where.append(
"NOT EXISTS (SELECT 1 FROM plg_contacts_group_members m "
"WHERE m.contact_id = c.id)"
)
if group:
where.append(
"EXISTS (SELECT 1 FROM plg_contacts_group_members m "
"WHERE m.contact_id = c.id AND m.group_id = :g)"
)
params["g"] = group
if q.strip():
where.append("(c.name_display ILIKE :q OR c.emails::text ILIKE :q)")
params["q"] = f"%{q.strip()}%"
order = (
"c.updated_at DESC"
if filter == "recent"
else "lower(c.name_display), c.updated_at DESC"
)
limit = " LIMIT 50" if filter == "recent" else ""
rows = (
await s.execute(
text(
f"SELECT {_COLS} FROM plg_contacts_contacts c "
f"WHERE {' AND '.join(where)} ORDER BY {order}{limit}"
),
params,
)
).mappings().all()
groups = await _group_map(s)
contacts = []
for r in rows:
d = dict(r)
contacts.append(
{
"id": str(r["id"]),
"display_name": _display_name(d),
"primary_email": _primary_email(d),
"org": r["org"] or "",
"title": r["title"] or "",
"starred": bool(r["starred"]),
"groups": groups.get(str(r["id"]), []),
}
)
return {"contacts": contacts}
@router.get("/contacts/{contact_id}")
async def get_contact(contact_id: uuid.UUID, user_id: CurrentUserId) -> dict[str, object]:
async with _SDK.session(user_id) as s:
row = (
await s.execute(
text(f"SELECT {_COLS} FROM plg_contacts_contacts c WHERE c.id = :id"),
{"id": str(contact_id)},
)
).mappings().first()
if row is None:
raise HTTPException(status_code=404, detail="not_found")
groups = (await _group_map(s)).get(str(contact_id), [])
return _detail(dict(row), groups)
def _derive_name(body: ContactIn) -> str:
"""FN: the client's display_name, else the assembled name parts, else first email."""
name = body.display_name.strip()
if not name:
np = body.name_parts
name = " ".join(
x.strip()
for x in (np.prefix, np.given, np.middle, np.family, np.suffix)
if x.strip()
).strip()
if not name and body.emails:
name = body.emails[0].value.strip()
return name
def _contact_params(body: ContactIn) -> dict[str, object]:
bday: date | None = None
if body.birthday:
try:
bday = date.fromisoformat(body.birthday)
except ValueError:
bday = None
return {
"name": _derive_name(body),
"parts": json.dumps(body.name_parts.model_dump()),
"nick": body.nickname.strip(),
"emails": json.dumps([e.model_dump() for e in body.emails]),
"defemail": body.default_email.strip(),
"phones": json.dumps([p.model_dump() for p in body.phones]),
"urls": json.dumps([u.model_dump() for u in body.urls]),
"addrs": json.dumps([a.model_dump() for a in body.addresses]),
"org": body.org.strip(),
"dept": body.department.strip(),
"title": body.title.strip(),
"bday": bday,
"notes": body.notes,
"starred": body.starred,
}
async def _set_groups(
s: AsyncSession, user_id: uuid.UUID, contact_id: str, group_ids: list[str]
) -> None:
await s.execute(
text("DELETE FROM plg_contacts_group_members WHERE contact_id = :c"),
{"c": contact_id},
)
for gid in group_ids:
await s.execute(
text(
"INSERT INTO plg_contacts_group_members (user_id, group_id, contact_id) "
"VALUES (:u, :g, :c) ON CONFLICT DO NOTHING"
),
{"u": str(user_id), "g": gid, "c": contact_id},
)
@router.post("/contacts", dependencies=[Depends(require_csrf)])
async def create_contact(body: ContactIn, user_id: CurrentUserId) -> dict[str, str]:
async with _SDK.session(user_id) as s:
book_id = await _ensure_book(s, user_id)
p = _contact_params(body)
p.update({"u": str(user_id), "book": book_id, "uid": str(uuid.uuid4())})
cid = (
await s.execute(
text(
"INSERT INTO plg_contacts_contacts "
"(user_id, book_id, vcard_uid, name_display, name_parts, nickname, emails, "
" default_email, phones, urls, addresses, org, department, title, birthday, "
" notes, starred) "
"VALUES (:u, :book, :uid, :name, :parts, :nick, :emails, :defemail, :phones, "
" :urls, :addrs, :org, :dept, :title, :bday, :notes, :starred) RETURNING id"
),
p,
)
).scalar_one()
await _set_groups(s, user_id, str(cid), body.group_ids)
return {"id": str(cid)}
@router.put("/contacts/{contact_id}", dependencies=[Depends(require_csrf)])
async def update_contact(
contact_id: uuid.UUID, body: ContactIn, user_id: CurrentUserId
) -> dict[str, str]:
async with _SDK.session(user_id) as s:
p = _contact_params(body)
p["id"] = str(contact_id)
result = await s.execute(
text(
"UPDATE plg_contacts_contacts SET "
"name_display = :name, name_parts = :parts, nickname = :nick, emails = :emails, "
"default_email = :defemail, phones = :phones, urls = :urls, addresses = :addrs, "
"org = :org, department = :dept, title = :title, birthday = :bday, notes = :notes, "
"starred = :starred, updated_at = now() WHERE id = :id"
),
p,
)
if result.rowcount == 0:
raise HTTPException(status_code=404, detail="not_found")
await _set_groups(s, user_id, str(contact_id), body.group_ids)
return {"status": "ok"}
@router.delete("/contacts/{contact_id}", dependencies=[Depends(require_csrf)])
async def delete_contact(contact_id: uuid.UUID, user_id: CurrentUserId) -> dict[str, str]:
async with _SDK.session(user_id) as s:
await s.execute(
text("DELETE FROM plg_contacts_contacts WHERE id = :id"), {"id": str(contact_id)}
)
return {"status": "ok"}
@router.post("/contacts/{contact_id}/star", dependencies=[Depends(require_csrf)])
async def toggle_star(contact_id: uuid.UUID, user_id: CurrentUserId) -> dict[str, object]:
async with _SDK.session(user_id) as s:
row = (
await s.execute(
text(
"UPDATE plg_contacts_contacts SET starred = NOT starred WHERE id = :id "
"RETURNING starred"
),
{"id": str(contact_id)},
)
).first()
if row is None:
raise HTTPException(status_code=404, detail="not_found")
return {"starred": bool(row[0])}
# --- recipients.suggest capability (SPEC §B.5 / §11.3) -----------------------
@router.get("/suggest")
async def suggest(
user_id: CurrentUserId, q: str = Query("", max_length=200)
) -> list[dict[str, object]]:
term = q.strip()
if not term:
return []
like = f"%{term}%"
async with _SDK.session(user_id) as s:
rows = (
await s.execute(
text(
"SELECT id, name_display, emails, default_email FROM plg_contacts_contacts "
"WHERE name_display ILIKE :q OR emails::text ILIKE :q "
"ORDER BY lower(name_display) LIMIT 10"
),
{"q": like},
)
).mappings().all()
out: list[dict[str, object]] = []
for r in rows:
email = _primary_email(dict(r))
if not email:
continue
out.append(
{
"name": _display_name(dict(r)),
"email": email,
"source": "contact",
"contact_id": str(r["id"]),
}
)
return out
@@ -0,0 +1,59 @@
"""Request models for the contacts API (SPEC §B.5)."""
from __future__ import annotations
from pydantic import BaseModel
class NameParts(BaseModel):
prefix: str = "" # "Title" in the edit form (Dr, Mr, …)
given: str = ""
middle: str = ""
family: str = ""
suffix: str = ""
class EmailEntry(BaseModel):
value: str
type: str = "personal" # personal | work | other
pref: bool = False
class PhoneEntry(BaseModel):
value: str
type: str = "mobile" # mobile | home | work | other
class UrlEntry(BaseModel):
value: str
type: str = "website"
class AddressEntry(BaseModel):
type: str = "home" # home | work | other
country: str = ""
street: str = ""
postal_code: str = ""
city: str = ""
class ContactIn(BaseModel):
display_name: str = "" # FN; derived from name parts / first email when blank
name_parts: NameParts = NameParts()
nickname: str = ""
org: str = "" # Company
department: str = ""
title: str = "" # Job title
birthday: str | None = None # "YYYY-MM-DD" (full date) or None
emails: list[EmailEntry] = []
default_email: str = ""
phones: list[PhoneEntry] = []
urls: list[UrlEntry] = []
addresses: list[AddressEntry] = []
notes: str = ""
starred: bool = False
group_ids: list[str] = []
class GroupIn(BaseModel):
name: str
@@ -0,0 +1,591 @@
// contacts · app.page bundle (SPEC §B.6). A full-bleed three-pane contacts app:
// shared sidebar template (.wm-side-*, identical to Mail/Calendar) with a
// "Contacts" header, an alphabetically grouped contact list, and a detail pane
// with an edit-in-place form. Framework-agnostic vanilla DOM; theme-aware via
// rgb(var(--app-*)); data comes from the plugin API.
const esc = (s) => String(s == null ? "" : s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
const MONTHS = ["January","February","March","April","May","June","July","August","September","October","November","December"];
const EMAIL_TYPES = ["personal", "work", "other"];
const PHONE_TYPES = ["mobile", "home", "work", "other"];
const ADDR_TYPES = ["home", "work", "other"];
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);
// Icons copied from Mail/Calendar (frontend/icons/*.svg) so the apps match exactly.
const HIDE_ICON = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m4 4l16 16m-3.5-3.244C15.147 17.485 13.618 18 12 18c-3.53 0-6.634-2.452-8.413-4.221c-.47-.467-.705-.7-.854-1.159c-.107-.327-.107-.913 0-1.24c.15-.459.385-.693.855-1.16c.897-.892 2.13-1.956 3.584-2.793M19.5 14.634c.333-.293.638-.582.912-.854l.003-.003c.468-.466.703-.7.852-1.156c.107-.327.107-.914 0-1.241c-.15-.458-.384-.692-.854-1.159C18.633 8.452 15.531 6 12 6q-.507 0-1 .064m2.323 7.436a2 2 0 0 1-2.762-2.889"/></svg>`;
const SHOW_ICON = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3.587 13.779c1.78 1.769 4.883 4.22 8.413 4.22s6.634-2.451 8.413-4.22c.47-.467.705-.7.854-1.159c.107-.327.107-.913 0-1.24c-.15-.458-.385-.692-.854-1.159C18.633 8.452 15.531 6 12 6c-3.53 0-6.634 2.452-8.413 4.221c-.47.467-.705.7-.854 1.159c-.107.327-.107.913 0 1.24c.15.458.384.692.854 1.159"/><path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0-4 0"/></svg>`;
const SETTINGS_ICON = `<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M19 7.5h-7.628a2.251 2.251 0 0 0-4.244 0H5V9h2.128a2.25 2.25 0 0 0 4.244 0H19zm0 7.5h-2.128a2.251 2.251 0 0 0-4.244 0H5v1.5h7.628a2.251 2.251 0 0 0 4.244 0H19z"/></svg>`;
const LOGOUT_ICON = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m16 17l5-5l-5-5m5 5H9m0 9H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/></svg>`;
const PLUS_ICON = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>`;
const IC_BOOK = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="3" width="14" height="18" rx="2"/><path d="M4 8h-.5M4 12h-.5M4 16h-.5"/><circle cx="11" cy="10" r="2"/><path d="M8 16c.5-1.5 1.7-2 3-2s2.5.5 3 2"/></svg>`;
const IC_STAR = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l2.6 5.3 5.9.9-4.3 4.1 1 5.8L12 16.9 6.8 19.6l1-5.8L3.5 9.7l5.9-.9z"/></svg>`;
const IC_CLOCK = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>`;
const IC_GROUP = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3"/><path d="M3 20c0-3 3-5 6-5s6 2 6 5"/><path d="M16 6a3 3 0 0 1 0 5m1 8c0-2-1-3.5-2.5-4.3"/></svg>`;
const IC_PERSON = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 4-6 8-6s8 2 8 6"/></svg>`;
const IC_MAIL = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></svg>`;
const IC_PHONE = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h4l2 5-3 2a12 12 0 0 0 6 6l2-3 5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 2 6a2 2 0 0 1 2-2"/></svg>`;
const IC_CHAT = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H8l-4 4V5a2 2 0 0 1 2-2h13a2 2 0 0 1 2 2z"/></svg>`;
const IC_COMPOSE = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/></svg>`;
const IC_CAL = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>`;
const IC_PENCIL = IC_COMPOSE;
const IC_SORT = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M7 4v16m0 0-3-3m3 3 3-3M17 20V4m0 0-3 3m3-3 3 3"/></svg>`;
const IC_MORE = `<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg>`;
const CHEV = `<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>`;
const themeGlyph = () => (document.documentElement.classList.contains("dark") ? "☀" : "☾");
const AVATAR_COLORS = ["#ef4444","#f97316","#eab308","#22c55e","#14b8a6","#3b82f6","#8b5cf6","#ec4899","#6b7280"];
function avatarColor(key) { let h = 0; for (const c of String(key || "?")) h = (h * 31 + c.charCodeAt(0)) >>> 0; return AVATAR_COLORS[h % AVATAR_COLORS.length]; }
function initials(name) {
const parts = String(name || "").trim().split(/\s+/).filter(Boolean);
if (!parts.length) return "?";
return ((parts[0][0] || "") + (parts.length > 1 ? parts[parts.length - 1][0] : "")).toUpperCase();
}
function avatarHtml(name, email, size) {
const s = size || 40;
return `<span class="ct-av" style="width:${s}px;height:${s}px;background:${avatarColor(email || name)};font-size:${Math.round(s * 0.38)}px">${esc(initials(name || email))}</span>`;
}
function shortDate(iso) {
const d = new Date(iso);
if (isNaN(d)) return "";
const now = new Date();
const opts = d.getFullYear() === now.getFullYear() ? { month: "short", day: "numeric" } : { year: "numeric", month: "short", day: "numeric" };
return d.toLocaleDateString("en-GB", opts);
}
const CSS = `
.ct-app{display:flex;height:100vh;background:rgb(var(--app-bg));color:rgb(var(--app-fg));font-family:"Lato",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;font-size:14px}
.ct-app .wm-side-item.active{background:rgb(var(--app-active));color:rgb(var(--app-fg));font-weight:600}
.ct-app .wm-side-item .ct-dico{margin-top:0}
/* collapsed icon-only rail (matches Mail's 60px rail) */
.ct-rail{width:60px;align-items:center}
.ct-railtop{display:flex;flex-direction:column;align-items:center;gap:4px;padding:12px 0 6px}
.ct-railprimary{padding:2px 0 8px}
.ct-railnav{flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column;align-items:center;gap:4px;padding:2px 0}
.ct-railfoot{width:100%;display:flex;flex-direction:column;align-items:center;gap:4px;padding:8px 0;border-top:1px solid rgb(var(--app-border))}
.ct-railbtn{display:flex;align-items:center;justify-content:center;width:38px;height:38px;border-radius:9px;color:rgb(var(--app-muted));cursor:pointer;background:none;border:0}
.ct-railbtn:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
.ct-railbtn.active{background:rgb(var(--app-active));color:rgb(var(--app-fg))}
.ct-railnew{display:flex;align-items:center;justify-content:center;width:40px;height:40px;border-radius:11px;background:rgb(var(--app-accent));color:rgb(var(--app-accent-fg));cursor:pointer;border:0}
.ct-railnew:hover{opacity:.9}
.ct-av{display:inline-flex;align-items:center;justify-content:center;border-radius:50%;color:#fff;font-weight:600;flex:0 0 auto;overflow:hidden}
.ct-listcol{flex:0 0 46%;min-width:0;display:flex;flex-direction:column;border-right:1px solid rgb(var(--app-border))}
.ct-detailcol{flex:1;min-width:0;display:flex;flex-direction:column}
.ct-bar{display:flex;align-items:center;gap:6px;height:49px;padding:0 14px;border-bottom:1px solid rgb(var(--app-border));flex:0 0 auto}
.ct-bar .sp{flex:1}
.ct-iconbtn{display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:50%;color:rgb(var(--app-muted));cursor:pointer}
.ct-iconbtn:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
.ct-barbtn{display:inline-flex;align-items:center;gap:6px;padding:5px 10px;border-radius:6px;color:rgb(var(--app-fg));cursor:pointer;font-size:13px}
.ct-barbtn:hover{background:rgb(var(--app-hover))}
.ct-cbx{width:16px;height:16px;accent-color:rgb(var(--app-accent))}
.ct-list{flex:1;min-height:0;overflow:auto}
.ct-alpha{position:sticky;top:0;background:rgb(var(--app-bg));color:rgb(var(--app-muted));font-size:12px;font-weight:700;padding:6px 18px 2px;z-index:1}
.ct-row{display:flex;align-items:center;gap:12px;padding:9px 18px;cursor:pointer;border-bottom:1px solid rgb(var(--app-border) / .5)}
.ct-row:hover{background:rgb(var(--app-hover))}
.ct-row.sel{background:rgb(var(--app-active))}
.ct-rowmain{min-width:0;flex:1}
.ct-rowname{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ct-rowmail{color:rgb(var(--app-muted));font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ct-empty{padding:60px 20px;text-align:center;color:rgb(var(--app-muted))}
.ct-detail{flex:1;min-height:0;overflow:auto;padding:20px}
.ct-card{max-width:760px;margin:0 auto;border:1px solid rgb(var(--app-border));border-radius:12px;padding:22px 26px}
.ct-cardhead{display:flex;gap:20px;align-items:flex-start;position:relative}
.ct-headinfo{flex:1;min-width:0}
.ct-name{font-size:22px;font-weight:700;margin:6px 0 12px}
.ct-star{position:absolute;top:0;right:0;color:rgb(var(--app-muted));cursor:pointer}
.ct-star.on{color:#eab308}
.ct-actions{display:flex;gap:8px;flex-wrap:wrap}
.ct-act{display:inline-flex;align-items:center;gap:7px;padding:7px 12px;border:1px solid rgb(var(--app-border));border-radius:7px;color:rgb(var(--app-fg));cursor:pointer;font-size:13px;background:rgb(var(--app-surface))}
.ct-act:hover{background:rgb(var(--app-hover))}
.ct-sec{margin-top:26px}
.ct-sectitle{font-size:15px;font-weight:700;margin-bottom:6px}
.ct-drow{display:flex;gap:14px;align-items:flex-start;padding:12px 2px;border-top:1px solid rgb(var(--app-border))}
.ct-dico{color:rgb(var(--app-muted));margin-top:2px}
.ct-dlabel{color:rgb(var(--app-muted));font-size:12px}
.ct-dval{margin-top:1px}
.ct-mailrow{display:flex;gap:12px;padding:12px 2px;border-top:1px solid rgb(var(--app-border))}
.ct-mailmain{min-width:0;flex:1}
.ct-mailsub{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ct-mailsnip{color:rgb(var(--app-muted));font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ct-maildate{color:rgb(var(--app-muted));font-size:12px;flex:0 0 auto}
.ct-findbtn{margin-top:14px;display:inline-flex;align-items:center;gap:8px;padding:7px 12px;border-radius:7px;background:rgb(var(--app-hover));cursor:pointer;font-size:13px}
/* --- edit form --- */
.ct-form{max-width:640px;margin:0 auto;padding:6px 0 40px}
.ct-formav{text-align:center;margin-bottom:6px}
.ct-formav .ct-av{margin:0 auto}
.ct-formav .cap{color:rgb(var(--app-muted));font-size:12px;margin-top:6px}
.ct-frow{display:flex;gap:10px;margin-bottom:12px}
.ct-fsec{font-weight:700;margin:22px 0 10px}
.ct-flabel{width:90px;flex:0 0 auto;color:rgb(var(--app-muted));font-size:13px;padding-top:9px}
.ct-in,.ct-sel{width:100%;background:rgb(var(--app-surface));border:1px solid rgb(var(--app-border));border-radius:6px;padding:8px 10px;color:rgb(var(--app-fg));font-size:14px;outline:none}
.ct-in:focus,.ct-sel:focus{border-color:rgb(var(--app-accent))}
.ct-multi{display:flex;gap:8px;align-items:center;margin-bottom:8px}
.ct-multi .ct-sel{width:130px;flex:0 0 auto}
.ct-rm{color:rgb(var(--app-muted));cursor:pointer;padding:4px;flex:0 0 auto}
.ct-rm:hover{color:rgb(var(--app-fg))}
.ct-addline{color:rgb(var(--app-accent));cursor:pointer;font-size:13px;display:inline-flex;align-items:center;gap:6px;margin-top:2px}
.ct-menu{position:absolute;z-index:20;background:rgb(var(--app-surface));border:1px solid rgb(var(--app-border));border-radius:8px;box-shadow:0 8px 24px rgb(0 0 0 / .3);padding:6px;min-width:180px}
.ct-menu button,.ct-menu label{display:flex;align-items:center;gap:8px;width:100%;text-align:left;padding:7px 9px;border-radius:6px;cursor:pointer;font-size:13px;color:rgb(var(--app-fg))}
.ct-menu button:hover,.ct-menu label:hover{background:rgb(var(--app-hover))}
.ct-primary{background:rgb(var(--app-accent));color:rgb(var(--app-accent-fg));padding:6px 16px;border-radius:6px;font-weight:600;font-size:13px;cursor:pointer}
.ct-primary:hover{opacity:.9}
.ct-ghostbtn{padding:6px 14px;border-radius:6px;cursor:pointer;font-size:13px}
.ct-ghostbtn:hover{background:rgb(var(--app-hover))}
`;
export default function mount(el, ctx) {
if (!document.getElementById("ct-styles")) {
const style = document.createElement("style");
style.id = "ct-styles";
style.textContent = CSS;
document.head.appendChild(style);
}
const state = {
filter: "all", group: "", groupLabel: "All contacts",
q: "",
contacts: [], groups: [],
selectedId: null, detail: null,
mode: "view", // view | edit | new
mail: null, // { loading, threads }
sortAsc: true,
menu: null, // "groups" | "more" | null
leftW: Number(localStorage.getItem("ct.leftW")) || 240,
leftCollapsed: localStorage.getItem("ct.leftCollapsed") === "1",
menuOpen: false,
me: "", quota: "",
apps: [{ label: "Mail", to: "/mail" }, { label: "Contacts", to: null }],
};
const api = ctx.api;
// ---- data loading --------------------------------------------------------
async function loadGroups() {
try { state.groups = await api.pluginFetch("/groups"); } catch { state.groups = []; }
}
async function loadContacts() {
const qs = `filter=${encodeURIComponent(state.filter)}&group=${encodeURIComponent(state.group)}&q=${encodeURIComponent(state.q)}`;
const res = await api.pluginFetch(`/contacts?${qs}`);
state.contacts = res.contacts || [];
if (!state.contacts.find((c) => c.id === state.selectedId)) {
state.selectedId = state.contacts[0] ? state.contacts[0].id : null;
}
}
async function loadDetail(id) {
if (!id) { state.detail = null; state.mail = null; return; }
state.detail = await api.pluginFetch(`/contacts/${id}`);
loadMail(state.detail.default_email);
}
async function loadMail(email) {
state.mail = { loading: true, threads: [] };
if (!email) { state.mail = { loading: false, threads: [] }; render(); return; }
try {
const res = await api.fetch(`/search?q=${encodeURIComponent('"' + email + '"')}&limit=5`);
state.mail = { loading: false, threads: (res.threads || []).map((h) => h.thread) };
} catch { state.mail = { loading: false, threads: [] }; }
render();
}
async function selectContact(id) {
state.selectedId = id;
state.mode = "view";
state.menu = null;
await loadDetail(id);
render();
}
async function reloadList(keepSelection) {
if (!keepSelection) state.selectedId = state.selectedId;
await loadContacts();
await loadDetail(state.selectedId);
render();
}
// ---- sidebar (shared .wm-side-* template; identical to Mail/Calendar) -----
function navItem(icon, label, active, act) {
return `<button class="wm-side-item${active ? " active" : ""}" data-act="${act}"><span class="ct-dico">${icon}</span>${esc(label)}</button>`;
}
function leftPanel() {
const f = state.filter;
const grp = (g) => `<button class="wm-side-item${state.group === g.id ? " active" : ""}" data-group="${g.id}" data-groupname="${esc(g.name)}"><span class="ct-dico">${IC_GROUP}</span>${esc(g.name)}</button>`;
return `
<div class="wm-side-head">
<div class="wm-side-switch">
<button data-act="menu">Contacts ${CHEV}</button>
${state.menuOpen ? `<div class="wm-side-menu">${state.apps.map((a, i) => `<button data-app="${i}">${esc(a.label)}</button>`).join("")}</div>` : ""}
</div>
<div class="wm-side-headicons">
<button class="wm-side-iconbtn" data-act="left-toggle" title="Collapse sidebar">${HIDE_ICON}</button>
<button class="wm-side-iconbtn wm-side-themebtn" data-act="theme" title="Toggle theme">${themeGlyph()}</button>
</div>
</div>
<div class="wm-side-primary">
<button data-act="new">${PLUS_ICON}New contact</button>
</div>
<div class="wm-side-body">
<div class="wm-side-nav">
${navItem(IC_BOOK, "All contacts", !state.group && f === "all", "filter-all")}
${navItem(IC_STAR, "VIPs", !state.group && f === "vip", "filter-vip")}
${navItem(IC_CLOCK, "Recent", !state.group && f === "recent", "filter-recent")}
</div>
<div class="wm-side-section">Groups</div>
<div class="wm-side-nav">
${state.groups.map(grp).join("")}
${navItem(IC_PERSON, "No group", f === "nogroup", "filter-nogroup")}
</div>
</div>
<div class="wm-side-foot">
<button data-act="settings">${SETTINGS_ICON}Settings</button>
<button data-act="signout">${LOGOUT_ICON}Sign out</button>
${state.quota ? `<div class="wm-side-quota">${esc(state.quota)}</div>` : ""}
</div>`;
}
// Collapsed sidebar: a narrow icon-only rail (like Mail) that keeps the expand
// toggle, so it can always be re-opened.
function railPanel() {
const nav = (icon, act, on, title) => `<button class="ct-railbtn${on ? " active" : ""}" data-act="${act}" title="${title}">${icon}</button>`;
return `<div class="wm-side ct-rail" style="flex:0 0 auto">
<div class="ct-railtop">
<button class="ct-railbtn" data-act="left-toggle" title="Expand sidebar">${SHOW_ICON}</button>
<button class="ct-railbtn" data-act="theme" title="Toggle theme">${themeGlyph()}</button>
</div>
<div class="ct-railprimary"><button class="ct-railnew" data-act="new" title="New contact">${PLUS_ICON}</button></div>
<div class="ct-railnav">
${nav(IC_BOOK, "filter-all", !state.group && state.filter === "all", "All contacts")}
${nav(IC_STAR, "filter-vip", !state.group && state.filter === "vip", "VIPs")}
${nav(IC_CLOCK, "filter-recent", !state.group && state.filter === "recent", "Recent")}
</div>
<div class="ct-railfoot">
<button class="ct-railbtn" data-act="settings" title="Settings">${SETTINGS_ICON}</button>
<button class="ct-railbtn" data-act="signout" title="Sign out">${LOGOUT_ICON}</button>
</div>
</div>`;
}
// ---- middle: contact list ------------------------------------------------
function listPanel() {
const rows = [...state.contacts].sort((a, b) => {
const r = (a.display_name || a.primary_email || "").localeCompare(b.display_name || b.primary_email || "", undefined, { sensitivity: "base" });
return state.sortAsc ? r : -r;
});
let html = "";
let letter = null;
for (const c of rows) {
const name = c.display_name || c.primary_email || "(no name)";
const ch = (name[0] || "#").toUpperCase();
const key = /[A-Z]/.test(ch) ? ch : "#";
if (key !== letter) { letter = key; html += `<div class="ct-alpha">${esc(letter)}</div>`; }
html += `<div class="ct-row${c.id === state.selectedId ? " sel" : ""}" data-contact="${c.id}">
${avatarHtml(c.display_name, c.primary_email, 38)}
<div class="ct-rowmain">
<div class="ct-rowname">${esc(name)}</div>
<div class="ct-rowmail">${esc(c.primary_email || "")}</div>
</div></div>`;
}
if (!rows.length) html = `<div class="ct-empty">No contacts${state.q ? " match your search" : " yet"}.</div>`;
return `
<div class="ct-bar">
<input type="checkbox" class="ct-cbx" disabled title="Select all"/>
<span class="sp"></span>
<button class="ct-iconbtn" data-act="sort" title="Sort">${IC_SORT}</button>
<button class="ct-iconbtn" data-act="more-list" title="More">${IC_MORE}</button>
</div>
<div class="ct-list">${html}</div>`;
}
// ---- right: detail / edit ------------------------------------------------
function detailBar() {
if (state.mode === "edit" || state.mode === "new") {
return `<div class="ct-bar"><button class="ct-primary" data-act="save">Save</button><button class="ct-ghostbtn" data-act="cancel">Cancel</button></div>`;
}
const disabled = state.detail ? "" : "opacity:.4;pointer-events:none";
return `<div class="ct-bar" style="${disabled}">
<button class="ct-barbtn" data-act="edit">${IC_PENCIL} Edit</button>
<div style="position:relative"><button class="ct-barbtn" data-act="menu-groups">${IC_GROUP} Groups ${CHEV}</button>${state.menu === "groups" ? groupsMenu() : ""}</div>
<div style="position:relative"><button class="ct-barbtn" data-act="menu-more">More ${CHEV}</button>${state.menu === "more" ? moreMenu() : ""}</div>
</div>`;
}
function groupsMenu() {
const mine = new Set((state.detail.groups || []).map((g) => g.id));
return `<div class="ct-menu" data-stop>${state.groups.map((g) => `<label><input type="checkbox" data-toggle-group="${g.id}" ${mine.has(g.id) ? "checked" : ""}/> ${esc(g.name)}</label>`).join("") || `<div class="ct-dlabel" style="padding:6px 9px">No groups yet</div>`}</div>`;
}
function moreMenu() {
return `<div class="ct-menu" data-stop><button data-act="delete">Delete contact</button></div>`;
}
function detailPanel() {
if (state.mode === "edit" || state.mode === "new") return editForm();
const c = state.detail;
if (!c) return `<div class="ct-detail"><div class="ct-empty">Select a contact.</div></div>`;
const groups = (c.groups || []).map((g) => esc(g.name)).join(", ");
const emailRows = (c.emails && c.emails.length ? c.emails : (c.default_email ? [{ value: c.default_email, type: "personal" }] : []));
const emailsHtml = emailRows.map((e) => `<div class="ct-drow"><span class="ct-dico">${IC_MAIL}</span><div><div class="ct-dlabel">${esc(cap(e.type || "email"))}</div><div class="ct-dval"><a href="mailto:${esc(e.value)}">${esc(e.value)}</a></div></div></div>`).join("");
return `<div class="ct-detail"><div class="ct-card">
<div class="ct-cardhead">
${avatarHtml(c.display_name, c.default_email, 84)}
<div class="ct-headinfo">
<div class="ct-name">${esc(c.display_name || c.default_email || "(no name)")}</div>
<div class="ct-actions">
<button class="ct-act" data-act="call">${IC_PHONE} Call</button>
<button class="ct-act" data-act="message">${IC_CHAT} Message</button>
<button class="ct-act" data-act="compose">${IC_COMPOSE} Compose</button>
<button class="ct-act" data-act="invite">${IC_CAL} Invite</button>
</div>
</div>
<button class="ct-star${c.starred ? " on" : ""}" data-act="star" title="VIP">${IC_STAR}</button>
</div>
<div class="ct-sec">
<div class="ct-sectitle">Contact details</div>
${groups ? `<div class="ct-drow"><span class="ct-dico">${IC_GROUP}</span><div><div class="ct-dlabel">Groups</div><div class="ct-dval">${groups}</div></div></div>` : ""}
${emailsHtml}
<div class="ct-drow"><span class="ct-dico">${IC_PENCIL}</span><div>${c.notes ? `<div class="ct-dlabel">Notes</div><div class="ct-dval" style="white-space:pre-wrap">${esc(c.notes)}</div>` : `<span class="ct-dlabel">Add notes</span>`}</div></div>
</div>
<div class="ct-sec">
<div class="ct-sectitle">Mail</div>
${mailSection(c)}
</div>
<div class="ct-sec">
<div class="ct-sectitle">Events</div>
<div class="ct-dlabel" style="padding:12px 2px;border-top:1px solid rgb(var(--app-border))">No matches found</div>
</div>
</div></div>`;
}
function mailSection(c) {
if (!state.mail || state.mail.loading) return `<div class="ct-dlabel" style="padding:12px 2px;border-top:1px solid rgb(var(--app-border))">Loading…</div>`;
if (!state.mail.threads.length) return `<div class="ct-dlabel" style="padding:12px 2px;border-top:1px solid rgb(var(--app-border))">No matches found</div>`;
const rows = state.mail.threads.map((t) => {
const who = (t.participants && t.participants[0]) ? (t.participants[0].name || t.participants[0].email) : (c.display_name || "");
return `<div class="ct-mailrow">${avatarHtml(who, (t.participants && t.participants[0] && t.participants[0].email) || c.default_email, 32)}
<div class="ct-mailmain"><div class="ct-mailsub">${esc(t.subject || "(no subject)")}</div><div class="ct-mailsnip">${esc(t.snippet || "")}</div></div>
<div class="ct-maildate">${esc(shortDate(t.last_message_at))}</div></div>`;
}).join("");
return rows + `<button class="ct-findbtn" data-act="find-mail">${IC_MAIL} Find all conversations with ${esc((c.display_name || "").split(" ")[0] || "this contact")}</button>`;
}
// ---- edit form -----------------------------------------------------------
function typeSelect(kind, current) {
const opts = kind === "email" ? EMAIL_TYPES : kind === "phone" ? PHONE_TYPES : ADDR_TYPES;
return `<select class="ct-sel" data-type>${opts.map((o) => `<option value="${o}"${o === current ? " selected" : ""}>${esc(cap(o))}</option>`).join("")}</select>`;
}
function emailRow(e) {
return `<div class="ct-multi" data-emailrow>${typeSelect("email", (e && e.type) || "personal")}<input class="ct-in" data-val placeholder="email@example.com" value="${esc(e ? e.value : "")}"/><span class="ct-rm" data-act="rm-email">✕</span></div>`;
}
function phoneRow(p) {
return `<div class="ct-multi" data-phonerow>${typeSelect("phone", (p && p.type) || "mobile")}<input class="ct-in" data-val placeholder="0123 456 789" value="${esc(p ? p.value : "")}"/><span class="ct-rm" data-act="rm-phone">✕</span></div>`;
}
function editForm() {
const c = state.detail && state.mode === "edit" ? state.detail : {};
const np = c.name_parts || {};
const emails = (c.emails && c.emails.length ? c.emails : [null]);
const phones = (c.phones && c.phones.length ? c.phones : [null]);
const url0 = (c.urls && c.urls[0]) || {};
const ad0 = (c.addresses && c.addresses[0]) || {};
const bday = c.birthday ? new Date(c.birthday) : null;
const monthSel = `<select class="ct-sel" data-f=bmonth><option value="">Month</option>${MONTHS.map((m, i) => `<option value="${i + 1}"${bday && bday.getMonth() === i ? " selected" : ""}>${m}</option>`).join("")}</select>`;
return `<div class="ct-detail"><div class="ct-form">
<div class="ct-formav">${avatarHtml(c.display_name, c.default_email, 88)}<div class="cap">Photo from initials</div></div>
<div class="ct-frow">
<input class="ct-in" data-f=prefix placeholder="Title" style="flex:0 0 110px" value="${esc(np.prefix || "")}"/>
<input class="ct-in" data-f=given placeholder="First name" value="${esc(np.given || "")}"/>
<input class="ct-in" data-f=family placeholder="Last name" value="${esc(np.family || "")}"/>
</div>
<div class="ct-frow">
<input class="ct-in" data-f=title placeholder="Job title" value="${esc(c.title || "")}"/>
<input class="ct-in" data-f=department placeholder="Department" value="${esc(c.department || "")}"/>
<input class="ct-in" data-f=org placeholder="Company" value="${esc(c.org || "")}"/>
</div>
<div class="ct-fsec">Personal</div>
<div class="ct-frow"><div class="ct-flabel">Nickname</div><input class="ct-in" data-f=nickname value="${esc(c.nickname || "")}"/></div>
<div class="ct-frow"><div class="ct-flabel">Birthday</div>${monthSel}
<input class="ct-in" data-f=bday placeholder="Day" style="flex:0 0 90px" value="${bday ? bday.getDate() : ""}"/>
<input class="ct-in" data-f=byear placeholder="Year" style="flex:0 0 110px" value="${bday ? bday.getFullYear() : ""}"/></div>
<div class="ct-fsec">Email</div>
<div data-emails>${emails.map(emailRow).join("")}</div>
<span class="ct-addline" data-act="add-email">${PLUS_ICON} Add email</span>
<div class="ct-fsec">Phone</div>
<div data-phones>${phones.map(phoneRow).join("")}</div>
<span class="ct-addline" data-act="add-phone">${PLUS_ICON} Add phone</span>
<div class="ct-fsec">Online</div>
<div class="ct-multi"><select class="ct-sel" data-f=urltype><option>Website</option></select><input class="ct-in" data-f=url placeholder="www.example.com" value="${esc(url0.value || "")}"/></div>
<div class="ct-fsec">Address</div>
<div class="ct-frow">${typeSelectAddr(ad0.type || "home")}<input class="ct-in" data-f=country placeholder="Country" value="${esc(ad0.country || "")}"/></div>
<div class="ct-frow"><input class="ct-in" data-f=street placeholder="Street" value="${esc(ad0.street || "")}"/></div>
<div class="ct-frow"><input class="ct-in" data-f=postal placeholder="Postal code" style="flex:0 0 160px" value="${esc(ad0.postal_code || "")}"/><input class="ct-in" data-f=city placeholder="City" value="${esc(ad0.city || "")}"/></div>
<div class="ct-fsec">Notes</div>
<textarea class="ct-in" data-f=notes rows="3">${esc(c.notes || "")}</textarea>
</div></div>`;
}
function typeSelectAddr(current) {
return `<select class="ct-sel" data-f=addrtype style="flex:0 0 130px">${ADDR_TYPES.map((o) => `<option value="${o}"${o === current ? " selected" : ""}>${esc(cap(o))}</option>`).join("")}</select>`;
}
function collectForm() {
const v = (sel) => { const n = el.querySelector(sel); return n ? n.value.trim() : ""; };
const emails = [...el.querySelectorAll("[data-emailrow]")].map((r) => ({ value: r.querySelector("[data-val]").value.trim(), type: r.querySelector("[data-type]").value })).filter((e) => e.value);
const phones = [...el.querySelectorAll("[data-phonerow]")].map((r) => ({ value: r.querySelector("[data-val]").value.trim(), type: r.querySelector("[data-type]").value })).filter((p) => p.value);
const given = v("[data-f=given]"), family = v("[data-f=family]"), prefix = v("[data-f=prefix]");
const display = [prefix, given, family].filter(Boolean).join(" ") || (emails[0] ? emails[0].value : "");
const month = v("[data-f=bmonth]"), day = v("[data-f=bday]"), year = v("[data-f=byear]");
let birthday = null;
if (month && day && year) birthday = `${year.padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
const url = v("[data-f=url]");
const addr = { type: v("[data-f=addrtype]") || "home", country: v("[data-f=country]"), street: v("[data-f=street]"), postal_code: v("[data-f=postal]"), city: v("[data-f=city]") };
const hasAddr = addr.country || addr.street || addr.postal_code || addr.city;
return {
display_name: display,
name_parts: { prefix, given, family, middle: "", suffix: "" },
nickname: v("[data-f=nickname]"),
title: v("[data-f=title]"), department: v("[data-f=department]"), org: v("[data-f=org]"),
birthday,
emails, default_email: emails[0] ? emails[0].value : "",
phones,
urls: url ? [{ value: url, type: "website" }] : [],
addresses: hasAddr ? [addr] : [],
notes: v("[data-f=notes]"),
starred: state.detail ? !!state.detail.starred : false,
group_ids: state.detail ? (state.detail.groups || []).map((g) => g.id) : [],
};
}
async function saveForm() {
const body = collectForm();
if (state.mode === "new") {
const res = await api.pluginFetch("/contacts", { method: "POST", body: JSON.stringify(body) });
state.selectedId = res.id;
} else {
await api.pluginFetch(`/contacts/${state.selectedId}`, { method: "PUT", body: JSON.stringify(body) });
}
state.mode = "view";
await loadGroups();
await reloadList(true);
}
// ---- render + wire -------------------------------------------------------
function render() {
el.innerHTML = `<div class="ct-app">
${state.leftCollapsed ? railPanel() : `<div class="wm-side ct-left" style="width:${state.leftW}px;flex:0 0 auto">${leftPanel()}</div>`}
<div class="ct-listcol">${listPanel()}</div>
<div class="ct-detailcol">${detailBar()}${detailPanel()}</div>
</div>`;
wire();
}
function wire() {
const on = (sel, ev, fn) => el.querySelectorAll(sel).forEach((n) => n.addEventListener(ev, fn));
const act = (name, fn) => on(`[data-act="${name}"]`, "click", fn);
const setFilter = (f) => { state.filter = f; state.group = ""; state.menuOpen = false; reloadList(false); };
on("[data-app]", "click", (e) => { const a = state.apps[Number(e.currentTarget.getAttribute("data-app"))]; state.menuOpen = false; if (a.to) ctx.navigate(a.to); else render(); });
act("menu", () => { state.menuOpen = !state.menuOpen; render(); });
act("left-toggle", () => { state.leftCollapsed = !state.leftCollapsed; try { localStorage.setItem("ct.leftCollapsed", state.leftCollapsed ? "1" : "0"); } catch { /* ignore */ } render(); });
act("theme", () => { const dark = document.documentElement.classList.toggle("dark"); try { localStorage.setItem("theme", dark ? "dark" : "light"); } catch { /* ignore */ } render(); });
act("settings", () => ctx.navigate("/settings?from=%2Fp%2Fcontacts"));
act("signout", async () => { await api.fetch("/auth/logout", { method: "POST" }); ctx.navigate("/login"); });
act("filter-all", () => setFilter("all"));
act("filter-vip", () => setFilter("vip"));
act("filter-recent", () => setFilter("recent"));
act("filter-nogroup", () => setFilter("nogroup"));
on("[data-group]", "click", (e) => {
state.group = e.currentTarget.getAttribute("data-group");
state.groupLabel = e.currentTarget.getAttribute("data-groupname");
state.filter = "group"; state.menuOpen = false; reloadList(false);
});
on("[data-contact]", "click", (e) => selectContact(e.currentTarget.getAttribute("data-contact")));
act("sort", () => { state.sortAsc = !state.sortAsc; render(); });
act("new", () => { state.mode = "new"; state.selectedId = null; state.detail = null; render(); });
// detail actions
act("edit", () => { state.mode = "edit"; state.menu = null; render(); });
act("cancel", () => { state.mode = "view"; render(); });
act("save", () => saveForm());
act("star", async () => { const r = await api.pluginFetch(`/contacts/${state.selectedId}/star`, { method: "POST" }); state.detail.starred = r.starred; render(); });
act("call", () => { const p = state.detail.phones && state.detail.phones[0]; if (p) window.open(`tel:${p.value}`); });
act("message", () => { if (state.detail.default_email) window.location.href = `mailto:${state.detail.default_email}`; });
act("compose", () => { if (state.detail.default_email) window.location.href = `mailto:${state.detail.default_email}`; });
act("invite", () => ctx.navigate("/p/calendar"));
act("find-mail", () => ctx.navigate(`/mail/search/${encodeURIComponent('"' + state.detail.default_email + '"')}`));
// menus
act("menu-groups", (e) => { e.stopPropagation(); state.menu = state.menu === "groups" ? null : "groups"; render(); });
act("menu-more", (e) => { e.stopPropagation(); state.menu = state.menu === "more" ? null : "more"; render(); });
on("[data-stop]", "click", (e) => e.stopPropagation());
on("[data-toggle-group]", "change", async (e) => {
const gid = e.currentTarget.getAttribute("data-toggle-group");
const cur = new Set((state.detail.groups || []).map((g) => g.id));
if (e.currentTarget.checked) cur.add(gid); else cur.delete(gid);
const body = detailToBody(state.detail, [...cur]);
await api.pluginFetch(`/contacts/${state.selectedId}`, { method: "PUT", body: JSON.stringify(body) });
await loadGroups(); await loadDetail(state.selectedId); render();
});
act("delete", async () => {
state.menu = null;
await api.pluginFetch(`/contacts/${state.selectedId}`, { method: "DELETE" });
state.selectedId = null;
await reloadList(false);
});
// edit form multi-rows
act("add-email", () => { const box = el.querySelector("[data-emails]"); const w = document.createElement("div"); w.innerHTML = emailRow(null); const row = w.firstElementChild; row.querySelector("[data-act=rm-email]").addEventListener("click", () => row.remove()); box.appendChild(row); });
act("add-phone", () => { const box = el.querySelector("[data-phones]"); const w = document.createElement("div"); w.innerHTML = phoneRow(null); const row = w.firstElementChild; row.querySelector("[data-act=rm-phone]").addEventListener("click", () => row.remove()); box.appendChild(row); });
on("[data-act=rm-email]", "click", (e) => e.currentTarget.closest("[data-emailrow]").remove());
on("[data-act=rm-phone]", "click", (e) => e.currentTarget.closest("[data-phonerow]").remove());
// close popover menus on outside click
if (state.menu || state.menuOpen) {
setTimeout(() => {
const close = () => { state.menu = null; state.menuOpen = false; document.removeEventListener("click", close); render(); };
document.addEventListener("click", close);
}, 0);
}
}
// Map a loaded detail contact back into a ContactIn body (for group toggles).
function detailToBody(c, groupIds) {
return {
display_name: c.display_name, name_parts: c.name_parts || {}, nickname: c.nickname || "",
title: c.title || "", department: c.department || "", org: c.org || "",
birthday: c.birthday || null, emails: c.emails || [], default_email: c.default_email || "",
phones: c.phones || [], urls: c.urls || [], addresses: c.addresses || [],
notes: c.notes || "", starred: !!c.starred, group_ids: groupIds,
};
}
async function loadChrome() {
try {
const manifest = await api.fetch("/plugins/ui-manifest");
const pages = (manifest.plugins || []).flatMap((p) => p.pages.map((pg) => ({ label: pg.nav.label, to: pg.route === "/contacts" ? null : "/p" + pg.route })));
state.apps = [{ label: "Mail", to: "/mail" }, ...pages];
} catch { /* keep defaults */ }
try { const accounts = await api.fetch("/accounts"); if (accounts && accounts[0]) state.me = accounts[0].email; } catch { /* ignore */ }
try {
const q = await api.fetch("/settings/quota");
if (q && q.quota_bytes) {
const pct = (q.used_bytes / q.quota_bytes) * 100;
const gb = q.quota_bytes / 1024 ** 3;
state.quota = `${pct >= 10 ? Math.round(pct) : Math.round(pct * 10) / 10}% of ${Number.isInteger(gb) ? gb : gb.toFixed(1)} GB`;
}
} catch { /* ignore */ }
}
(async () => {
await loadChrome();
await loadGroups();
await loadContacts();
await loadDetail(state.selectedId);
render();
})();
return () => { el.innerHTML = ""; };
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB