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:
@@ -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', () => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "敬称",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user