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 { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||||
import { useIsMobile } from "@/hooks/use-media-query";
|
import { useIsMobile } from "@/hooks/use-media-query";
|
||||||
import type { ContactCard } from "@/lib/jmap/types";
|
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
|
||||||
|
|
||||||
type View =
|
type View =
|
||||||
| "list"
|
| "list"
|
||||||
@@ -46,6 +46,7 @@ export default function ContactsPage() {
|
|||||||
const { quota, isPushConnected } = useEmailStore();
|
const { quota, isPushConnected } = useEmailStore();
|
||||||
const {
|
const {
|
||||||
contacts,
|
contacts,
|
||||||
|
addressBooks,
|
||||||
selectedContactId,
|
selectedContactId,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
supportsSync,
|
supportsSync,
|
||||||
@@ -71,6 +72,7 @@ export default function ContactsPage() {
|
|||||||
clearSelection,
|
clearSelection,
|
||||||
bulkDeleteContacts,
|
bulkDeleteContacts,
|
||||||
bulkAddToGroup,
|
bulkAddToGroup,
|
||||||
|
moveContactToAddressBook,
|
||||||
} = useContactStore();
|
} = useContactStore();
|
||||||
|
|
||||||
const [view, setView] = useState<View>("list");
|
const [view, setView] = useState<View>("list");
|
||||||
@@ -123,7 +125,21 @@ export default function ContactsPage() {
|
|||||||
|
|
||||||
// Contacts to display based on active category
|
// Contacts to display based on active category
|
||||||
const displayedContacts = useMemo(() => {
|
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
|
// Show members of the selected group
|
||||||
return getGroupMembers(activeCategory.groupId);
|
return getGroupMembers(activeCategory.groupId);
|
||||||
}, [activeCategory, individuals, getGroupMembers]);
|
}, [activeCategory, individuals, getGroupMembers]);
|
||||||
@@ -131,20 +147,38 @@ export default function ContactsPage() {
|
|||||||
// Label for the current category
|
// Label for the current category
|
||||||
const categoryLabel = useMemo(() => {
|
const categoryLabel = useMemo(() => {
|
||||||
if (activeCategory === "all") return t("tabs.all");
|
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);
|
const group = contacts.find(c => c.id === activeCategory.groupId);
|
||||||
return group ? getContactDisplayName(group) : t("tabs.all");
|
return group ? getContactDisplayName(group) : t("tabs.all");
|
||||||
}, [activeCategory, contacts, t]);
|
}, [activeCategory, contacts, addressBooks, t]);
|
||||||
|
|
||||||
const handleSelectCategory = useCallback((category: ContactCategory) => {
|
const handleSelectCategory = useCallback((category: ContactCategory) => {
|
||||||
setActiveCategory(category);
|
setActiveCategory(category);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
if (typeof category === "object") {
|
if (typeof category === "object" && "groupId" in category) {
|
||||||
setSelectedGroupId(category.groupId);
|
setSelectedGroupId(category.groupId);
|
||||||
} else {
|
} else {
|
||||||
setSelectedGroupId(null);
|
setSelectedGroupId(null);
|
||||||
}
|
}
|
||||||
}, [clearSelection]);
|
}, [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) => {
|
const handleSelectContact = (id: string) => {
|
||||||
setSelectedContact(id);
|
setSelectedContact(id);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
@@ -357,13 +391,14 @@ export default function ContactsPage() {
|
|||||||
const renderRightPanel = () => {
|
const renderRightPanel = () => {
|
||||||
switch (view) {
|
switch (view) {
|
||||||
case "create":
|
case "create":
|
||||||
return <ContactForm onSave={handleSaveNew} onCancel={handleCancel} />;
|
return <ContactForm addressBooks={addressBooks} onSave={handleSaveNew} onCancel={handleCancel} />;
|
||||||
|
|
||||||
case "edit":
|
case "edit":
|
||||||
if (!selectedContact) return null;
|
if (!selectedContact) return null;
|
||||||
return (
|
return (
|
||||||
<ContactForm
|
<ContactForm
|
||||||
contact={selectedContact}
|
contact={selectedContact}
|
||||||
|
addressBooks={addressBooks}
|
||||||
onSave={handleSaveEdit}
|
onSave={handleSaveEdit}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
/>
|
/>
|
||||||
@@ -508,10 +543,12 @@ export default function ContactsPage() {
|
|||||||
<ContactsSidebar
|
<ContactsSidebar
|
||||||
groups={groups}
|
groups={groups}
|
||||||
individuals={individuals}
|
individuals={individuals}
|
||||||
|
addressBooks={addressBooks}
|
||||||
activeCategory={activeCategory}
|
activeCategory={activeCategory}
|
||||||
onSelectCategory={handleSelectCategory}
|
onSelectCategory={handleSelectCategory}
|
||||||
onCreateGroup={handleCreateGroup}
|
onCreateGroup={handleCreateGroup}
|
||||||
onCreateContact={handleCreateNew}
|
onCreateContact={handleCreateNew}
|
||||||
|
onDropContacts={handleDropContacts}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ describe('ContactListItem', () => {
|
|||||||
density: 'regular' as const,
|
density: 'regular' as const,
|
||||||
onClick: vi.fn(),
|
onClick: vi.fn(),
|
||||||
onCheckboxClick: vi.fn(),
|
onCheckboxClick: vi.fn(),
|
||||||
|
selectedContactIds: new Set<string>(),
|
||||||
};
|
};
|
||||||
|
|
||||||
it('renders contact name and email', () => {
|
it('renders contact name and email', () => {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { cn } from "@/lib/utils";
|
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 {
|
interface EmailEntry {
|
||||||
address: string;
|
address: string;
|
||||||
@@ -47,6 +47,7 @@ interface AddressEntry {
|
|||||||
|
|
||||||
interface ContactFormProps {
|
interface ContactFormProps {
|
||||||
contact?: ContactCard | null;
|
contact?: ContactCard | null;
|
||||||
|
addressBooks?: AddressBook[];
|
||||||
onSave: (data: Partial<ContactCard>) => Promise<void>;
|
onSave: (data: Partial<ContactCard>) => Promise<void>;
|
||||||
onCancel: () => 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 t = useTranslations("contacts.form");
|
||||||
const isEditing = !!contact;
|
const isEditing = !!contact;
|
||||||
|
|
||||||
@@ -243,6 +244,23 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) {
|
|||||||
const [schedulingUri, setSchedulingUri] = useState(contact?.schedulingUri || "");
|
const [schedulingUri, setSchedulingUri] = useState(contact?.schedulingUri || "");
|
||||||
const [freeBusyUri, setFreeBusyUri] = useState(contact?.freeBusyUri || "");
|
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 [isSaving, setIsSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [emailErrors, setEmailErrors] = useState<Record<number, string>>({});
|
const [emailErrors, setEmailErrors] = useState<Record<number, string>>({});
|
||||||
@@ -382,6 +400,7 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) {
|
|||||||
calendarUri: calendarUri.trim() || undefined,
|
calendarUri: calendarUri.trim() || undefined,
|
||||||
schedulingUri: schedulingUri.trim() || undefined,
|
schedulingUri: schedulingUri.trim() || undefined,
|
||||||
freeBusyUri: freeBusyUri.trim() || undefined,
|
freeBusyUri: freeBusyUri.trim() || undefined,
|
||||||
|
...(selectedBookId ? { addressBookIds: { [selectedBookId]: true } } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
setIsSaving(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">
|
<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 */}
|
{/* Name & Identity — full width */}
|
||||||
<div className="md:col-span-2 xl:col-span-3">
|
<div className="md:col-span-2 xl:col-span-3">
|
||||||
<FormSection icon={User} title={t("section_identity")} category="contact">
|
<FormSection icon={User} title={t("section_identity")} category="contact">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, type DragEvent } from "react";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ContactCard } from "@/lib/jmap/types";
|
import type { ContactCard } from "@/lib/jmap/types";
|
||||||
@@ -13,19 +14,47 @@ interface ContactListItemProps {
|
|||||||
isChecked: boolean;
|
isChecked: boolean;
|
||||||
hasSelection: boolean;
|
hasSelection: boolean;
|
||||||
density: Density;
|
density: Density;
|
||||||
|
selectedContactIds: Set<string>;
|
||||||
onClick: (e: React.MouseEvent) => void;
|
onClick: (e: React.MouseEvent) => void;
|
||||||
onCheckboxClick: (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 name = getContactDisplayName(contact);
|
||||||
const email = getContactPrimaryEmail(contact);
|
const email = getContactPrimaryEmail(contact);
|
||||||
const org = contact.organizations
|
const org = contact.organizations
|
||||||
? Object.values(contact.organizations)[0]?.name
|
? Object.values(contact.organizations)[0]?.name
|
||||||
: undefined;
|
: 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
draggable
|
||||||
|
onDragStart={handleDragStart}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center cursor-pointer select-none transition-all duration-200 border-b border-border",
|
"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)}
|
isChecked={selectedContactIds.has(contact.id)}
|
||||||
hasSelection={hasSelection}
|
hasSelection={hasSelection}
|
||||||
density={density}
|
density={density}
|
||||||
|
selectedContactIds={selectedContactIds}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -1,32 +1,36 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState, useCallback, type DragEvent } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
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";
|
import { getContactDisplayName } from "@/stores/contact-store";
|
||||||
|
|
||||||
export type ContactCategory = "all" | { groupId: string };
|
export type ContactCategory = "all" | { groupId: string } | { addressBookId: string };
|
||||||
|
|
||||||
interface ContactsSidebarProps {
|
interface ContactsSidebarProps {
|
||||||
groups: ContactCard[];
|
groups: ContactCard[];
|
||||||
individuals: ContactCard[];
|
individuals: ContactCard[];
|
||||||
|
addressBooks: AddressBook[];
|
||||||
activeCategory: ContactCategory;
|
activeCategory: ContactCategory;
|
||||||
onSelectCategory: (category: ContactCategory) => void;
|
onSelectCategory: (category: ContactCategory) => void;
|
||||||
onCreateGroup: () => void;
|
onCreateGroup: () => void;
|
||||||
onCreateContact: () => void;
|
onCreateContact: () => void;
|
||||||
|
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContactsSidebar({
|
export function ContactsSidebar({
|
||||||
groups,
|
groups,
|
||||||
individuals,
|
individuals,
|
||||||
|
addressBooks,
|
||||||
activeCategory,
|
activeCategory,
|
||||||
onSelectCategory,
|
onSelectCategory,
|
||||||
onCreateGroup,
|
onCreateGroup,
|
||||||
onCreateContact,
|
onCreateContact,
|
||||||
|
onDropContacts,
|
||||||
className,
|
className,
|
||||||
}: ContactsSidebarProps) {
|
}: ContactsSidebarProps) {
|
||||||
const t = useTranslations("contacts");
|
const t = useTranslations("contacts");
|
||||||
@@ -39,6 +43,44 @@ export function ContactsSidebar({
|
|||||||
|
|
||||||
const isAllActive = activeCategory === "all";
|
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 (
|
return (
|
||||||
<div className={cn("flex flex-col h-full bg-secondary", className)}>
|
<div className={cn("flex flex-col h-full bg-secondary", className)}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -65,10 +107,31 @@ export function ContactsSidebar({
|
|||||||
<BookUser className="w-4 h-4 flex-shrink-0" />
|
<BookUser className="w-4 h-4 flex-shrink-0" />
|
||||||
<span className="truncate">{t("tabs.all")}</span>
|
<span className="truncate">{t("tabs.all")}</span>
|
||||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
||||||
{individuals.length}
|
{individuals.filter(c => !c.isShared).length}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</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 */}
|
{/* Groups section */}
|
||||||
{(sortedGroups.length > 0) && (
|
{(sortedGroups.length > 0) && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
@@ -82,7 +145,7 @@ export function ContactsSidebar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sortedGroups.map((group) => {
|
{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
|
const memberCount = group.members
|
||||||
? Object.values(group.members).filter(Boolean).length
|
? Object.values(group.members).filter(Boolean).length
|
||||||
: 0;
|
: 0;
|
||||||
@@ -128,7 +191,94 @@ export function ContactsSidebar({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
||||||
</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"];
|
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[]> {
|
async getAddressBooks(): Promise<AddressBook[]> {
|
||||||
try {
|
try {
|
||||||
const accountId = this.getContactsAccountId();
|
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[]> {
|
async getContacts(addressBookId?: string): Promise<ContactCard[]> {
|
||||||
try {
|
try {
|
||||||
const accountId = this.getContactsAccountId();
|
const accountId = this.getContactsAccountId();
|
||||||
@@ -2383,12 +2454,55 @@ export class JMAPClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getContact(contactId: string): Promise<ContactCard | null> {
|
async getAllContacts(): Promise<ContactCard[]> {
|
||||||
try {
|
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([
|
const response = await this.request([
|
||||||
["ContactCard/get", {
|
["ContactCard/get", {
|
||||||
accountId,
|
accountId: targetAccountId,
|
||||||
ids: [contactId],
|
ids: [contactId],
|
||||||
}, "0"]
|
}, "0"]
|
||||||
], this.contactUsing());
|
], this.contactUsing());
|
||||||
@@ -2404,8 +2518,8 @@ export class JMAPClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async createContact(contact: Partial<ContactCard>): Promise<ContactCard> {
|
async createContact(contact: Partial<ContactCard>, targetAccountId?: string): Promise<ContactCard> {
|
||||||
const accountId = this.getContactsAccountId();
|
const accountId = targetAccountId || this.getContactsAccountId();
|
||||||
let addressBookIds = contact.addressBookIds;
|
let addressBookIds = contact.addressBookIds;
|
||||||
if (!addressBookIds || Object.keys(addressBookIds).length === 0) {
|
if (!addressBookIds || Object.keys(addressBookIds).length === 0) {
|
||||||
const books = await this.getAddressBooks();
|
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([
|
const response = await this.request([
|
||||||
["ContactCard/set", {
|
["ContactCard/set", {
|
||||||
accountId,
|
accountId,
|
||||||
create: {
|
create: {
|
||||||
"new-contact": {
|
"new-contact": {
|
||||||
...contact,
|
...contactData,
|
||||||
addressBookIds,
|
addressBookIds,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2437,7 +2554,7 @@ export class JMAPClient {
|
|||||||
|
|
||||||
const createdId = result.created?.["new-contact"]?.id;
|
const createdId = result.created?.["new-contact"]?.id;
|
||||||
if (createdId) {
|
if (createdId) {
|
||||||
const created = await this.getContact(createdId);
|
const created = await this.getContact(createdId, accountId);
|
||||||
if (created) return created;
|
if (created) return created;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2445,14 +2562,17 @@ export class JMAPClient {
|
|||||||
throw new Error("Failed to create contact");
|
throw new Error("Failed to create contact");
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateContact(contactId: string, updates: Partial<ContactCard>): Promise<void> {
|
async updateContact(contactId: string, updates: Partial<ContactCard>, targetAccountId?: string): Promise<void> {
|
||||||
const accountId = this.getContactsAccountId();
|
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([
|
const response = await this.request([
|
||||||
["ContactCard/set", {
|
["ContactCard/set", {
|
||||||
accountId,
|
accountId,
|
||||||
update: {
|
update: {
|
||||||
[contactId]: updates
|
[contactId]: cleanUpdates
|
||||||
}
|
}
|
||||||
}, "0"]
|
}, "0"]
|
||||||
], this.contactUsing());
|
], this.contactUsing());
|
||||||
@@ -2470,8 +2590,8 @@ export class JMAPClient {
|
|||||||
throw new Error("Failed to update contact");
|
throw new Error("Failed to update contact");
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteContact(contactId: string): Promise<void> {
|
async deleteContact(contactId: string, targetAccountId?: string): Promise<void> {
|
||||||
const accountId = this.getContactsAccountId();
|
const accountId = targetAccountId || this.getContactsAccountId();
|
||||||
|
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["ContactCard/set", {
|
["ContactCard/set", {
|
||||||
@@ -2495,24 +2615,45 @@ export class JMAPClient {
|
|||||||
|
|
||||||
async searchContacts(query: string): Promise<ContactCard[]> {
|
async searchContacts(query: string): Promise<ContactCard[]> {
|
||||||
try {
|
try {
|
||||||
const accountId = this.getContactsAccountId();
|
const allResults: ContactCard[] = [];
|
||||||
|
const primaryId = this.getContactsAccountId();
|
||||||
|
const accountIds = this.getContactCapableAccountIds();
|
||||||
|
|
||||||
const response = await this.request([
|
for (const accountId of accountIds) {
|
||||||
["ContactCard/query", {
|
const isPrimary = accountId === primaryId;
|
||||||
accountId,
|
const account = this.accounts[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") {
|
try {
|
||||||
return (response.methodResponses[1][1].list || []) as ContactCard[];
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to search contacts:', error);
|
console.error('Failed to search contacts:', error);
|
||||||
return [];
|
return [];
|
||||||
@@ -2536,8 +2677,47 @@ export class JMAPClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async createCalendar(calendar: Partial<Calendar>): Promise<Calendar> {
|
async getAllCalendars(): Promise<Calendar[]> {
|
||||||
const accountId = this.getCalendarsAccountId();
|
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([
|
const response = await this.request([
|
||||||
["Calendar/set", {
|
["Calendar/set", {
|
||||||
@@ -2558,17 +2738,23 @@ export class JMAPClient {
|
|||||||
|
|
||||||
const createdId = result.created?.["new-calendar"]?.id;
|
const createdId = result.created?.["new-calendar"]?.id;
|
||||||
if (createdId) {
|
if (createdId) {
|
||||||
const calendars = await this.getCalendars();
|
// Fetch from the target account to find the created calendar
|
||||||
const created = calendars.find(c => c.id === createdId);
|
const fetchAccountId = targetAccountId || this.getCalendarsAccountId();
|
||||||
if (created) return created;
|
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");
|
throw new Error("Failed to create calendar");
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateCalendar(calendarId: string, updates: Partial<Calendar>): Promise<void> {
|
async updateCalendar(calendarId: string, updates: Partial<Calendar>, targetAccountId?: string): Promise<void> {
|
||||||
const accountId = this.getCalendarsAccountId();
|
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||||
|
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["Calendar/set", {
|
["Calendar/set", {
|
||||||
@@ -2592,8 +2778,8 @@ export class JMAPClient {
|
|||||||
throw new Error("Failed to update calendar");
|
throw new Error("Failed to update calendar");
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteCalendar(calendarId: string): Promise<void> {
|
async deleteCalendar(calendarId: string, targetAccountId?: string): Promise<void> {
|
||||||
const accountId = this.getCalendarsAccountId();
|
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||||
|
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["Calendar/set", {
|
["Calendar/set", {
|
||||||
@@ -2616,8 +2802,8 @@ export class JMAPClient {
|
|||||||
throw new Error("Failed to delete calendar");
|
throw new Error("Failed to delete calendar");
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCalendarEvents(calendarIds?: string[]): Promise<CalendarEvent[]> {
|
async getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise<CalendarEvent[]> {
|
||||||
const accountId = this.getCalendarsAccountId();
|
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||||
|
|
||||||
const queryArgs: Record<string, unknown> = { accountId, limit: 1000 };
|
const queryArgs: Record<string, unknown> = { accountId, limit: 1000 };
|
||||||
if (calendarIds && calendarIds.length > 0) {
|
if (calendarIds && calendarIds.length > 0) {
|
||||||
@@ -2644,13 +2830,55 @@ export class JMAPClient {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async queryCalendarEvents(
|
async queryAllCalendarEvents(
|
||||||
filter: CalendarEventFilter,
|
filter: CalendarEventFilter,
|
||||||
sort?: Array<{ property: string; isAscending: boolean }>,
|
sort?: Array<{ property: string; isAscending: boolean }>,
|
||||||
limit?: number
|
limit?: number
|
||||||
): Promise<CalendarEvent[]> {
|
): Promise<CalendarEvent[]> {
|
||||||
try {
|
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> = {
|
const queryArgs: Record<string, unknown> = {
|
||||||
accountId,
|
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 {
|
try {
|
||||||
const accountId = this.getCalendarsAccountId();
|
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["CalendarEvent/get", {
|
["CalendarEvent/get", {
|
||||||
accountId,
|
accountId,
|
||||||
@@ -2700,13 +2928,16 @@ export class JMAPClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async createCalendarEvent(event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean): Promise<CalendarEvent> {
|
async createCalendarEvent(event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<CalendarEvent> {
|
||||||
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, ...cleanEvent } = event as CalendarEvent;
|
||||||
|
|
||||||
const setArgs: Record<string, unknown> = {
|
const setArgs: Record<string, unknown> = {
|
||||||
accountId,
|
accountId,
|
||||||
create: {
|
create: {
|
||||||
"new-event": event
|
"new-event": cleanEvent
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (sendSchedulingMessages !== undefined) {
|
if (sendSchedulingMessages !== undefined) {
|
||||||
@@ -2727,7 +2958,7 @@ export class JMAPClient {
|
|||||||
|
|
||||||
const createdId = result.created?.["new-event"]?.id;
|
const createdId = result.created?.["new-event"]?.id;
|
||||||
if (createdId) {
|
if (createdId) {
|
||||||
const created = await this.getCalendarEvent(createdId);
|
const created = await this.getCalendarEvent(createdId, targetAccountId);
|
||||||
if (created) return created;
|
if (created) return created;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2738,14 +2969,18 @@ export class JMAPClient {
|
|||||||
async updateCalendarEvent(
|
async updateCalendarEvent(
|
||||||
eventId: string,
|
eventId: string,
|
||||||
updates: Partial<CalendarEvent>,
|
updates: Partial<CalendarEvent>,
|
||||||
sendSchedulingMessages?: boolean
|
sendSchedulingMessages?: boolean,
|
||||||
|
targetAccountId?: string
|
||||||
): Promise<void> {
|
): 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> = {
|
const setArgs: Record<string, unknown> = {
|
||||||
accountId,
|
accountId,
|
||||||
update: {
|
update: {
|
||||||
[eventId]: updates
|
[eventId]: cleanUpdates
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (sendSchedulingMessages !== undefined) {
|
if (sendSchedulingMessages !== undefined) {
|
||||||
@@ -2800,8 +3035,8 @@ export class JMAPClient {
|
|||||||
throw new Error("Failed to parse calendar file");
|
throw new Error("Failed to parse calendar file");
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteCalendarEvent(eventId: string, sendSchedulingMessages?: boolean): Promise<void> {
|
async deleteCalendarEvent(eventId: string, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<void> {
|
||||||
const accountId = this.getCalendarsAccountId();
|
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||||
|
|
||||||
const setArgs: Record<string, unknown> = {
|
const setArgs: Record<string, unknown> = {
|
||||||
accountId,
|
accountId,
|
||||||
@@ -2828,10 +3063,10 @@ export class JMAPClient {
|
|||||||
throw new Error("Failed to delete calendar event");
|
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: [] };
|
if (eventIds.length === 0) return { destroyed: [], notDestroyed: [] };
|
||||||
|
|
||||||
const accountId = this.getCalendarsAccountId();
|
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["CalendarEvent/set", { accountId, destroy: eventIds }, "0"]
|
["CalendarEvent/set", { accountId, destroy: eventIds }, "0"]
|
||||||
], this.calendarUsing());
|
], this.calendarUsing());
|
||||||
|
|||||||
@@ -160,9 +160,13 @@ export interface Identity {
|
|||||||
|
|
||||||
export interface ContactCard {
|
export interface ContactCard {
|
||||||
id: string;
|
id: string;
|
||||||
|
originalId?: string;
|
||||||
uid?: string;
|
uid?: string;
|
||||||
addressBookIds: Record<string, boolean>;
|
addressBookIds: Record<string, boolean>;
|
||||||
kind?: 'individual' | 'group' | 'org' | 'location' | 'device' | 'application';
|
kind?: 'individual' | 'group' | 'org' | 'location' | 'device' | 'application';
|
||||||
|
accountId?: string;
|
||||||
|
accountName?: string;
|
||||||
|
isShared?: boolean;
|
||||||
language?: string;
|
language?: string;
|
||||||
name?: ContactName;
|
name?: ContactName;
|
||||||
nicknames?: Record<string, ContactNickname>;
|
nicknames?: Record<string, ContactNickname>;
|
||||||
@@ -319,12 +323,16 @@ export interface ContactRelation {
|
|||||||
|
|
||||||
export interface AddressBook {
|
export interface AddressBook {
|
||||||
id: string;
|
id: string;
|
||||||
|
originalId?: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
isSubscribed?: boolean;
|
isSubscribed?: boolean;
|
||||||
myRights?: AddressBookRights;
|
myRights?: AddressBookRights;
|
||||||
|
accountId?: string;
|
||||||
|
accountName?: string;
|
||||||
|
isShared?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AddressBookRights {
|
export interface AddressBookRights {
|
||||||
@@ -370,6 +378,7 @@ export interface DeliveryStatus {
|
|||||||
|
|
||||||
export interface Calendar {
|
export interface Calendar {
|
||||||
id: string;
|
id: string;
|
||||||
|
originalId?: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
color: string | null;
|
color: string | null;
|
||||||
@@ -383,6 +392,9 @@ export interface Calendar {
|
|||||||
timeZone: string | null;
|
timeZone: string | null;
|
||||||
shareWith: Record<string, CalendarRights> | null;
|
shareWith: Record<string, CalendarRights> | null;
|
||||||
myRights: CalendarRights;
|
myRights: CalendarRights;
|
||||||
|
accountId?: string;
|
||||||
|
accountName?: string;
|
||||||
|
isShared?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CalendarRights {
|
export interface CalendarRights {
|
||||||
@@ -398,7 +410,12 @@ export interface CalendarRights {
|
|||||||
|
|
||||||
export interface CalendarEvent {
|
export interface CalendarEvent {
|
||||||
id: string;
|
id: string;
|
||||||
|
originalId?: string;
|
||||||
calendarIds: Record<string, boolean>;
|
calendarIds: Record<string, boolean>;
|
||||||
|
originalCalendarIds?: Record<string, boolean>;
|
||||||
|
accountId?: string;
|
||||||
|
accountName?: string;
|
||||||
|
isShared?: boolean;
|
||||||
isDraft: boolean;
|
isDraft: boolean;
|
||||||
isOrigin: boolean;
|
isOrigin: boolean;
|
||||||
utcStart: string | null;
|
utcStart: string | null;
|
||||||
|
|||||||
@@ -1469,6 +1469,16 @@
|
|||||||
"all": "Alle",
|
"all": "Alle",
|
||||||
"groups": "Gruppen"
|
"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": {
|
"detail": {
|
||||||
"emails": "E-Mail-Adressen",
|
"emails": "E-Mail-Adressen",
|
||||||
"phones": "Telefonnummern",
|
"phones": "Telefonnummern",
|
||||||
@@ -1524,6 +1534,8 @@
|
|||||||
"form": {
|
"form": {
|
||||||
"create_title": "Neuer Kontakt",
|
"create_title": "Neuer Kontakt",
|
||||||
"edit_title": "Kontakt bearbeiten",
|
"edit_title": "Kontakt bearbeiten",
|
||||||
|
"section_address_book": "Verzeichnis",
|
||||||
|
"select_address_book": "Verzeichnis auswählen...",
|
||||||
"section_identity": "Name & Identität",
|
"section_identity": "Name & Identität",
|
||||||
"section_work": "Beruf & Organisation",
|
"section_work": "Beruf & Organisation",
|
||||||
"prefix": "Anrede",
|
"prefix": "Anrede",
|
||||||
|
|||||||
@@ -1469,6 +1469,16 @@
|
|||||||
"all": "All",
|
"all": "All",
|
||||||
"groups": "Groups"
|
"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": {
|
"detail": {
|
||||||
"emails": "Email Addresses",
|
"emails": "Email Addresses",
|
||||||
"phones": "Phone Numbers",
|
"phones": "Phone Numbers",
|
||||||
@@ -1524,6 +1534,8 @@
|
|||||||
"form": {
|
"form": {
|
||||||
"create_title": "New Contact",
|
"create_title": "New Contact",
|
||||||
"edit_title": "Edit Contact",
|
"edit_title": "Edit Contact",
|
||||||
|
"section_address_book": "Directory",
|
||||||
|
"select_address_book": "Select a directory...",
|
||||||
"section_identity": "Name & Identity",
|
"section_identity": "Name & Identity",
|
||||||
"section_work": "Work & Organization",
|
"section_work": "Work & Organization",
|
||||||
"prefix": "Prefix",
|
"prefix": "Prefix",
|
||||||
|
|||||||
@@ -1469,6 +1469,16 @@
|
|||||||
"all": "Todos",
|
"all": "Todos",
|
||||||
"groups": "Grupos"
|
"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": {
|
"detail": {
|
||||||
"emails": "Direcciones de correo",
|
"emails": "Direcciones de correo",
|
||||||
"phones": "Números de teléfono",
|
"phones": "Números de teléfono",
|
||||||
@@ -1524,6 +1534,8 @@
|
|||||||
"form": {
|
"form": {
|
||||||
"create_title": "Nuevo contacto",
|
"create_title": "Nuevo contacto",
|
||||||
"edit_title": "Editar contacto",
|
"edit_title": "Editar contacto",
|
||||||
|
"section_address_book": "Directorio",
|
||||||
|
"select_address_book": "Seleccionar un directorio...",
|
||||||
"section_identity": "Nombre e identidad",
|
"section_identity": "Nombre e identidad",
|
||||||
"section_work": "Trabajo y organización",
|
"section_work": "Trabajo y organización",
|
||||||
"prefix": "Prefijo",
|
"prefix": "Prefijo",
|
||||||
|
|||||||
@@ -1469,6 +1469,16 @@
|
|||||||
"all": "Tous",
|
"all": "Tous",
|
||||||
"groups": "Groupes"
|
"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": {
|
"detail": {
|
||||||
"emails": "Adresses e-mail",
|
"emails": "Adresses e-mail",
|
||||||
"phones": "Numéros de téléphone",
|
"phones": "Numéros de téléphone",
|
||||||
@@ -1524,6 +1534,8 @@
|
|||||||
"form": {
|
"form": {
|
||||||
"create_title": "Nouveau contact",
|
"create_title": "Nouveau contact",
|
||||||
"edit_title": "Modifier le 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_identity": "Nom et identité",
|
||||||
"section_work": "Travail et organisation",
|
"section_work": "Travail et organisation",
|
||||||
"prefix": "Préfixe",
|
"prefix": "Préfixe",
|
||||||
|
|||||||
@@ -1469,6 +1469,16 @@
|
|||||||
"all": "Tutti",
|
"all": "Tutti",
|
||||||
"groups": "Gruppi"
|
"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": {
|
"detail": {
|
||||||
"emails": "Indirizzi email",
|
"emails": "Indirizzi email",
|
||||||
"phones": "Numeri di telefono",
|
"phones": "Numeri di telefono",
|
||||||
@@ -1524,6 +1534,8 @@
|
|||||||
"form": {
|
"form": {
|
||||||
"create_title": "Nuovo contatto",
|
"create_title": "Nuovo contatto",
|
||||||
"edit_title": "Modifica contatto",
|
"edit_title": "Modifica contatto",
|
||||||
|
"section_address_book": "Rubrica",
|
||||||
|
"select_address_book": "Seleziona una rubrica...",
|
||||||
"section_identity": "Nome e identità",
|
"section_identity": "Nome e identità",
|
||||||
"section_work": "Lavoro e organizzazione",
|
"section_work": "Lavoro e organizzazione",
|
||||||
"prefix": "Prefisso",
|
"prefix": "Prefisso",
|
||||||
|
|||||||
@@ -1469,6 +1469,16 @@
|
|||||||
"all": "すべて",
|
"all": "すべて",
|
||||||
"groups": "グループ"
|
"groups": "グループ"
|
||||||
},
|
},
|
||||||
|
"shared": {
|
||||||
|
"title": "共有"
|
||||||
|
},
|
||||||
|
"address_books": {
|
||||||
|
"title": "ディレクトリ",
|
||||||
|
"moved": "連絡先を {name} に移動しました",
|
||||||
|
"moved_plural": "{count} 件の連絡先を {name} に移動しました",
|
||||||
|
"move_failed": "連絡先の移動に失敗しました",
|
||||||
|
"address_book": "ディレクトリ"
|
||||||
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "メールアドレス",
|
"emails": "メールアドレス",
|
||||||
"phones": "電話番号",
|
"phones": "電話番号",
|
||||||
@@ -1524,6 +1534,8 @@
|
|||||||
"form": {
|
"form": {
|
||||||
"create_title": "新しい連絡先",
|
"create_title": "新しい連絡先",
|
||||||
"edit_title": "連絡先を編集",
|
"edit_title": "連絡先を編集",
|
||||||
|
"section_address_book": "ディレクトリ",
|
||||||
|
"select_address_book": "ディレクトリを選択...",
|
||||||
"section_identity": "名前と識別情報",
|
"section_identity": "名前と識別情報",
|
||||||
"section_work": "職業と組織",
|
"section_work": "職業と組織",
|
||||||
"prefix": "敬称",
|
"prefix": "敬称",
|
||||||
|
|||||||
@@ -1469,6 +1469,16 @@
|
|||||||
"all": "Alle",
|
"all": "Alle",
|
||||||
"groups": "Groepen"
|
"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": {
|
"detail": {
|
||||||
"emails": "E-mailadressen",
|
"emails": "E-mailadressen",
|
||||||
"phones": "Telefoonnummers",
|
"phones": "Telefoonnummers",
|
||||||
@@ -1524,6 +1534,8 @@
|
|||||||
"form": {
|
"form": {
|
||||||
"create_title": "Nieuw contact",
|
"create_title": "Nieuw contact",
|
||||||
"edit_title": "Contact bewerken",
|
"edit_title": "Contact bewerken",
|
||||||
|
"section_address_book": "Adresboek",
|
||||||
|
"select_address_book": "Selecteer een adresboek...",
|
||||||
"section_identity": "Naam en identiteit",
|
"section_identity": "Naam en identiteit",
|
||||||
"section_work": "Werk en organisatie",
|
"section_work": "Werk en organisatie",
|
||||||
"prefix": "Voorvoegsel",
|
"prefix": "Voorvoegsel",
|
||||||
|
|||||||
@@ -1469,6 +1469,16 @@
|
|||||||
"all": "Todos",
|
"all": "Todos",
|
||||||
"groups": "Grupos"
|
"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": {
|
"detail": {
|
||||||
"emails": "Endereços de e-mail",
|
"emails": "Endereços de e-mail",
|
||||||
"phones": "Números de telefone",
|
"phones": "Números de telefone",
|
||||||
@@ -1524,6 +1534,8 @@
|
|||||||
"form": {
|
"form": {
|
||||||
"create_title": "Novo contato",
|
"create_title": "Novo contato",
|
||||||
"edit_title": "Editar contato",
|
"edit_title": "Editar contato",
|
||||||
|
"section_address_book": "Diretório",
|
||||||
|
"select_address_book": "Selecionar um diretório...",
|
||||||
"section_identity": "Nome e identidade",
|
"section_identity": "Nome e identidade",
|
||||||
"section_work": "Trabalho e organização",
|
"section_work": "Trabalho e organização",
|
||||||
"prefix": "Prefixo",
|
"prefix": "Prefixo",
|
||||||
|
|||||||
+92
-12
@@ -80,6 +80,7 @@ interface ContactStore {
|
|||||||
clearSelection: () => void;
|
clearSelection: () => void;
|
||||||
bulkDeleteContacts: (client: JMAPClient | null, ids: string[]) => Promise<void>;
|
bulkDeleteContacts: (client: JMAPClient | null, ids: string[]) => Promise<void>;
|
||||||
bulkAddToGroup: (client: JMAPClient | null, groupId: string, contactIds: 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>;
|
importContacts: (client: JMAPClient | null, contacts: ContactCard[]) => Promise<number>;
|
||||||
}
|
}
|
||||||
@@ -101,7 +102,7 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
fetchContacts: async (client) => {
|
fetchContacts: async (client) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const contacts = await client.getContacts();
|
const contacts = await client.getAllContacts();
|
||||||
set({ contacts, isLoading: false });
|
set({ contacts, isLoading: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch contacts:', error);
|
console.error('Failed to fetch contacts:', error);
|
||||||
@@ -111,7 +112,7 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
|
|
||||||
fetchAddressBooks: async (client) => {
|
fetchAddressBooks: async (client) => {
|
||||||
try {
|
try {
|
||||||
const addressBooks = await client.getAddressBooks();
|
const addressBooks = await client.getAllAddressBooks();
|
||||||
set({ addressBooks });
|
set({ addressBooks });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch address books:', error);
|
console.error('Failed to fetch address books:', error);
|
||||||
@@ -122,7 +123,16 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
createContact: async (client, contact) => {
|
createContact: async (client, contact) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
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) => ({
|
set((state) => ({
|
||||||
contacts: [...state.contacts, created],
|
contacts: [...state.contacts, created],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -137,7 +147,10 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
updateContact: async (client, id, updates) => {
|
updateContact: async (client, id, updates) => {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
try {
|
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) => ({
|
set((state) => ({
|
||||||
contacts: state.contacts.map(c =>
|
contacts: state.contacts.map(c =>
|
||||||
c.id === id ? { ...c, ...updates } : c
|
c.id === id ? { ...c, ...updates } : c
|
||||||
@@ -153,7 +166,10 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
deleteContact: async (client, id) => {
|
deleteContact: async (client, id) => {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
try {
|
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) => ({
|
set((state) => ({
|
||||||
contacts: state.contacts.filter(c => c.id !== id),
|
contacts: state.contacts.filter(c => c.id !== id),
|
||||||
selectedContactId: state.selectedContactId === id ? null : state.selectedContactId,
|
selectedContactId: state.selectedContactId === id ? null : state.selectedContactId,
|
||||||
@@ -296,7 +312,10 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
name: { components: [{ kind: 'given', value: name }], isOrdered: true },
|
name: { components: [{ kind: 'given', value: name }], isOrdered: true },
|
||||||
};
|
};
|
||||||
if (client && get().supportsSync) {
|
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) => ({
|
set((state) => ({
|
||||||
contacts: state.contacts.map(c =>
|
contacts: state.contacts.map(c =>
|
||||||
@@ -313,13 +332,15 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
const newMembers = { ...group.members };
|
const newMembers = { ...group.members };
|
||||||
memberIds.forEach(id => {
|
memberIds.forEach(id => {
|
||||||
const contact = contacts.find(c => c.id === id);
|
const contact = contacts.find(c => c.id === id);
|
||||||
const key = contact?.uid || id;
|
const key = contact?.uid || contact?.originalId || id;
|
||||||
newMembers[key] = true;
|
newMembers[key] = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const updates: Partial<ContactCard> = { members: newMembers };
|
const updates: Partial<ContactCard> = { members: newMembers };
|
||||||
if (client && get().supportsSync) {
|
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) => ({
|
set((state) => ({
|
||||||
contacts: state.contacts.map(c =>
|
contacts: state.contacts.map(c =>
|
||||||
@@ -359,7 +380,9 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
|
|
||||||
const updates: Partial<ContactCard> = { members: newMembers };
|
const updates: Partial<ContactCard> = { members: newMembers };
|
||||||
if (client && get().supportsSync) {
|
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) => ({
|
set((state) => ({
|
||||||
contacts: state.contacts.map(c =>
|
contacts: state.contacts.map(c =>
|
||||||
@@ -370,7 +393,10 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
|
|
||||||
deleteGroup: async (client, groupId) => {
|
deleteGroup: async (client, groupId) => {
|
||||||
if (client && get().supportsSync) {
|
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) => ({
|
set((state) => ({
|
||||||
contacts: state.contacts.filter(c => c.id !== groupId),
|
contacts: state.contacts.filter(c => c.id !== groupId),
|
||||||
@@ -410,13 +436,16 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
|
|
||||||
bulkDeleteContacts: async (client, ids) => {
|
bulkDeleteContacts: async (client, ids) => {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
const { supportsSync } = get();
|
const { supportsSync, contacts } = get();
|
||||||
const deletedIds = new Set(ids);
|
const deletedIds = new Set(ids);
|
||||||
|
|
||||||
if (client && supportsSync) {
|
if (client && supportsSync) {
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
console.error(`Failed to delete contact ${id}:`, error);
|
console.error(`Failed to delete contact ${id}:`, error);
|
||||||
deletedIds.delete(id);
|
deletedIds.delete(id);
|
||||||
@@ -439,6 +468,57 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
set({ selectedContactIds: new Set<string>() });
|
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) => {
|
importContacts: async (client, contacts) => {
|
||||||
const { supportsSync } = get();
|
const { supportsSync } = get();
|
||||||
let imported = 0;
|
let imported = 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user