diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js index 32e8cbe..719b6ad 100644 --- a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js +++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js @@ -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-secrow:hover .cal-secadd{opacity:1} .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::after{content:"";position:absolute;top:0;bottom:0;left:50%;width:1px;transform:translateX(-50%);background:transparent} .cal-resizer:hover::after{background:#3b82f6} @@ -813,7 +823,7 @@ export default function mount(el, ctx) { // ---- render ---------------------------------------------------------------- function render() { el.innerHTML = `
- ${state.leftCollapsed ? "" : `
${leftPanel()}
`} + ${state.leftCollapsed ? railPanel() : `
${leftPanel()}
`}
${topbar()}
@@ -858,7 +868,6 @@ export default function mount(el, ctx) { function topbar() { return `
- ${state.leftCollapsed ? `` : ""} `; } + // 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 = ``; + return `
+
+ + +
+
+
+ +
+
+ + +
+
`; + } + // Left panel = the shared sidebar-shell template (.wm-side-*, see plugin-ui.css): // switcher + collapse/theme, primary button, an app-specific scrollable body // (mini-month + calendars here), and the shared foot. Identical to Mail. diff --git a/plugins/webmail_plugin_contacts/pyproject.toml b/plugins/webmail_plugin_contacts/pyproject.toml new file mode 100644 index 0000000..a002f6e --- /dev/null +++ b/plugins/webmail_plugin_contacts/pyproject.toml @@ -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"] diff --git a/plugins/webmail_plugin_contacts/webmail_plugin_contacts/__init__.py b/plugins/webmail_plugin_contacts/webmail_plugin_contacts/__init__.py new file mode 100644 index 0000000..d01b707 --- /dev/null +++ b/plugins/webmail_plugin_contacts/webmail_plugin_contacts/__init__.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"), +) diff --git a/plugins/webmail_plugin_contacts/webmail_plugin_contacts/migrations/versions/0001_init.py b/plugins/webmail_plugin_contacts/webmail_plugin_contacts/migrations/versions/0001_init.py new file mode 100644 index 0000000..610c238 --- /dev/null +++ b/plugins/webmail_plugin_contacts/webmail_plugin_contacts/migrations/versions/0001_init.py @@ -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") diff --git a/plugins/webmail_plugin_contacts/webmail_plugin_contacts/router.py b/plugins/webmail_plugin_contacts/webmail_plugin_contacts/router.py new file mode 100644 index 0000000..c6f79a2 --- /dev/null +++ b/plugins/webmail_plugin_contacts/webmail_plugin_contacts/router.py @@ -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 diff --git a/plugins/webmail_plugin_contacts/webmail_plugin_contacts/schemas.py b/plugins/webmail_plugin_contacts/webmail_plugin_contacts/schemas.py new file mode 100644 index 0000000..609150a --- /dev/null +++ b/plugins/webmail_plugin_contacts/webmail_plugin_contacts/schemas.py @@ -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 diff --git a/plugins/webmail_plugin_contacts/webmail_plugin_contacts/ui/page.js b/plugins/webmail_plugin_contacts/webmail_plugin_contacts/ui/page.js new file mode 100644 index 0000000..9ad96e1 --- /dev/null +++ b/plugins/webmail_plugin_contacts/webmail_plugin_contacts/ui/page.js @@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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 = ``; +const SHOW_ICON = ``; +const SETTINGS_ICON = ``; +const LOGOUT_ICON = ``; +const PLUS_ICON = ``; +const IC_BOOK = ``; +const IC_STAR = ``; +const IC_CLOCK = ``; +const IC_GROUP = ``; +const IC_PERSON = ``; +const IC_MAIL = ``; +const IC_PHONE = ``; +const IC_CHAT = ``; +const IC_COMPOSE = ``; +const IC_CAL = ``; +const IC_PENCIL = IC_COMPOSE; +const IC_SORT = ``; +const IC_MORE = ``; +const CHEV = ``; +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 `${esc(initials(name || email))}`; +} +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 ``; + } + function leftPanel() { + const f = state.filter; + const grp = (g) => ``; + return ` +
+
+ + ${state.menuOpen ? `
${state.apps.map((a, i) => ``).join("")}
` : ""} +
+
+ + +
+
+
+ +
+
+
+ ${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")} +
+
Groups
+
+ ${state.groups.map(grp).join("")} + ${navItem(IC_PERSON, "No group", f === "nogroup", "filter-nogroup")} +
+
+
+ + + ${state.quota ? `
${esc(state.quota)}
` : ""} +
`; + } + + // 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) => ``; + return `
+
+ + +
+
+
+ ${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")} +
+
+ + +
+
`; + } + + // ---- 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 += `
${esc(letter)}
`; } + html += `
+ ${avatarHtml(c.display_name, c.primary_email, 38)} +
+
${esc(name)}
+
${esc(c.primary_email || "")}
+
`; + } + if (!rows.length) html = `
No contacts${state.q ? " match your search" : " yet"}.
`; + return ` +
+ + + + +
+
${html}
`; + } + + // ---- right: detail / edit ------------------------------------------------ + function detailBar() { + if (state.mode === "edit" || state.mode === "new") { + return `
`; + } + const disabled = state.detail ? "" : "opacity:.4;pointer-events:none"; + return `
+ +
${state.menu === "groups" ? groupsMenu() : ""}
+
${state.menu === "more" ? moreMenu() : ""}
+
`; + } + function groupsMenu() { + const mine = new Set((state.detail.groups || []).map((g) => g.id)); + return `
${state.groups.map((g) => ``).join("") || `
No groups yet
`}
`; + } + function moreMenu() { + return `
`; + } + + function detailPanel() { + if (state.mode === "edit" || state.mode === "new") return editForm(); + const c = state.detail; + if (!c) return `
Select a contact.
`; + 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) => `
${IC_MAIL}
${esc(cap(e.type || "email"))}
`).join(""); + return `
+
+ ${avatarHtml(c.display_name, c.default_email, 84)} +
+
${esc(c.display_name || c.default_email || "(no name)")}
+
+ + + + +
+
+ +
+ +
+
Contact details
+ ${groups ? `
${IC_GROUP}
Groups
${groups}
` : ""} + ${emailsHtml} +
${IC_PENCIL}
${c.notes ? `
Notes
${esc(c.notes)}
` : `Add notes`}
+
+ +
+
Mail
+ ${mailSection(c)} +
+ +
+
Events
+
No matches found
+
+
`; + } + + function mailSection(c) { + if (!state.mail || state.mail.loading) return `
Loading…
`; + if (!state.mail.threads.length) return `
No matches found
`; + 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 `
${avatarHtml(who, (t.participants && t.participants[0] && t.participants[0].email) || c.default_email, 32)} +
${esc(t.subject || "(no subject)")}
${esc(t.snippet || "")}
+
${esc(shortDate(t.last_message_at))}
`; + }).join(""); + return rows + ``; + } + + // ---- edit form ----------------------------------------------------------- + function typeSelect(kind, current) { + const opts = kind === "email" ? EMAIL_TYPES : kind === "phone" ? PHONE_TYPES : ADDR_TYPES; + return ``; + } + function emailRow(e) { + return `
${typeSelect("email", (e && e.type) || "personal")}✕
`; + } + function phoneRow(p) { + return `
${typeSelect("phone", (p && p.type) || "mobile")}✕
`; + } + 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 = ``; + return `
+
${avatarHtml(c.display_name, c.default_email, 88)}
Photo from initials
+
+ + + +
+
+ + + +
+ +
Personal
+
Nickname
+
Birthday
${monthSel} + +
+ +
Email
+
${emails.map(emailRow).join("")}
+ ${PLUS_ICON} Add email + +
Phone
+
${phones.map(phoneRow).join("")}
+ ${PLUS_ICON} Add phone + +
Online
+
+ +
Address
+
${typeSelectAddr(ad0.type || "home")}
+
+
+ +
Notes
+ +
`; + } + function typeSelectAddr(current) { + return ``; + } + + 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 = `
+ ${state.leftCollapsed ? railPanel() : `
${leftPanel()}
`} +
${listPanel()}
+
${detailBar()}${detailPanel()}
+
`; + 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 = ""; }; +} diff --git a/screenshots/contacts-2.png b/screenshots/contacts-2.png new file mode 100644 index 0000000..2d879d0 Binary files /dev/null and b/screenshots/contacts-2.png differ