From af115e3245747492cae81b1a6e32af061fddb737 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 19 Mar 2026 01:13:34 +0100 Subject: [PATCH] feat: add address book directories with drag-and-drop and editor picker - Show address books in sidebar organized by personal directories and shared accounts, replacing the flat shared accounts list - Make contact list items draggable with multi-select support using native HTML5 drag-and-drop (application/x-contact-ids MIME type) - Add drop targets on sidebar address book items with visual feedback - Add moveContactToAddressBook store method supporting same-account updates and cross-account create+delete moves - Add address book picker dropdown in contact create/edit form - Update ContactCategory type from sharedAccountId to addressBookId - Add address_books translations to all 8 locales - Fix contact-list-item tests for new selectedContactIds prop --- app/[locale]/contacts/page.tsx | 47 ++- .../__tests__/contact-list-item.test.tsx | 1 + components/contacts/contact-form.tsx | 47 ++- components/contacts/contact-list-item.tsx | 31 +- components/contacts/contact-list.tsx | 1 + components/contacts/contacts-sidebar.tsx | 162 ++++++++- lib/jmap/client.ts | 341 +++++++++++++++--- lib/jmap/types.ts | 17 + locales/de/common.json | 12 + locales/en/common.json | 12 + locales/es/common.json | 12 + locales/fr/common.json | 12 + locales/it/common.json | 12 + locales/ja/common.json | 12 + locales/nl/common.json | 12 + locales/pt/common.json | 12 + stores/contact-store.ts | 104 +++++- 17 files changed, 766 insertions(+), 81 deletions(-) diff --git a/app/[locale]/contacts/page.tsx b/app/[locale]/contacts/page.tsx index 695b7b56..dc4d314f 100644 --- a/app/[locale]/contacts/page.tsx +++ b/app/[locale]/contacts/page.tsx @@ -25,7 +25,7 @@ import { InlineAppView } from "@/components/layout/inline-app-view"; import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { useIsMobile } from "@/hooks/use-media-query"; -import type { ContactCard } from "@/lib/jmap/types"; +import type { ContactCard, AddressBook } from "@/lib/jmap/types"; type View = | "list" @@ -46,6 +46,7 @@ export default function ContactsPage() { const { quota, isPushConnected } = useEmailStore(); const { contacts, + addressBooks, selectedContactId, searchQuery, supportsSync, @@ -71,6 +72,7 @@ export default function ContactsPage() { clearSelection, bulkDeleteContacts, bulkAddToGroup, + moveContactToAddressBook, } = useContactStore(); const [view, setView] = useState("list"); @@ -123,7 +125,21 @@ export default function ContactsPage() { // Contacts to display based on active category const displayedContacts = useMemo(() => { - if (activeCategory === "all") return individuals; + if (activeCategory === "all") return individuals.filter(c => !c.isShared); + if ("addressBookId" in activeCategory) { + const bookId = activeCategory.addressBookId; + return individuals.filter(c => { + if (!c.addressBookIds) return false; + // Check both namespaced (accountId:bookId) and raw bookId + if (c.addressBookIds[bookId]) return true; + // For shared contacts, match namespaced id + if (c.isShared && c.accountId) { + const namespacedId = `${c.accountId}:${Object.keys(c.addressBookIds).find(k => c.addressBookIds[k])}`; + return namespacedId === bookId; + } + return false; + }); + } // Show members of the selected group return getGroupMembers(activeCategory.groupId); }, [activeCategory, individuals, getGroupMembers]); @@ -131,20 +147,38 @@ export default function ContactsPage() { // Label for the current category const categoryLabel = useMemo(() => { if (activeCategory === "all") return t("tabs.all"); + if ("addressBookId" in activeCategory) { + const book = addressBooks.find(b => b.id === activeCategory.addressBookId); + return book?.name || t("tabs.all"); + } const group = contacts.find(c => c.id === activeCategory.groupId); return group ? getContactDisplayName(group) : t("tabs.all"); - }, [activeCategory, contacts, t]); + }, [activeCategory, contacts, addressBooks, t]); const handleSelectCategory = useCallback((category: ContactCategory) => { setActiveCategory(category); clearSelection(); - if (typeof category === "object") { + if (typeof category === "object" && "groupId" in category) { setSelectedGroupId(category.groupId); } else { setSelectedGroupId(null); } }, [clearSelection]); + const handleDropContacts = useCallback(async (contactIds: string[], addressBook: AddressBook) => { + if (!client) return; + try { + await moveContactToAddressBook(client, contactIds, addressBook); + const msg = contactIds.length === 1 + ? t("address_books.moved", { name: addressBook.name }) + : t("address_books.moved_plural", { count: contactIds.length, name: addressBook.name }); + toast.success(msg); + } catch (error) { + console.error('Failed to move contacts:', error); + toast.error(t("address_books.move_failed")); + } + }, [client, moveContactToAddressBook, t]); + const handleSelectContact = (id: string) => { setSelectedContact(id); clearSelection(); @@ -357,13 +391,14 @@ export default function ContactsPage() { const renderRightPanel = () => { switch (view) { case "create": - return ; + return ; case "edit": if (!selectedContact) return null; return ( @@ -508,10 +543,12 @@ export default function ContactsPage() { { density: 'regular' as const, onClick: vi.fn(), onCheckboxClick: vi.fn(), + selectedContactIds: new Set(), }; it('renders contact name and email', () => { diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx index 7ef14167..ffeb6603 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -1,12 +1,12 @@ "use client"; -import { useState } from "react"; +import { useState, useMemo } from "react"; import { useTranslations } from "next-intl"; -import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle } from "lucide-react"; +import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; -import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo } from "@/lib/jmap/types"; +import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook } from "@/lib/jmap/types"; interface EmailEntry { address: string; @@ -47,6 +47,7 @@ interface AddressEntry { interface ContactFormProps { contact?: ContactCard | null; + addressBooks?: AddressBook[]; onSave: (data: Partial) => Promise; onCancel: () => void; } @@ -122,7 +123,7 @@ function Select({ value, onChange, children, className }: { ); } -export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) { +export function ContactForm({ contact, addressBooks, onSave, onCancel }: ContactFormProps) { const t = useTranslations("contacts.form"); const isEditing = !!contact; @@ -243,6 +244,23 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) { const [schedulingUri, setSchedulingUri] = useState(contact?.schedulingUri || ""); const [freeBusyUri, setFreeBusyUri] = useState(contact?.freeBusyUri || ""); + // Address book selection + const currentBookId = useMemo(() => { + if (contact?.addressBookIds) { + const ids = Object.keys(contact.addressBookIds).filter(k => contact.addressBookIds[k]); + if (ids.length > 0) { + // For shared contacts, the addressBookIds uses the original (non-namespaced) id + // but we need the namespaced id to match addressBooks entries + if (contact.isShared && contact.accountId) { + return `${contact.accountId}:${ids[0]}`; + } + return ids[0]; + } + } + return ""; + }, [contact]); + const [selectedBookId, setSelectedBookId] = useState(currentBookId); + const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); const [emailErrors, setEmailErrors] = useState>({}); @@ -382,6 +400,7 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) { calendarUri: calendarUri.trim() || undefined, schedulingUri: schedulingUri.trim() || undefined, freeBusyUri: freeBusyUri.trim() || undefined, + ...(selectedBookId ? { addressBookIds: { [selectedBookId]: true } } : {}), }; setIsSaving(true); @@ -415,6 +434,26 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) {
+ {/* Address Book Selector */} + {addressBooks && addressBooks.length > 1 && ( +
+ + + +
+ )} + {/* Name & Identity — full width */}
diff --git a/components/contacts/contact-list-item.tsx b/components/contacts/contact-list-item.tsx index 920c274c..ccb13479 100644 --- a/components/contacts/contact-list-item.tsx +++ b/components/contacts/contact-list-item.tsx @@ -1,5 +1,6 @@ "use client"; +import { useCallback, type DragEvent } from "react"; import { Avatar } from "@/components/ui/avatar"; import { cn } from "@/lib/utils"; import type { ContactCard } from "@/lib/jmap/types"; @@ -13,19 +14,47 @@ interface ContactListItemProps { isChecked: boolean; hasSelection: boolean; density: Density; + selectedContactIds: Set; onClick: (e: React.MouseEvent) => void; onCheckboxClick: (e: React.MouseEvent) => void; } -export function ContactListItem({ contact, isSelected, isChecked, hasSelection, density, onClick, onCheckboxClick }: ContactListItemProps) { +export function ContactListItem({ contact, isSelected, isChecked, hasSelection, density, selectedContactIds, onClick, onCheckboxClick }: ContactListItemProps) { const name = getContactDisplayName(contact); const email = getContactPrimaryEmail(contact); const org = contact.organizations ? Object.values(contact.organizations)[0]?.name : undefined; + const handleDragStart = useCallback((e: DragEvent) => { + // Drag all selected contacts if this one is selected, otherwise just this one + const ids = selectedContactIds.has(contact.id) + ? Array.from(selectedContactIds) + : [contact.id]; + + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("application/x-contact-ids", JSON.stringify(ids)); + e.dataTransfer.setData("text/plain", name || email || contact.id); + + // Custom drag preview + const preview = document.createElement("div"); + preview.style.cssText = ` + position: fixed; top: -9999px; left: 0; + padding: 8px 16px; background-color: var(--color-primary, #3b82f6); + color: var(--color-primary-foreground, #ffffff); border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); font-size: 14px; font-weight: 500; + z-index: 9999; white-space: nowrap; pointer-events: none; + `; + preview.textContent = ids.length === 1 ? (name || "1 contact") : `${ids.length} contacts`; + document.body.appendChild(preview); + e.dataTransfer.setDragImage(preview, 0, 0); + requestAnimationFrame(() => preview.remove()); + }, [contact.id, name, email, selectedContactIds]); + return (
{ if (e.ctrlKey || e.metaKey) { e.preventDefault(); diff --git a/components/contacts/contacts-sidebar.tsx b/components/contacts/contacts-sidebar.tsx index a21a85d8..8ce903a8 100644 --- a/components/contacts/contacts-sidebar.tsx +++ b/components/contacts/contacts-sidebar.tsx @@ -1,32 +1,36 @@ "use client"; -import { useMemo } from "react"; +import { useMemo, useState, useCallback, type DragEvent } from "react"; import { useTranslations } from "next-intl"; -import { BookUser, Users, Plus, UserPlus } from "lucide-react"; +import { BookUser, Users, Plus, UserPlus, Share2, Book } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; -import type { ContactCard } from "@/lib/jmap/types"; +import type { ContactCard, AddressBook } from "@/lib/jmap/types"; import { getContactDisplayName } from "@/stores/contact-store"; -export type ContactCategory = "all" | { groupId: string }; +export type ContactCategory = "all" | { groupId: string } | { addressBookId: string }; interface ContactsSidebarProps { groups: ContactCard[]; individuals: ContactCard[]; + addressBooks: AddressBook[]; activeCategory: ContactCategory; onSelectCategory: (category: ContactCategory) => void; onCreateGroup: () => void; onCreateContact: () => void; + onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void; className?: string; } export function ContactsSidebar({ groups, individuals, + addressBooks, activeCategory, onSelectCategory, onCreateGroup, onCreateContact, + onDropContacts, className, }: ContactsSidebarProps) { const t = useTranslations("contacts"); @@ -39,6 +43,44 @@ export function ContactsSidebar({ const isAllActive = activeCategory === "all"; + // Group address books: personal vs shared accounts + const personalBooks = useMemo(() => + addressBooks.filter(b => !b.isShared), + [addressBooks]); + + const sharedBookGroups = useMemo(() => { + const map = new Map(); + for (const book of addressBooks) { + if (!book.isShared || !book.accountId) continue; + const existing = map.get(book.accountId); + if (existing) { + existing.books.push(book); + } else { + map.set(book.accountId, { + accountId: book.accountId, + accountName: book.accountName || book.accountId, + books: [book], + }); + } + } + return Array.from(map.values()); + }, [addressBooks]); + + // Count contacts per address book + const contactCountByBook = useMemo(() => { + const counts: Record = {}; + for (const contact of individuals) { + if (!contact.addressBookIds) continue; + for (const bookId of Object.keys(contact.addressBookIds)) { + if (!contact.addressBookIds[bookId]) continue; + // Build the full namespaced key + const key = contact.isShared && contact.accountId ? `${contact.accountId}:${bookId}` : bookId; + counts[key] = (counts[key] || 0) + 1; + } + } + return counts; + }, [individuals]); + return (
{/* Header */} @@ -65,10 +107,31 @@ export function ContactsSidebar({ {t("tabs.all")} - {individuals.length} + {individuals.filter(c => !c.isShared).length} + {/* Personal address books */} + {personalBooks.length > 0 && ( +
+
+ + {t("address_books.title")} + +
+ {personalBooks.map((book) => ( + onSelectCategory({ addressBookId: book.id })} + onDropContacts={onDropContacts} + /> + ))} +
+ )} + {/* Groups section */} {(sortedGroups.length > 0) && (
@@ -82,7 +145,7 @@ export function ContactsSidebar({
{sortedGroups.map((group) => { - const isActive = typeof activeCategory === "object" && activeCategory.groupId === group.id; + const isActive = typeof activeCategory === "object" && "groupId" in activeCategory && activeCategory.groupId === group.id; const memberCount = group.members ? Object.values(group.members).filter(Boolean).length : 0; @@ -128,7 +191,94 @@ export function ContactsSidebar({
)} + + {/* Shared accounts with address books */} + {sharedBookGroups.map((group) => ( +
+
+ + + {group.accountName} + +
+ {group.books.map((book) => ( + onSelectCategory({ addressBookId: book.id })} + onDropContacts={onDropContacts} + /> + ))} +
+ ))}
); } + +function AddressBookItem({ + book, + isActive, + contactCount, + onSelect, + onDropContacts, +}: { + book: AddressBook; + isActive: boolean; + contactCount: number; + onSelect: () => void; + onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void; +}) { + const [isDragOver, setIsDragOver] = useState(false); + + const handleDragOver = useCallback((e: DragEvent) => { + if (!e.dataTransfer.types.includes("application/x-contact-ids")) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setIsDragOver(true); + }, []); + + const handleDragLeave = useCallback(() => { + setIsDragOver(false); + }, []); + + const handleDrop = useCallback((e: DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + const data = e.dataTransfer.getData("application/x-contact-ids"); + if (!data || !onDropContacts) return; + try { + const contactIds = JSON.parse(data) as string[]; + if (contactIds.length > 0) { + onDropContacts(contactIds, book); + } + } catch { + // ignore invalid data + } + }, [book, onDropContacts]); + + return ( + + ); +} diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 09bd9fda..be91e659 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -2340,6 +2340,38 @@ export class JMAPClient { return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:calendars"]; } + private getCalendarCapableAccountIds(): string[] { + const primaryId = this.getCalendarsAccountId(); + const accountIds: string[] = []; + for (const [id, account] of Object.entries(this.accounts)) { + if (id === primaryId) continue; + // Include accounts that either advertise calendar capability + // or are non-personal (shared/group) accounts — Stalwart doesn't + // always advertise capabilities on group accounts even when they + // have calendar resources. + if (account.accountCapabilities?.["urn:ietf:params:jmap:calendars"] || !account.isPersonal) { + accountIds.push(id); + } + } + return [primaryId, ...accountIds]; + } + + private getContactCapableAccountIds(): string[] { + const primaryId = this.getContactsAccountId(); + const accountIds: string[] = []; + for (const [id, account] of Object.entries(this.accounts)) { + if (id === primaryId) continue; + // Include accounts that either advertise contacts capability + // or are non-personal (shared/group) accounts — Stalwart doesn't + // always advertise capabilities on group accounts even when they + // have contact resources. + if (account.accountCapabilities?.["urn:ietf:params:jmap:contacts"] || !account.isPersonal) { + accountIds.push(id); + } + } + return [primaryId, ...accountIds]; + } + async getAddressBooks(): Promise { try { const accountId = this.getContactsAccountId(); @@ -2357,6 +2389,45 @@ export class JMAPClient { } } + async getAllAddressBooks(): Promise { + try { + const allBooks: AddressBook[] = []; + const primaryId = this.getContactsAccountId(); + const accountIds = this.getContactCapableAccountIds(); + + for (const accountId of accountIds) { + const isPrimary = accountId === primaryId; + const account = this.accounts[accountId]; + + try { + const response = await this.request([ + ["AddressBook/get", { accountId }, "0"] + ], this.contactUsing()); + + if (response.methodResponses?.[0]?.[0] === "AddressBook/get") { + const rawBooks = (response.methodResponses[0][1].list || []) as AddressBook[]; + const books = rawBooks.map((book) => ({ + ...book, + id: isPrimary ? book.id : `${accountId}:${book.id}`, + originalId: book.id, + accountId, + accountName: account?.name || (isPrimary ? this.username : accountId), + isShared: !isPrimary, + })); + allBooks.push(...books); + } + } catch (error) { + console.error(`Failed to fetch address books for account ${accountId}:`, error); + } + } + + return allBooks; + } catch (error) { + console.error('Failed to fetch all address books:', error); + return this.getAddressBooks(); + } + } + async getContacts(addressBookId?: string): Promise { try { const accountId = this.getContactsAccountId(); @@ -2383,12 +2454,55 @@ export class JMAPClient { } } - async getContact(contactId: string): Promise { + async getAllContacts(): Promise { try { - const accountId = this.getContactsAccountId(); + const allContacts: ContactCard[] = []; + const primaryId = this.getContactsAccountId(); + const accountIds = this.getContactCapableAccountIds(); + + for (const accountId of accountIds) { + const isPrimary = accountId === primaryId; + const account = this.accounts[accountId]; + + try { + const response = await this.request([ + ["ContactCard/query", { accountId, limit: 1000 }, "0"], + ["ContactCard/get", { + accountId, + "#ids": { resultOf: "0", name: "ContactCard/query", path: "/ids" }, + }, "1"], + ], this.contactUsing()); + + if (response.methodResponses?.[1]?.[0] === "ContactCard/get") { + const rawContacts = (response.methodResponses[1][1].list || []) as ContactCard[]; + const contacts = rawContacts.map((contact) => ({ + ...contact, + id: isPrimary ? contact.id : `${accountId}:${contact.id}`, + originalId: contact.id, + accountId, + accountName: account?.name || (isPrimary ? this.username : accountId), + isShared: !isPrimary, + })); + allContacts.push(...contacts); + } + } catch (error) { + console.error(`Failed to fetch contacts for account ${accountId}:`, error); + } + } + + return allContacts; + } catch (error) { + console.error('Failed to fetch all contacts:', error); + return this.getContacts(); + } + } + + async getContact(contactId: string, accountId?: string): Promise { + try { + const targetAccountId = accountId || this.getContactsAccountId(); const response = await this.request([ ["ContactCard/get", { - accountId, + accountId: targetAccountId, ids: [contactId], }, "0"] ], this.contactUsing()); @@ -2404,8 +2518,8 @@ export class JMAPClient { } } - async createContact(contact: Partial): Promise { - const accountId = this.getContactsAccountId(); + async createContact(contact: Partial, targetAccountId?: string): Promise { + const accountId = targetAccountId || this.getContactsAccountId(); let addressBookIds = contact.addressBookIds; if (!addressBookIds || Object.keys(addressBookIds).length === 0) { const books = await this.getAddressBooks(); @@ -2415,12 +2529,15 @@ export class JMAPClient { } } + // Strip shared-only fields before sending to JMAP + const { originalId: _oid, accountId: _aid, accountName: _an, isShared: _is, ...contactData } = contact as ContactCard; + const response = await this.request([ ["ContactCard/set", { accountId, create: { "new-contact": { - ...contact, + ...contactData, addressBookIds, } } @@ -2437,7 +2554,7 @@ export class JMAPClient { const createdId = result.created?.["new-contact"]?.id; if (createdId) { - const created = await this.getContact(createdId); + const created = await this.getContact(createdId, accountId); if (created) return created; } } @@ -2445,14 +2562,17 @@ export class JMAPClient { throw new Error("Failed to create contact"); } - async updateContact(contactId: string, updates: Partial): Promise { - const accountId = this.getContactsAccountId(); + async updateContact(contactId: string, updates: Partial, targetAccountId?: string): Promise { + const accountId = targetAccountId || this.getContactsAccountId(); + + // Strip shared-only fields before sending to JMAP + const { originalId: _oid, accountId: _aid, accountName: _an, isShared: _is, ...cleanUpdates } = updates as ContactCard; const response = await this.request([ ["ContactCard/set", { accountId, update: { - [contactId]: updates + [contactId]: cleanUpdates } }, "0"] ], this.contactUsing()); @@ -2470,8 +2590,8 @@ export class JMAPClient { throw new Error("Failed to update contact"); } - async deleteContact(contactId: string): Promise { - const accountId = this.getContactsAccountId(); + async deleteContact(contactId: string, targetAccountId?: string): Promise { + const accountId = targetAccountId || this.getContactsAccountId(); const response = await this.request([ ["ContactCard/set", { @@ -2495,24 +2615,45 @@ export class JMAPClient { async searchContacts(query: string): Promise { try { - const accountId = this.getContactsAccountId(); + const allResults: ContactCard[] = []; + const primaryId = this.getContactsAccountId(); + const accountIds = this.getContactCapableAccountIds(); - const response = await this.request([ - ["ContactCard/query", { - accountId, - filter: { text: query }, - limit: 50, - }, "0"], - ["ContactCard/get", { - accountId, - "#ids": { resultOf: "0", name: "ContactCard/query", path: "/ids" }, - }, "1"] - ], this.contactUsing()); + for (const accountId of accountIds) { + const isPrimary = accountId === primaryId; + const account = this.accounts[accountId]; - if (response.methodResponses?.[1]?.[0] === "ContactCard/get") { - return (response.methodResponses[1][1].list || []) as ContactCard[]; + try { + const response = await this.request([ + ["ContactCard/query", { + accountId, + filter: { text: query }, + limit: 50, + }, "0"], + ["ContactCard/get", { + accountId, + "#ids": { resultOf: "0", name: "ContactCard/query", path: "/ids" }, + }, "1"] + ], this.contactUsing()); + + if (response.methodResponses?.[1]?.[0] === "ContactCard/get") { + const rawContacts = (response.methodResponses[1][1].list || []) as ContactCard[]; + const contacts = rawContacts.map((contact) => ({ + ...contact, + id: isPrimary ? contact.id : `${accountId}:${contact.id}`, + originalId: contact.id, + accountId, + accountName: account?.name || (isPrimary ? this.username : accountId), + isShared: !isPrimary, + })); + allResults.push(...contacts); + } + } catch (error) { + console.error(`Failed to search contacts for account ${accountId}:`, error); + } } - return []; + + return allResults; } catch (error) { console.error('Failed to search contacts:', error); return []; @@ -2536,8 +2677,47 @@ export class JMAPClient { } } - async createCalendar(calendar: Partial): Promise { - const accountId = this.getCalendarsAccountId(); + async getAllCalendars(): Promise { + try { + const allCalendars: Calendar[] = []; + const primaryId = this.getCalendarsAccountId(); + const accountIds = this.getCalendarCapableAccountIds(); + + for (const accountId of accountIds) { + const isPrimary = accountId === primaryId; + const account = this.accounts[accountId]; + + try { + const response = await this.request([ + ["Calendar/get", { accountId }, "0"] + ], this.calendarUsing()); + + if (response.methodResponses?.[0]?.[0] === "Calendar/get") { + const rawCalendars = (response.methodResponses[0][1].list || []) as Calendar[]; + const calendars = rawCalendars.map((cal) => ({ + ...cal, + id: isPrimary ? cal.id : `${accountId}:${cal.id}`, + originalId: cal.id, + accountId, + accountName: account?.name || (isPrimary ? this.username : accountId), + isShared: !isPrimary, + })); + allCalendars.push(...calendars); + } + } catch (error) { + console.error(`Failed to fetch calendars for account ${accountId}:`, error); + } + } + + return allCalendars; + } catch (error) { + console.error('Failed to fetch all calendars:', error); + return this.getCalendars(); + } + } + + async createCalendar(calendar: Partial, targetAccountId?: string): Promise { + const accountId = targetAccountId || this.getCalendarsAccountId(); const response = await this.request([ ["Calendar/set", { @@ -2558,17 +2738,23 @@ export class JMAPClient { const createdId = result.created?.["new-calendar"]?.id; if (createdId) { - const calendars = await this.getCalendars(); - const created = calendars.find(c => c.id === createdId); - if (created) return created; + // Fetch from the target account to find the created calendar + const fetchAccountId = targetAccountId || this.getCalendarsAccountId(); + const fetchResponse = await this.request([ + ["Calendar/get", { accountId: fetchAccountId, ids: [createdId] }, "0"] + ], this.calendarUsing()); + if (fetchResponse.methodResponses?.[0]?.[0] === "Calendar/get") { + const list = fetchResponse.methodResponses[0][1].list || []; + if (list[0]) return list[0] as Calendar; + } } } throw new Error("Failed to create calendar"); } - async updateCalendar(calendarId: string, updates: Partial): Promise { - const accountId = this.getCalendarsAccountId(); + async updateCalendar(calendarId: string, updates: Partial, targetAccountId?: string): Promise { + const accountId = targetAccountId || this.getCalendarsAccountId(); const response = await this.request([ ["Calendar/set", { @@ -2592,8 +2778,8 @@ export class JMAPClient { throw new Error("Failed to update calendar"); } - async deleteCalendar(calendarId: string): Promise { - const accountId = this.getCalendarsAccountId(); + async deleteCalendar(calendarId: string, targetAccountId?: string): Promise { + const accountId = targetAccountId || this.getCalendarsAccountId(); const response = await this.request([ ["Calendar/set", { @@ -2616,8 +2802,8 @@ export class JMAPClient { throw new Error("Failed to delete calendar"); } - async getCalendarEvents(calendarIds?: string[]): Promise { - const accountId = this.getCalendarsAccountId(); + async getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise { + const accountId = targetAccountId || this.getCalendarsAccountId(); const queryArgs: Record = { accountId, limit: 1000 }; if (calendarIds && calendarIds.length > 0) { @@ -2644,13 +2830,55 @@ export class JMAPClient { return []; } - async queryCalendarEvents( + async queryAllCalendarEvents( filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number ): Promise { try { - const accountId = this.getCalendarsAccountId(); + const allEvents: CalendarEvent[] = []; + const primaryId = this.getCalendarsAccountId(); + const accountIds = this.getCalendarCapableAccountIds(); + + for (const accountId of accountIds) { + const isPrimary = accountId === primaryId; + const account = this.accounts[accountId]; + + try { + const events = await this.queryCalendarEvents(filter, sort, limit, accountId); + const mapped = events.map((event) => ({ + ...event, + id: isPrimary ? event.id : `${accountId}:${event.id}`, + originalId: event.id, + originalCalendarIds: event.calendarIds, + calendarIds: isPrimary ? event.calendarIds : Object.fromEntries( + Object.entries(event.calendarIds).map(([calId, v]) => [`${accountId}:${calId}`, v]) + ), + accountId, + accountName: account?.name || (isPrimary ? this.username : accountId), + isShared: !isPrimary, + })); + allEvents.push(...mapped); + } catch (error) { + console.error(`Failed to query calendar events for account ${accountId}:`, error); + } + } + + return allEvents; + } catch (error) { + console.error('Failed to query all calendar events:', error); + return this.queryCalendarEvents(filter, sort, limit); + } + } + + async queryCalendarEvents( + filter: CalendarEventFilter, + sort?: Array<{ property: string; isAscending: boolean }>, + limit?: number, + targetAccountId?: string + ): Promise { + try { + const accountId = targetAccountId || this.getCalendarsAccountId(); const queryArgs: Record = { accountId, @@ -2679,9 +2907,9 @@ export class JMAPClient { } } - async getCalendarEvent(id: string): Promise { + async getCalendarEvent(id: string, targetAccountId?: string): Promise { try { - const accountId = this.getCalendarsAccountId(); + const accountId = targetAccountId || this.getCalendarsAccountId(); const response = await this.request([ ["CalendarEvent/get", { accountId, @@ -2700,13 +2928,16 @@ export class JMAPClient { } } - async createCalendarEvent(event: Partial, sendSchedulingMessages?: boolean): Promise { - const accountId = this.getCalendarsAccountId(); + async createCalendarEvent(event: Partial, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise { + const accountId = targetAccountId || this.getCalendarsAccountId(); + + // Strip client-only shared fields before sending to JMAP + const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanEvent } = event as CalendarEvent; const setArgs: Record = { accountId, create: { - "new-event": event + "new-event": cleanEvent } }; if (sendSchedulingMessages !== undefined) { @@ -2727,7 +2958,7 @@ export class JMAPClient { const createdId = result.created?.["new-event"]?.id; if (createdId) { - const created = await this.getCalendarEvent(createdId); + const created = await this.getCalendarEvent(createdId, targetAccountId); if (created) return created; } } @@ -2738,14 +2969,18 @@ export class JMAPClient { async updateCalendarEvent( eventId: string, updates: Partial, - sendSchedulingMessages?: boolean + sendSchedulingMessages?: boolean, + targetAccountId?: string ): Promise { - const accountId = this.getCalendarsAccountId(); + const accountId = targetAccountId || this.getCalendarsAccountId(); + + // Strip client-only shared fields before sending to JMAP + const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanUpdates } = updates as CalendarEvent; const setArgs: Record = { accountId, update: { - [eventId]: updates + [eventId]: cleanUpdates } }; if (sendSchedulingMessages !== undefined) { @@ -2800,8 +3035,8 @@ export class JMAPClient { throw new Error("Failed to parse calendar file"); } - async deleteCalendarEvent(eventId: string, sendSchedulingMessages?: boolean): Promise { - const accountId = this.getCalendarsAccountId(); + async deleteCalendarEvent(eventId: string, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise { + const accountId = targetAccountId || this.getCalendarsAccountId(); const setArgs: Record = { accountId, @@ -2828,10 +3063,10 @@ export class JMAPClient { throw new Error("Failed to delete calendar event"); } - async batchDeleteCalendarEvents(eventIds: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> { + async batchDeleteCalendarEvents(eventIds: string[], targetAccountId?: string): Promise<{ destroyed: string[]; notDestroyed: string[] }> { if (eventIds.length === 0) return { destroyed: [], notDestroyed: [] }; - const accountId = this.getCalendarsAccountId(); + const accountId = targetAccountId || this.getCalendarsAccountId(); const response = await this.request([ ["CalendarEvent/set", { accountId, destroy: eventIds }, "0"] ], this.calendarUsing()); diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index c6748c21..8d27c209 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -160,9 +160,13 @@ export interface Identity { export interface ContactCard { id: string; + originalId?: string; uid?: string; addressBookIds: Record; kind?: 'individual' | 'group' | 'org' | 'location' | 'device' | 'application'; + accountId?: string; + accountName?: string; + isShared?: boolean; language?: string; name?: ContactName; nicknames?: Record; @@ -319,12 +323,16 @@ export interface ContactRelation { export interface AddressBook { id: string; + originalId?: string; name: string; description?: string | null; sortOrder?: number; isDefault?: boolean; isSubscribed?: boolean; myRights?: AddressBookRights; + accountId?: string; + accountName?: string; + isShared?: boolean; } export interface AddressBookRights { @@ -370,6 +378,7 @@ export interface DeliveryStatus { export interface Calendar { id: string; + originalId?: string; name: string; description: string | null; color: string | null; @@ -383,6 +392,9 @@ export interface Calendar { timeZone: string | null; shareWith: Record | null; myRights: CalendarRights; + accountId?: string; + accountName?: string; + isShared?: boolean; } export interface CalendarRights { @@ -398,7 +410,12 @@ export interface CalendarRights { export interface CalendarEvent { id: string; + originalId?: string; calendarIds: Record; + originalCalendarIds?: Record; + accountId?: string; + accountName?: string; + isShared?: boolean; isDraft: boolean; isOrigin: boolean; utcStart: string | null; diff --git a/locales/de/common.json b/locales/de/common.json index c67524bd..20a01f71 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1469,6 +1469,16 @@ "all": "Alle", "groups": "Gruppen" }, + "shared": { + "title": "Geteilt" + }, + "address_books": { + "title": "Verzeichnisse", + "moved": "Kontakt verschoben nach {name}", + "moved_plural": "{count} Kontakte verschoben nach {name}", + "move_failed": "Kontakt konnte nicht verschoben werden", + "address_book": "Verzeichnis" + }, "detail": { "emails": "E-Mail-Adressen", "phones": "Telefonnummern", @@ -1524,6 +1534,8 @@ "form": { "create_title": "Neuer Kontakt", "edit_title": "Kontakt bearbeiten", + "section_address_book": "Verzeichnis", + "select_address_book": "Verzeichnis auswählen...", "section_identity": "Name & Identität", "section_work": "Beruf & Organisation", "prefix": "Anrede", diff --git a/locales/en/common.json b/locales/en/common.json index 716b8422..9bd0b09d 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1469,6 +1469,16 @@ "all": "All", "groups": "Groups" }, + "shared": { + "title": "Shared" + }, + "address_books": { + "title": "Directories", + "moved": "Contact moved to {name}", + "moved_plural": "{count} contacts moved to {name}", + "move_failed": "Failed to move contact", + "address_book": "Directory" + }, "detail": { "emails": "Email Addresses", "phones": "Phone Numbers", @@ -1524,6 +1534,8 @@ "form": { "create_title": "New Contact", "edit_title": "Edit Contact", + "section_address_book": "Directory", + "select_address_book": "Select a directory...", "section_identity": "Name & Identity", "section_work": "Work & Organization", "prefix": "Prefix", diff --git a/locales/es/common.json b/locales/es/common.json index 3d632113..b61f05bc 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1469,6 +1469,16 @@ "all": "Todos", "groups": "Grupos" }, + "shared": { + "title": "Compartidos" + }, + "address_books": { + "title": "Directorios", + "moved": "Contacto movido a {name}", + "moved_plural": "{count} contactos movidos a {name}", + "move_failed": "Error al mover el contacto", + "address_book": "Directorio" + }, "detail": { "emails": "Direcciones de correo", "phones": "Números de teléfono", @@ -1524,6 +1534,8 @@ "form": { "create_title": "Nuevo contacto", "edit_title": "Editar contacto", + "section_address_book": "Directorio", + "select_address_book": "Seleccionar un directorio...", "section_identity": "Nombre e identidad", "section_work": "Trabajo y organización", "prefix": "Prefijo", diff --git a/locales/fr/common.json b/locales/fr/common.json index e807de0b..b59f1e53 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1469,6 +1469,16 @@ "all": "Tous", "groups": "Groupes" }, + "shared": { + "title": "Partagés" + }, + "address_books": { + "title": "Répertoires", + "moved": "Contact déplacé vers {name}", + "moved_plural": "{count} contacts déplacés vers {name}", + "move_failed": "Échec du déplacement du contact", + "address_book": "Répertoire" + }, "detail": { "emails": "Adresses e-mail", "phones": "Numéros de téléphone", @@ -1524,6 +1534,8 @@ "form": { "create_title": "Nouveau contact", "edit_title": "Modifier le contact", + "section_address_book": "Répertoire", + "select_address_book": "Sélectionner un répertoire...", "section_identity": "Nom et identité", "section_work": "Travail et organisation", "prefix": "Préfixe", diff --git a/locales/it/common.json b/locales/it/common.json index 16cd0f2c..c39b9006 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1469,6 +1469,16 @@ "all": "Tutti", "groups": "Gruppi" }, + "shared": { + "title": "Condivisi" + }, + "address_books": { + "title": "Rubriche", + "moved": "Contatto spostato in {name}", + "moved_plural": "{count} contatti spostati in {name}", + "move_failed": "Impossibile spostare il contatto", + "address_book": "Rubrica" + }, "detail": { "emails": "Indirizzi email", "phones": "Numeri di telefono", @@ -1524,6 +1534,8 @@ "form": { "create_title": "Nuovo contatto", "edit_title": "Modifica contatto", + "section_address_book": "Rubrica", + "select_address_book": "Seleziona una rubrica...", "section_identity": "Nome e identità", "section_work": "Lavoro e organizzazione", "prefix": "Prefisso", diff --git a/locales/ja/common.json b/locales/ja/common.json index a1a648b7..cc38ba3b 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1469,6 +1469,16 @@ "all": "すべて", "groups": "グループ" }, + "shared": { + "title": "共有" + }, + "address_books": { + "title": "ディレクトリ", + "moved": "連絡先を {name} に移動しました", + "moved_plural": "{count} 件の連絡先を {name} に移動しました", + "move_failed": "連絡先の移動に失敗しました", + "address_book": "ディレクトリ" + }, "detail": { "emails": "メールアドレス", "phones": "電話番号", @@ -1524,6 +1534,8 @@ "form": { "create_title": "新しい連絡先", "edit_title": "連絡先を編集", + "section_address_book": "ディレクトリ", + "select_address_book": "ディレクトリを選択...", "section_identity": "名前と識別情報", "section_work": "職業と組織", "prefix": "敬称", diff --git a/locales/nl/common.json b/locales/nl/common.json index e2aad935..d80069ff 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1469,6 +1469,16 @@ "all": "Alle", "groups": "Groepen" }, + "shared": { + "title": "Gedeeld" + }, + "address_books": { + "title": "Adresboeken", + "moved": "Contact verplaatst naar {name}", + "moved_plural": "{count} contacten verplaatst naar {name}", + "move_failed": "Verplaatsen van contact mislukt", + "address_book": "Adresboek" + }, "detail": { "emails": "E-mailadressen", "phones": "Telefoonnummers", @@ -1524,6 +1534,8 @@ "form": { "create_title": "Nieuw contact", "edit_title": "Contact bewerken", + "section_address_book": "Adresboek", + "select_address_book": "Selecteer een adresboek...", "section_identity": "Naam en identiteit", "section_work": "Werk en organisatie", "prefix": "Voorvoegsel", diff --git a/locales/pt/common.json b/locales/pt/common.json index abe7cfc8..0a420620 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1469,6 +1469,16 @@ "all": "Todos", "groups": "Grupos" }, + "shared": { + "title": "Compartilhados" + }, + "address_books": { + "title": "Diretórios", + "moved": "Contato movido para {name}", + "moved_plural": "{count} contatos movidos para {name}", + "move_failed": "Falha ao mover o contato", + "address_book": "Diretório" + }, "detail": { "emails": "Endereços de e-mail", "phones": "Números de telefone", @@ -1524,6 +1534,8 @@ "form": { "create_title": "Novo contato", "edit_title": "Editar contato", + "section_address_book": "Diretório", + "select_address_book": "Selecionar um diretório...", "section_identity": "Nome e identidade", "section_work": "Trabalho e organização", "prefix": "Prefixo", diff --git a/stores/contact-store.ts b/stores/contact-store.ts index 6df85ee2..bb1fe825 100644 --- a/stores/contact-store.ts +++ b/stores/contact-store.ts @@ -80,6 +80,7 @@ interface ContactStore { clearSelection: () => void; bulkDeleteContacts: (client: JMAPClient | null, ids: string[]) => Promise; bulkAddToGroup: (client: JMAPClient | null, groupId: string, contactIds: string[]) => Promise; + moveContactToAddressBook: (client: JMAPClient, contactIds: string[], addressBook: AddressBook) => Promise; importContacts: (client: JMAPClient | null, contacts: ContactCard[]) => Promise; } @@ -101,7 +102,7 @@ export const useContactStore = create()( fetchContacts: async (client) => { set({ isLoading: true, error: null }); try { - const contacts = await client.getContacts(); + const contacts = await client.getAllContacts(); set({ contacts, isLoading: false }); } catch (error) { console.error('Failed to fetch contacts:', error); @@ -111,7 +112,7 @@ export const useContactStore = create()( fetchAddressBooks: async (client) => { try { - const addressBooks = await client.getAddressBooks(); + const addressBooks = await client.getAllAddressBooks(); set({ addressBooks }); } catch (error) { console.error('Failed to fetch address books:', error); @@ -122,7 +123,16 @@ export const useContactStore = create()( createContact: async (client, contact) => { set({ isLoading: true, error: null }); try { - const created = await client.createContact(contact); + const accountId = contact.isShared ? contact.accountId : undefined; + const created = await client.createContact(contact, accountId); + // Preserve shared account metadata + if (contact.isShared && contact.accountId) { + created.accountId = contact.accountId; + created.accountName = contact.accountName; + created.isShared = true; + created.id = `${contact.accountId}:${created.id}`; + created.originalId = created.id.includes(':') ? created.id.split(':').slice(1).join(':') : created.id; + } set((state) => ({ contacts: [...state.contacts, created], isLoading: false, @@ -137,7 +147,10 @@ export const useContactStore = create()( updateContact: async (client, id, updates) => { set({ error: null }); try { - await client.updateContact(id, updates); + const contact = get().contacts.find(c => c.id === id); + const originalId = contact?.originalId || id; + const accountId = contact?.isShared ? contact.accountId : undefined; + await client.updateContact(originalId, updates, accountId); set((state) => ({ contacts: state.contacts.map(c => c.id === id ? { ...c, ...updates } : c @@ -153,7 +166,10 @@ export const useContactStore = create()( deleteContact: async (client, id) => { set({ error: null }); try { - await client.deleteContact(id); + const contact = get().contacts.find(c => c.id === id); + const originalId = contact?.originalId || id; + const accountId = contact?.isShared ? contact.accountId : undefined; + await client.deleteContact(originalId, accountId); set((state) => ({ contacts: state.contacts.filter(c => c.id !== id), selectedContactId: state.selectedContactId === id ? null : state.selectedContactId, @@ -296,7 +312,10 @@ export const useContactStore = create()( name: { components: [{ kind: 'given', value: name }], isOrdered: true }, }; if (client && get().supportsSync) { - await client.updateContact(groupId, updates); + const group = get().contacts.find(c => c.id === groupId); + const originalId = group?.originalId || groupId; + const accountId = group?.isShared ? group.accountId : undefined; + await client.updateContact(originalId, updates, accountId); } set((state) => ({ contacts: state.contacts.map(c => @@ -313,13 +332,15 @@ export const useContactStore = create()( const newMembers = { ...group.members }; memberIds.forEach(id => { const contact = contacts.find(c => c.id === id); - const key = contact?.uid || id; + const key = contact?.uid || contact?.originalId || id; newMembers[key] = true; }); const updates: Partial = { members: newMembers }; if (client && get().supportsSync) { - await client.updateContact(groupId, updates); + const originalId = group.originalId || groupId; + const accountId = group.isShared ? group.accountId : undefined; + await client.updateContact(originalId, updates, accountId); } set((state) => ({ contacts: state.contacts.map(c => @@ -359,7 +380,9 @@ export const useContactStore = create()( const updates: Partial = { members: newMembers }; if (client && get().supportsSync) { - await client.updateContact(groupId, updates); + const originalId = group.originalId || groupId; + const accountId = group.isShared ? group.accountId : undefined; + await client.updateContact(originalId, updates, accountId); } set((state) => ({ contacts: state.contacts.map(c => @@ -370,7 +393,10 @@ export const useContactStore = create()( deleteGroup: async (client, groupId) => { if (client && get().supportsSync) { - await client.deleteContact(groupId); + const group = get().contacts.find(c => c.id === groupId); + const originalId = group?.originalId || groupId; + const accountId = group?.isShared ? group.accountId : undefined; + await client.deleteContact(originalId, accountId); } set((state) => ({ contacts: state.contacts.filter(c => c.id !== groupId), @@ -410,13 +436,16 @@ export const useContactStore = create()( bulkDeleteContacts: async (client, ids) => { set({ error: null }); - const { supportsSync } = get(); + const { supportsSync, contacts } = get(); const deletedIds = new Set(ids); if (client && supportsSync) { for (const id of ids) { try { - await client.deleteContact(id); + const contact = contacts.find(c => c.id === id); + const originalId = contact?.originalId || id; + const accountId = contact?.isShared ? contact.accountId : undefined; + await client.deleteContact(originalId, accountId); } catch (error) { console.error(`Failed to delete contact ${id}:`, error); deletedIds.delete(id); @@ -439,6 +468,57 @@ export const useContactStore = create()( set({ selectedContactIds: new Set() }); }, + moveContactToAddressBook: async (client, contactIds, addressBook) => { + set({ error: null }); + const { contacts } = get(); + const targetBookOriginalId = addressBook.originalId || addressBook.id; + const targetAccountId = addressBook.accountId; + const primaryAccountId = client.getContactsAccountId(); + + for (const id of contactIds) { + const contact = contacts.find(c => c.id === id); + if (!contact) continue; + + const originalId = contact.originalId || id; + const sourceAccountId = contact.isShared ? contact.accountId : undefined; + + // Same account: just update the addressBookIds + if ((sourceAccountId || primaryAccountId) === (targetAccountId || primaryAccountId)) { + await client.updateContact(originalId, { addressBookIds: { [targetBookOriginalId]: true } }, sourceAccountId); + set((state) => ({ + contacts: state.contacts.map(c => + c.id === id ? { ...c, addressBookIds: { [targetBookOriginalId]: true } } : c + ), + })); + } else { + // Cross-account: create in target, delete from source + const { originalId: _oid, accountId: _aid, accountName: _an, isShared: _is, id: _id, ...contactData } = contact; + const newContact = await client.createContact( + { ...contactData, addressBookIds: { [targetBookOriginalId]: true } }, + targetAccountId + ); + await client.deleteContact(originalId, sourceAccountId); + + // Update local state + const isPrimary = !targetAccountId || targetAccountId === primaryAccountId; + set((state) => ({ + contacts: state.contacts.map(c => { + if (c.id !== id) return c; + return { + ...newContact, + id: isPrimary ? newContact.id : `${targetAccountId}:${newContact.id}`, + originalId: newContact.id, + accountId: targetAccountId, + accountName: addressBook.accountName || targetAccountId, + isShared: !isPrimary, + addressBookIds: { [targetBookOriginalId]: true }, + }; + }), + })); + } + } + }, + importContacts: async (client, contacts) => { const { supportsSync } = get(); let imported = 0;