/* RustBox UI module: 70-contacts.js */ async function openContacts() { $("#contacts-dialog").showModal(); await loadContactGroups(); await loadContacts(); } async function loadContactGroups() { try { const data = await request("/contact-groups", { method: "GET" }); state.contactGroups = data.groups || []; renderContactGroupFilter(); renderContactGroupMembership($("#contact-id").value || null, []); } catch (error) { toast(error.message, "error"); } } function renderContactGroupFilter() { const select = $("#contact-group-filter"); const current = select.value; select.replaceChildren(new Option("All contacts", "")); for (const group of state.contactGroups) select.add(new Option(`${group.name} (${group.member_count || 0})`, group.id)); if ([...select.options].some((option) => option.value === current)) select.value = current; const hasGroup = Boolean(select.value); $("#contact-group-rename").disabled = !hasGroup; $("#contact-group-delete").disabled = !hasGroup; } async function downloadContactExport() { try { const headers = new Headers(); if (state.accountId) headers.set("X-Active-Account", state.accountId); const response = await fetch(apiPath("/contacts/export"), { credentials: "include", headers }); if (!response.ok) throw new Error(`Export failed (${response.status})`); const blob = await response.blob(); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = "contacts.vcf"; document.body.append(link); link.click(); link.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); toast("Calendar exported", "success"); } catch (error) { toast(error.message, "error"); } } async function importContactVcard(files) { const file = files?.[0]; if (!file) return; if (file.size > 10 * 1024 * 1024) return toast("vCard file is too large", "error"); const form = new FormData(); form.append("file", file, file.name || "contacts.vcf"); try { const data = await request("/contacts/import", { method: "POST", body: form }); toast(`vCard import: ${data.created || 0} created, ${data.updated || 0} updated, ${data.skipped || 0} skipped`); await loadContacts($("#contact-search").value.trim()); } catch (error) { toast(error.message, "error"); } finally { $("#contact-import-file").value = ""; } } async function showContactDuplicates() { try { const data = await request("/contacts/duplicates", { method: "GET" }); const groups = data.duplicates || []; const list = $("#contact-duplicate-list"); list.replaceChildren(); if (!groups.length) { list.append(el("div", "empty-list", "No duplicate contacts found")); } for (const group of groups) { const card = el("section", "duplicate-contact-group"); card.append(el("strong", "", group.email)); const rows = el("div", "duplicate-contact-options"); for (const contact of group.contacts || []) { const row = el("div", "duplicate-contact-row"); const summary = el("div", "duplicate-contact-summary"); summary.append(el("strong", "", contact.name || contact.email)); summary.append(el("small", "muted", [contact.email, contact.company, contact.phone].filter(Boolean).join(" · "))); const keep = el("button", "button quiet", "Keep this"); keep.type = "button"; keep.addEventListener("click", async () => { const sources = (group.contacts || []).filter((item) => item.id !== contact.id); if (!sources.length) return; if (!await appConfirm(`Merge ${sources.length} duplicate contact(s) into ${contact.name || contact.email}?`, { title: "Merge duplicate contacts", confirmLabel: "Merge" })) return; try { let target = contact; for (const source of sources) { target = await request("/contacts/merge", { method: "POST", body: JSON.stringify({ target_id: target.id, source_id: source.id }) }); } toast("Contacts merged"); await loadContacts($("#contact-search").value.trim()); await showContactDuplicates(); editContact(target); } catch (error) { toast(error.message, "error"); } }); row.append(summary, keep); rows.append(row); } card.append(rows); list.append(card); } if (!$("#contact-duplicates-dialog").open) $("#contact-duplicates-dialog").showModal(); } catch (error) { toast(error.message, "error"); } } async function loadContacts(query = "") { try { const groupId = $("#contact-group-filter")?.value || ""; let contacts; if (groupId) { const data = await request(`/contact-groups/${encodeURIComponent(groupId)}/members`, { method: "GET" }); contacts = data.members || []; if (query) { const needle = query.toLowerCase(); contacts = contacts.filter((contact) => `${contact.name} ${contact.email} ${contact.company}`.toLowerCase().includes(needle)); } } else { const data = await request(`/contacts?limit=500&offset=0${query ? `&q=${encodeURIComponent(query)}` : ""}`, { method: "GET" }); contacts = data.contacts || []; } state.contacts = contacts; renderContacts(); } catch (error) { toast(error.message, "error"); } } async function createContactGroup() { const name = await appPrompt("New contact group", { placeholder: "Group name", confirmLabel: "Create", required: true }); if (!name?.trim()) return; try { await request("/contact-groups", { method: "POST", body: JSON.stringify({ name: name.trim() }) }); await loadContactGroups(); toast("Group created"); } catch (error) { toast(error.message, "error"); } } async function renameContactGroup() { const id = $("#contact-group-filter").value; const group = state.contactGroups.find((item) => item.id === id); if (!group) return; const name = await appPrompt("Rename contact group", { value: group.name, confirmLabel: "Rename", required: true }); if (!name?.trim() || name.trim() === group.name) return; try { await request(`/contact-groups/${encodeURIComponent(id)}`, { method: "PUT", body: JSON.stringify({ name: name.trim() }) }); await loadContactGroups(); toast("Group renamed"); } catch (error) { toast(error.message, "error"); } } async function deleteContactGroup() { const id = $("#contact-group-filter").value; const group = state.contactGroups.find((item) => item.id === id); if (!group || !await appConfirm(`Delete group “${group.name}”? Contacts will be kept.`, { title: "Delete contact group", confirmLabel: "Delete", danger: true })) return; try { await request(`/contact-groups/${encodeURIComponent(id)}`, { method: "DELETE" }); $("#contact-group-filter").value = ""; await loadContactGroups(); await loadContacts($("#contact-search").value.trim()); toast("Group deleted"); } catch (error) { toast(error.message, "error"); } } async function loadContactMembership(contactId) { if (!contactId) return renderContactGroupMembership(null, []); try { const data = await request(`/contacts/${encodeURIComponent(contactId)}/groups`, { method: "GET" }); renderContactGroupMembership(contactId, (data.groups || []).map((group) => group.id)); } catch (error) { toast(error.message, "error"); } } function renderContactGroupMembership(contactId, selectedIds) { const list = $("#contact-group-membership"); if (!list) return; const selected = new Set(selectedIds || []); list.replaceChildren(); if (!state.contactGroups.length) { list.append(el("span", "muted", "No groups")); return; } for (const group of state.contactGroups) { const label = el("label", "checkbox-row"); const input = document.createElement("input"); input.type = "checkbox"; input.checked = selected.has(group.id); input.disabled = !contactId; input.addEventListener("change", async () => { try { if (input.checked) await request(`/contact-groups/${encodeURIComponent(group.id)}/members`, { method: "POST", body: JSON.stringify({ contact_id: contactId }) }); else await request(`/contact-groups/${encodeURIComponent(group.id)}/members/${encodeURIComponent(contactId)}`, { method: "DELETE" }); await loadContactGroups(); } catch (error) { input.checked = !input.checked; toast(error.message, "error"); } }); label.append(input, el("span", "", group.name)); list.append(label); } } function renderContacts() { const list = $("#contact-list"); list.replaceChildren(); if (!state.contacts.length) list.append(el("div", "empty-list", "No contacts")); for (const contact of state.contacts) { const button = el("button", "contact-item"); button.type = "button"; button.innerHTML = `${escapeHtml(contact.name || contact.email)}${escapeHtml(contact.email)}${contact.company ? ` · ${escapeHtml(contact.company)}` : ""}${contact.phone ? ` · ${escapeHtml(contact.phone)}` : ""}`; button.addEventListener("click", () => editContact(contact)); list.append(button); } } function editContact(contact = null) { $("#contact-id").value = contact?.id || ""; $("#contact-name").value = contact?.name || ""; $("#contact-email").value = contact?.email || ""; $("#contact-emails").value = (contact?.emails || []).join("\n"); $("#contact-company").value = contact?.company || ""; $("#contact-phone").value = contact?.phone || ""; $("#contact-phones").value = (contact?.phones || []).join("\n"); $("#contact-job-title").value = contact?.job_title || ""; $("#contact-website").value = contact?.website || ""; $("#contact-birthday").value = contact?.birthday || ""; $("#contact-address").value = contact?.address || ""; $("#contact-addresses").value = (contact?.addresses || []).join("\n"); $("#contact-notes").value = contact?.notes || ""; $("#contact-favorite").checked = Boolean(contact?.is_favorite); $("#contact-delete").disabled = !contact; $("#contact-compose").disabled = !contact?.email; $("#contact-photo-upload").disabled = !contact?.id; $("#contact-photo-delete").disabled = !contact?.id || !contact?.has_photo; renderContactPhoto(contact); loadContactMembership(contact?.id || null); } function clearContactPhotoObjectUrl() { if (state.contactPhotoUrl) URL.revokeObjectURL(state.contactPhotoUrl); state.contactPhotoUrl = null; } async function renderContactPhoto(contact) { clearContactPhotoObjectUrl(); const image = $("#contact-photo-preview"); const placeholder = $("#contact-photo-placeholder"); image.classList.add("hidden"); image.removeAttribute("src"); placeholder.classList.remove("hidden"); if (!contact?.id || !contact?.has_photo) return; try { const headers = new Headers(); if (state.accountId) headers.set("X-Active-Account", state.accountId); const response = await fetch(apiPath(`/contacts/${encodeURIComponent(contact.id)}/photo`), { credentials: "include", headers }); if (!response.ok) return; const blob = await response.blob(); state.contactPhotoUrl = URL.createObjectURL(blob); image.src = state.contactPhotoUrl; image.classList.remove("hidden"); placeholder.classList.add("hidden"); } catch (_) { } } async function uploadContactPhoto(files) { const id = $("#contact-id").value; const file = files?.[0]; if (!id || !file) return; if (file.size > 2 * 1024 * 1024) { toast("Contact photo exceeds the 2 MB limit", "error"); $("#contact-photo-file").value = ""; return; } const form = new FormData(); form.append("file", file, file.name || "photo"); try { await request(`/contacts/${encodeURIComponent(id)}/photo`, { method: "POST", body: form }); const contact = state.contacts.find((item) => item.id === id); if (contact) contact.has_photo = true; $("#contact-photo-delete").disabled = false; await renderContactPhoto({ id, has_photo: true }); toast("Contact photo saved"); } catch (error) { toast(error.message, "error"); } finally { $("#contact-photo-file").value = ""; } } async function deleteContactPhoto() { const id = $("#contact-id").value; if (!id) return; try { await request(`/contacts/${encodeURIComponent(id)}/photo`, { method: "DELETE" }); const contact = state.contacts.find((item) => item.id === id); if (contact) contact.has_photo = false; $("#contact-photo-delete").disabled = true; await renderContactPhoto({ id, has_photo: false }); toast("Contact photo removed"); } catch (error) { toast(error.message, "error"); } } async function saveContact(event) { event.preventDefault(); const payload = { id: $("#contact-id").value || null, name: $("#contact-name").value.trim(), email: $("#contact-email").value.trim(), emails: $("#contact-emails").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), company: $("#contact-company").value.trim(), phone: $("#contact-phone").value.trim(), phones: $("#contact-phones").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), job_title: $("#contact-job-title").value.trim(), website: $("#contact-website").value.trim(), birthday: $("#contact-birthday").value, address: $("#contact-address").value.trim(), addresses: $("#contact-addresses").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), notes: $("#contact-notes").value, is_favorite: $("#contact-favorite").checked, source: "manual", }; try { const saved = await request("/contacts", { method: "POST", body: JSON.stringify(payload) }); await loadContacts($("#contact-search").value.trim()); editContact(saved); toast("Contact saved"); } catch (error) { toast(error.message, "error"); } } async function deleteContact() { const id = $("#contact-id").value; if (!id || !await appConfirm("Delete this contact?", { title: "Delete contact", confirmLabel: "Delete", danger: true })) return; try { await request(`/contacts/${encodeURIComponent(id)}`, { method: "DELETE" }); editContact(null); await loadContacts($("#contact-search").value.trim()); toast("Contact deleted"); } catch (error) { toast(error.message, "error"); } } function composeToContact() { const email = $("#contact-email").value.trim(); if (!email) return; $("#contacts-dialog").close(); openCompose("new"); $("#compose-to").value = email; }