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
This commit is contained in:
Linus Rath
2026-03-19 01:13:34 +01:00
parent fc79bf4f9b
commit af115e3245
17 changed files with 766 additions and 81 deletions
+42 -5
View File
@@ -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<View>("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 <ContactForm onSave={handleSaveNew} onCancel={handleCancel} />;
return <ContactForm addressBooks={addressBooks} onSave={handleSaveNew} onCancel={handleCancel} />;
case "edit":
if (!selectedContact) return null;
return (
<ContactForm
contact={selectedContact}
addressBooks={addressBooks}
onSave={handleSaveEdit}
onCancel={handleCancel}
/>
@@ -508,10 +543,12 @@ export default function ContactsPage() {
<ContactsSidebar
groups={groups}
individuals={individuals}
addressBooks={addressBooks}
activeCategory={activeCategory}
onSelectCategory={handleSelectCategory}
onCreateGroup={handleCreateGroup}
onCreateContact={handleCreateNew}
onDropContacts={handleDropContacts}
/>
</div>
<ResizeHandle
@@ -30,6 +30,7 @@ describe('ContactListItem', () => {
density: 'regular' as const,
onClick: vi.fn(),
onCheckboxClick: vi.fn(),
selectedContactIds: new Set<string>(),
};
it('renders contact name and email', () => {
+43 -4
View File
@@ -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<ContactCard>) => Promise<void>;
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<string | null>(null);
const [emailErrors, setEmailErrors] = useState<Record<number, string>>({});
@@ -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) {
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{/* Address Book Selector */}
{addressBooks && addressBooks.length > 1 && (
<div className="md:col-span-2 xl:col-span-3">
<FormSection icon={Book} title={t("section_address_book") || "Directory"} category="contact">
<select
value={selectedBookId}
onChange={(e) => setSelectedBookId(e.target.value)}
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
>
<option value="">{t("select_address_book") || "Select a directory..."}</option>
{addressBooks.map((book) => (
<option key={book.id} value={book.id}>
{book.accountName ? `${book.name} (${book.accountName})` : book.name}
</option>
))}
</select>
</FormSection>
</div>
)}
{/* Name & Identity — full width */}
<div className="md:col-span-2 xl:col-span-3">
<FormSection icon={User} title={t("section_identity")} category="contact">
+30 -1
View File
@@ -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<string>;
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<HTMLDivElement>) => {
// 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 (
<div
draggable
onDragStart={handleDragStart}
onClick={onClick}
className={cn(
"w-full flex items-center cursor-pointer select-none transition-all duration-200 border-b border-border",
+1
View File
@@ -193,6 +193,7 @@ export function ContactList({
isChecked={selectedContactIds.has(contact.id)}
hasSelection={hasSelection}
density={density}
selectedContactIds={selectedContactIds}
onClick={(e) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
+156 -6
View File
@@ -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<string, { accountId: string; accountName: string; books: AddressBook[] }>();
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<string, number> = {};
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 (
<div className={cn("flex flex-col h-full bg-secondary", className)}>
{/* Header */}
@@ -65,10 +107,31 @@ export function ContactsSidebar({
<BookUser className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{t("tabs.all")}</span>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
{individuals.length}
{individuals.filter(c => !c.isShared).length}
</span>
</button>
{/* Personal address books */}
{personalBooks.length > 0 && (
<div className="mt-2">
<div className="flex items-center justify-between px-3 py-1">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{t("address_books.title")}
</span>
</div>
{personalBooks.map((book) => (
<AddressBookItem
key={book.id}
book={book}
isActive={typeof activeCategory === "object" && "addressBookId" in activeCategory && activeCategory.addressBookId === book.id}
contactCount={contactCountByBook[book.id] || 0}
onSelect={() => onSelectCategory({ addressBookId: book.id })}
onDropContacts={onDropContacts}
/>
))}
</div>
)}
{/* Groups section */}
{(sortedGroups.length > 0) && (
<div className="mt-2">
@@ -82,7 +145,7 @@ export function ContactsSidebar({
</div>
{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({
</Button>
</div>
)}
{/* Shared accounts with address books */}
{sharedBookGroups.map((group) => (
<div key={group.accountId} className="mt-2">
<div className="flex items-center justify-between px-3 py-1">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1">
<Share2 className="w-3 h-3" />
{group.accountName}
</span>
</div>
{group.books.map((book) => (
<AddressBookItem
key={book.id}
book={book}
isActive={typeof activeCategory === "object" && "addressBookId" in activeCategory && activeCategory.addressBookId === book.id}
contactCount={contactCountByBook[book.id] || 0}
onSelect={() => onSelectCategory({ addressBookId: book.id })}
onDropContacts={onDropContacts}
/>
))}
</div>
))}
</div>
</div>
);
}
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<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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 (
<button
onClick={onSelect}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
"w-full flex items-center gap-2 px-3 text-sm transition-colors",
isActive
? "bg-accent text-accent-foreground font-medium"
: "text-foreground/80 hover:bg-muted",
isDragOver && "bg-primary/20 ring-2 ring-primary/50"
)}
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }}
>
<Book className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{book.name}</span>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
{contactCount}
</span>
</button>
);
}
+288 -53
View File
@@ -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<AddressBook[]> {
try {
const accountId = this.getContactsAccountId();
@@ -2357,6 +2389,45 @@ export class JMAPClient {
}
}
async getAllAddressBooks(): Promise<AddressBook[]> {
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<ContactCard[]> {
try {
const accountId = this.getContactsAccountId();
@@ -2383,12 +2454,55 @@ export class JMAPClient {
}
}
async getContact(contactId: string): Promise<ContactCard | null> {
async getAllContacts(): Promise<ContactCard[]> {
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<ContactCard | null> {
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<ContactCard>): Promise<ContactCard> {
const accountId = this.getContactsAccountId();
async createContact(contact: Partial<ContactCard>, targetAccountId?: string): Promise<ContactCard> {
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<ContactCard>): Promise<void> {
const accountId = this.getContactsAccountId();
async updateContact(contactId: string, updates: Partial<ContactCard>, targetAccountId?: string): Promise<void> {
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<void> {
const accountId = this.getContactsAccountId();
async deleteContact(contactId: string, targetAccountId?: string): Promise<void> {
const accountId = targetAccountId || this.getContactsAccountId();
const response = await this.request([
["ContactCard/set", {
@@ -2495,24 +2615,45 @@ export class JMAPClient {
async searchContacts(query: string): Promise<ContactCard[]> {
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<Calendar>): Promise<Calendar> {
const accountId = this.getCalendarsAccountId();
async getAllCalendars(): Promise<Calendar[]> {
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<Calendar>, targetAccountId?: string): Promise<Calendar> {
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<Calendar>): Promise<void> {
const accountId = this.getCalendarsAccountId();
async updateCalendar(calendarId: string, updates: Partial<Calendar>, targetAccountId?: string): Promise<void> {
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<void> {
const accountId = this.getCalendarsAccountId();
async deleteCalendar(calendarId: string, targetAccountId?: string): Promise<void> {
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<CalendarEvent[]> {
const accountId = this.getCalendarsAccountId();
async getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise<CalendarEvent[]> {
const accountId = targetAccountId || this.getCalendarsAccountId();
const queryArgs: Record<string, unknown> = { 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<CalendarEvent[]> {
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<CalendarEvent[]> {
try {
const accountId = targetAccountId || this.getCalendarsAccountId();
const queryArgs: Record<string, unknown> = {
accountId,
@@ -2679,9 +2907,9 @@ export class JMAPClient {
}
}
async getCalendarEvent(id: string): Promise<CalendarEvent | null> {
async getCalendarEvent(id: string, targetAccountId?: string): Promise<CalendarEvent | null> {
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<CalendarEvent>, sendSchedulingMessages?: boolean): Promise<CalendarEvent> {
const accountId = this.getCalendarsAccountId();
async createCalendarEvent(event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<CalendarEvent> {
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<string, unknown> = {
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<CalendarEvent>,
sendSchedulingMessages?: boolean
sendSchedulingMessages?: boolean,
targetAccountId?: string
): Promise<void> {
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<string, unknown> = {
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<void> {
const accountId = this.getCalendarsAccountId();
async deleteCalendarEvent(eventId: string, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<void> {
const accountId = targetAccountId || this.getCalendarsAccountId();
const setArgs: Record<string, unknown> = {
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());
+17
View File
@@ -160,9 +160,13 @@ export interface Identity {
export interface ContactCard {
id: string;
originalId?: string;
uid?: string;
addressBookIds: Record<string, boolean>;
kind?: 'individual' | 'group' | 'org' | 'location' | 'device' | 'application';
accountId?: string;
accountName?: string;
isShared?: boolean;
language?: string;
name?: ContactName;
nicknames?: Record<string, ContactNickname>;
@@ -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<string, CalendarRights> | 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<string, boolean>;
originalCalendarIds?: Record<string, boolean>;
accountId?: string;
accountName?: string;
isShared?: boolean;
isDraft: boolean;
isOrigin: boolean;
utcStart: string | null;
+12
View File
@@ -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",
+12
View File
@@ -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",
+12
View File
@@ -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",
+12
View File
@@ -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",
+12
View File
@@ -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",
+12
View File
@@ -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": "敬称",
+12
View File
@@ -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",
+12
View File
@@ -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",
+92 -12
View File
@@ -80,6 +80,7 @@ interface ContactStore {
clearSelection: () => void;
bulkDeleteContacts: (client: JMAPClient | null, ids: string[]) => Promise<void>;
bulkAddToGroup: (client: JMAPClient | null, groupId: string, contactIds: string[]) => Promise<void>;
moveContactToAddressBook: (client: JMAPClient, contactIds: string[], addressBook: AddressBook) => Promise<void>;
importContacts: (client: JMAPClient | null, contacts: ContactCard[]) => Promise<number>;
}
@@ -101,7 +102,7 @@ export const useContactStore = create<ContactStore>()(
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<ContactStore>()(
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<ContactStore>()(
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<ContactStore>()(
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<ContactStore>()(
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<ContactStore>()(
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<ContactStore>()(
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<ContactCard> = { 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<ContactStore>()(
const updates: Partial<ContactCard> = { 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<ContactStore>()(
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<ContactStore>()(
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<ContactStore>()(
set({ selectedContactIds: new Set<string>() });
},
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;