feat: Ability to rename address book #152

This commit is contained in:
Linus Rath
2026-04-08 13:05:42 +02:00
parent a9b9aeb44d
commit ffad3ea78b
21 changed files with 712 additions and 54 deletions
+47
View File
@@ -13,6 +13,7 @@ import { ContactGroupForm } from "@/components/contacts/contact-group-form";
import { ContactGroupDetail } from "@/components/contacts/contact-group-detail";
import { ContactsSidebar, type ContactCategory } from "@/components/contacts/contacts-sidebar";
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
import { RenameDialog } from "@/components/files/rename-dialog";
import { exportContacts } from "@/components/contacts/contact-export";
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
@@ -72,12 +73,16 @@ export default function ContactsPage() {
bulkDeleteContacts,
bulkAddToGroup,
moveContactToAddressBook,
renameAddressBook,
renameKeyword,
importContacts,
} = useContactStore();
const [view, setView] = useState<View>("list");
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
const [showImportDialog, setShowImportDialog] = useState(false);
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null);
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const hasFetched = useRef(false);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
@@ -631,6 +636,8 @@ export default function ContactsPage() {
onDeleteGroup={handleDeleteGroupFromSidebar}
onDropContacts={handleDropContacts}
onDropContactsToCategory={handleDropContactsToCategory}
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
/>
</div>
<ResizeHandle
@@ -725,6 +732,46 @@ export default function ContactsPage() {
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<ConfirmDialog {...confirmDialogProps} />
{renamingKeyword !== null && (
<RenameDialog
currentName={renamingKeyword}
title={t("rename_category")}
label={t("category_name_label")}
onCancel={() => setRenamingKeyword(null)}
onConfirm={async (newName) => {
try {
await renameKeyword(supportsSync && client ? client : null, renamingKeyword, newName);
toast.success(t("category_renamed"));
if (typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === renamingKeyword) {
setActiveCategory({ keyword: newName.trim() });
}
setRenamingKeyword(null);
} catch (err) {
console.error("Failed to rename category:", err);
toast.error(t("category_rename_failed"));
}
}}
/>
)}
{renamingAddressBook && (
<RenameDialog
currentName={renamingAddressBook.name}
title={t("address_books.rename")}
label={t("address_books.name_label")}
onCancel={() => setRenamingAddressBook(null)}
onConfirm={async (newName) => {
if (!client) return;
try {
await renameAddressBook(client, renamingAddressBook, newName);
toast.success(t("address_books.renamed"));
setRenamingAddressBook(null);
} catch (err) {
console.error("Failed to rename address book:", err);
toast.error(t("address_books.rename_failed"));
}
}}
/>
)}
{showImportDialog && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-background rounded-lg border border-border shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden">
+2 -1
View File
@@ -36,6 +36,7 @@ import { IdentitySettings } from '@/components/settings/identity-settings';
import { VacationSettings } from '@/components/settings/vacation-settings';
import { CalendarSettings } from '@/components/settings/calendar-settings';
import { CalendarManagementSettings } from '@/components/settings/calendar-management-settings';
import { AddressBookManagementSettings } from '@/components/settings/address-book-management-settings';
import { FilterSettings } from '@/components/settings/filter-settings';
import { TemplateSettings } from '@/components/settings/template-settings';
import { AdvancedSettings } from '@/components/settings/advanced-settings';
@@ -211,7 +212,7 @@ export default function SettingsPage() {
{activeTab === 'encryption' && <SmimeSettings />}
{activeTab === 'vacation' && <VacationSettings />}
{activeTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
{activeTab === 'contacts' && <ContactsSettings />}
{activeTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
{activeTab === 'filters' && <FilterSettings />}
{activeTab === 'templates' && <TemplateSettings />}
{activeTab === 'folders' && <FolderSettings />}
+111 -28
View File
@@ -2,7 +2,8 @@
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
import { useTranslations } from "next-intl";
import { BookUser, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2 } from "lucide-react";
import { BookUser, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings } from "lucide-react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
@@ -25,6 +26,8 @@ interface ContactsSidebarProps {
onDeleteGroup?: (groupId: string) => void;
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
onRenameAddressBook?: (addressBook: AddressBook) => void;
onRenameKeyword?: (keyword: string) => void;
className?: string;
}
@@ -58,10 +61,15 @@ export function ContactsSidebar({
onDeleteGroup,
onDropContacts,
onDropContactsToCategory,
onRenameAddressBook,
onRenameKeyword,
className,
}: ContactsSidebarProps) {
const t = useTranslations("contacts");
const router = useRouter();
const { contextMenu: groupContextMenu, openContextMenu: openGroupContextMenu, closeContextMenu: closeGroupContextMenu, menuRef: groupMenuRef } = useContextMenu<ContactCard>();
const { contextMenu: bookContextMenu, openContextMenu: openBookContextMenu, closeContextMenu: closeBookContextMenu, menuRef: bookMenuRef } = useContextMenu<AddressBook>();
const { contextMenu: keywordContextMenu, openContextMenu: openKeywordContextMenu, closeContextMenu: closeKeywordContextMenu, menuRef: keywordMenuRef } = useContextMenu<string>();
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(loadCollapsed);
const [showMenu, setShowMenu] = useState(false);
@@ -246,19 +254,32 @@ export function ContactsSidebar({
{/* My Address Books */}
{personalBooks.length > 0 && (
<div className="mt-2">
<button
onClick={() => toggleSection("addressBooks")}
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
>
{collapsed.addressBooks ? (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
)}
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{t("address_books.title")}
</span>
</button>
<div className="flex items-center px-3 py-1 group">
<button
onClick={() => toggleSection("addressBooks")}
className="flex items-center gap-1 flex-1 text-left"
>
{collapsed.addressBooks ? (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
)}
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{t("address_books.title")}
</span>
</button>
<button
onClick={(e) => {
e.stopPropagation();
try { localStorage.setItem('settings-active-tab', 'contacts'); } catch { /* ignore */ }
router.push('/settings');
}}
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted"
title={t("address_books.manage")}
>
<Settings className="w-3 h-3 text-muted-foreground" />
</button>
</div>
{!collapsed.addressBooks && personalBooks.map((book) => (
<AddressBookItem
key={book.id}
@@ -267,6 +288,7 @@ export function ContactsSidebar({
contactCount={contactCountByBook[book.id] || 0}
onSelect={() => onSelectCategory({ addressBookId: book.id })}
onDropContacts={onDropContacts}
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
/>
))}
</div>
@@ -362,6 +384,7 @@ export function ContactsSidebar({
isActive={isActive}
onSelect={() => onSelectCategory({ keyword })}
onDropContacts={onDropContactsToCategory}
onContextMenu={onRenameKeyword ? (e) => openKeywordContextMenu(e, keyword) : undefined}
/>
);
})}
@@ -372,20 +395,33 @@ export function ContactsSidebar({
{/* Shared accounts with address books */}
{sharedBookGroups.map((group) => (
<div key={group.accountId} className="mt-2">
<button
onClick={() => toggleSection(`shared-${group.accountId}`)}
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
>
{collapsed[`shared-${group.accountId}`] ? (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
)}
<Share2 className="w-3 h-3 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider truncate">
{t("address_books.shared_prefix", { name: group.accountName })}
</span>
</button>
<div className="flex items-center px-3 py-1 group">
<button
onClick={() => toggleSection(`shared-${group.accountId}`)}
className="flex items-center gap-1 flex-1 min-w-0 text-left"
>
{collapsed[`shared-${group.accountId}`] ? (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
)}
<Share2 className="w-3 h-3 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider truncate">
{t("address_books.shared_prefix", { name: group.accountName })}
</span>
</button>
<button
onClick={(e) => {
e.stopPropagation();
try { localStorage.setItem('settings-active-tab', 'contacts'); } catch { /* ignore */ }
router.push('/settings');
}}
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted"
title={t("address_books.manage")}
>
<Settings className="w-3 h-3 text-muted-foreground" />
</button>
</div>
{!collapsed[`shared-${group.accountId}`] && group.books.map((book) => (
<AddressBookItem
key={book.id}
@@ -394,12 +430,53 @@ export function ContactsSidebar({
contactCount={contactCountByBook[book.id] || 0}
onSelect={() => onSelectCategory({ addressBookId: book.id })}
onDropContacts={onDropContacts}
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
/>
))}
</div>
))}
</div>
{/* Address book context menu */}
{bookContextMenu.data && onRenameAddressBook && (
<ContextMenu
ref={bookMenuRef}
isOpen={bookContextMenu.isOpen}
position={bookContextMenu.position}
onClose={closeBookContextMenu}
>
<ContextMenuItem
icon={Pencil}
label={t("address_books.rename")}
onClick={() => {
const book = bookContextMenu.data!;
closeBookContextMenu();
onRenameAddressBook(book);
}}
/>
</ContextMenu>
)}
{/* Keyword (category) context menu */}
{keywordContextMenu.data && onRenameKeyword && (
<ContextMenu
ref={keywordMenuRef}
isOpen={keywordContextMenu.isOpen}
position={keywordContextMenu.position}
onClose={closeKeywordContextMenu}
>
<ContextMenuItem
icon={Pencil}
label={t("rename_category")}
onClick={() => {
const kw = keywordContextMenu.data!;
closeKeywordContextMenu();
onRenameKeyword(kw);
}}
/>
</ContextMenu>
)}
{/* Group context menu */}
{groupContextMenu.data && (
<ContextMenu
@@ -438,12 +515,14 @@ function CategoryItem({
isActive,
onSelect,
onDropContacts,
onContextMenu,
}: {
keyword: string;
count: number;
isActive: boolean;
onSelect: () => void;
onDropContacts?: (contactIds: string[], keyword: string) => void;
onContextMenu?: (e: React.MouseEvent<HTMLButtonElement>) => void;
}) {
const [isDragOver, setIsDragOver] = useState(false);
@@ -476,6 +555,7 @@ function CategoryItem({
return (
<button
onClick={onSelect}
onContextMenu={onContextMenu}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
@@ -503,12 +583,14 @@ function AddressBookItem({
contactCount,
onSelect,
onDropContacts,
onContextMenu,
}: {
book: AddressBook;
isActive: boolean;
contactCount: number;
onSelect: () => void;
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
onContextMenu?: (e: React.MouseEvent<HTMLButtonElement>) => void;
}) {
const [isDragOver, setIsDragOver] = useState(false);
@@ -541,6 +623,7 @@ function AddressBookItem({
return (
<button
onClick={onSelect}
onContextMenu={onContextMenu}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
@@ -0,0 +1,247 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Book, Pencil, Share2, Tag } from "lucide-react";
import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
import { toast } from "@/stores/toast-store";
import { SettingsSection } from "./settings-section";
import { cn } from "@/lib/utils";
import type { AddressBook } from "@/lib/jmap/types";
function AddressBookEditRow({
initial,
onSave,
onCancel,
isLoading,
}: {
initial: string;
onSave: (name: string) => void;
onCancel: () => void;
isLoading: boolean;
}) {
const t = useTranslations("contacts.address_books");
const tCal = useTranslations("calendar.management");
const [name, setName] = useState(initial);
const isValid = name.trim().length > 0;
return (
<div className="space-y-3 p-3 rounded-md border border-primary/30 bg-accent/30">
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("name_label")}
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && isValid) onSave(name.trim());
if (e.key === "Escape") onCancel();
}}
className="w-full px-3 py-1.5 text-sm rounded-md border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
autoFocus
disabled={isLoading}
/>
</div>
<div className="flex items-center gap-2 pt-1">
<button
onClick={() => isValid && onSave(name.trim())}
disabled={isLoading || !isValid}
className="px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
>
{tCal("save")}
</button>
<button
onClick={onCancel}
disabled={isLoading}
className="px-3 py-1.5 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
>
{tCal("cancel")}
</button>
</div>
</div>
);
}
export function AddressBookManagementSettings() {
const t = useTranslations("contacts.address_books");
const tContacts = useTranslations("contacts");
const tSettings = useTranslations("settings.contacts");
const { client } = useAuthStore();
const { addressBooks, contacts, supportsSync, fetchAddressBooks, renameAddressBook, renameKeyword } = useContactStore();
const [editingId, setEditingId] = useState<string | null>(null);
const [editingKeyword, setEditingKeyword] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (client && addressBooks.length === 0) {
fetchAddressBooks(client);
}
}, [client, addressBooks.length, fetchAddressBooks]);
const handleUpdate = async (book: AddressBook, newName: string) => {
if (!client) return;
setIsLoading(true);
try {
await renameAddressBook(client, book, newName);
setEditingId(null);
toast.success(t("renamed"));
} catch {
toast.error(t("rename_failed"));
} finally {
setIsLoading(false);
}
};
// Group: personal first, then by shared account
const personal = addressBooks.filter((b) => !b.isShared);
const sharedGroups = new Map<string, { accountName: string; books: AddressBook[] }>();
for (const book of addressBooks) {
if (!book.isShared || !book.accountId) continue;
const key = book.accountId;
const existing = sharedGroups.get(key);
if (existing) existing.books.push(book);
else sharedGroups.set(key, { accountName: book.accountName || book.accountId, books: [book] });
}
const renderBook = (book: AddressBook) => {
if (editingId === book.id) {
return (
<AddressBookEditRow
key={book.id}
initial={book.name}
onSave={(name) => handleUpdate(book, name)}
onCancel={() => setEditingId(null)}
isLoading={isLoading}
/>
);
}
const canRename = !book.isShared || book.myRights?.mayWrite !== false;
return (
<div
key={book.id}
className={cn(
"flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group"
)}
>
<Book className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">{book.name}</span>
</div>
{book.isDefault && (
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
{t("default")}
</span>
)}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{canRename && (
<button
type="button"
onClick={() => setEditingId(book.id)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={t("rename")}
>
<Pencil className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
};
// Collect keywords with counts
const keywordCounts: Record<string, number> = {};
for (const c of contacts) {
if (c.kind === "group" || !c.keywords) continue;
for (const [kw, active] of Object.entries(c.keywords)) {
if (active) keywordCounts[kw] = (keywordCounts[kw] || 0) + 1;
}
}
const sortedKeywords = Object.entries(keywordCounts).sort(([a], [b]) => a.localeCompare(b));
const handleRenameKeyword = async (oldKw: string, newKw: string) => {
setIsLoading(true);
try {
await renameKeyword(supportsSync && client ? client : null, oldKw, newKw);
setEditingKeyword(null);
toast.success(tContacts("category_renamed"));
} catch {
toast.error(tContacts("category_rename_failed"));
} finally {
setIsLoading(false);
}
};
return (
<>
<SettingsSection title={tSettings("manage_title")} description={tSettings("manage_description")}>
<div className="space-y-2">
{personal.map(renderBook)}
{Array.from(sharedGroups.entries()).map(([accountId, group]) => (
<div key={accountId} className="mt-4 space-y-2">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
<Share2 className="w-3 h-3" />
{t("shared_prefix", { name: group.accountName })}
</h4>
{group.books.map(renderBook)}
</div>
))}
{addressBooks.length === 0 && (
<p className="text-sm text-muted-foreground py-2">{tSettings("no_address_books")}</p>
)}
</div>
</SettingsSection>
<div className="mt-8">
<SettingsSection title={tSettings("categories_title")} description={tSettings("categories_description")}>
<div className="space-y-2">
{sortedKeywords.map(([keyword, count]) => {
if (editingKeyword === keyword) {
return (
<AddressBookEditRow
key={keyword}
initial={keyword}
onSave={(name) => handleRenameKeyword(keyword, name)}
onCancel={() => setEditingKeyword(null)}
isLoading={isLoading}
/>
);
}
return (
<div
key={keyword}
className="flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group"
>
<Tag className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">{keyword}</span>
</div>
<span className="text-xs text-muted-foreground tabular-nums">{count}</span>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => setEditingKeyword(keyword)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={tContacts("rename_category")}
>
<Pencil className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
})}
{sortedKeywords.length === 0 && (
<p className="text-sm text-muted-foreground py-2">{tSettings("no_categories")}</p>
)}
</div>
</SettingsSection>
</div>
</>
);
}
+1 -1
View File
@@ -112,7 +112,7 @@ export function AdvancedSettings() {
</div>
</button>
<a
href="https://github.com/stalwartlabs/webmail"
href="https://github.com/bulwarkmail/webmail"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
+5
View File
@@ -481,6 +481,11 @@ export class DemoJMAPClient implements IJMAPClient {
async getAddressBooks(): Promise<AddressBook[]> { return [...this.data.addressBooks]; }
async getAllAddressBooks(): Promise<AddressBook[]> { return [...this.data.addressBooks]; }
async updateAddressBook(addressBookId: string, updates: Partial<AddressBook>): Promise<void> {
const book = this.data.addressBooks.find(b => b.id === addressBookId);
if (book) Object.assign(book, updates);
}
async getContacts(addressBookId?: string): Promise<ContactCard[]> {
if (addressBookId) return this.data.contacts.filter(c => c.addressBookIds[addressBookId]);
return [...this.data.contacts];
+1
View File
@@ -178,6 +178,7 @@ export interface IJMAPClient {
getContactsAccountId(): string;
getAddressBooks(): Promise<AddressBook[]>;
getAllAddressBooks(): Promise<AddressBook[]>;
updateAddressBook(addressBookId: string, updates: Partial<AddressBook>, targetAccountId?: string): Promise<void>;
getContacts(addressBookId?: string): Promise<ContactCard[]>;
getAllContacts(): Promise<ContactCard[]>;
getContact(contactId: string, accountId?: string): Promise<ContactCard | null>;
+29
View File
@@ -2818,6 +2818,35 @@ export class JMAPClient implements IJMAPClient {
}
}
async updateAddressBook(addressBookId: string, updates: Partial<AddressBook>, targetAccountId?: string): Promise<void> {
const accountId = targetAccountId || this.getContactsAccountId();
// Only forward server-settable properties
const { name, description, sortOrder, isDefault, color } = updates as Record<string, unknown>;
const patch: Record<string, unknown> = {};
if (name !== undefined) patch.name = name;
if (description !== undefined) patch.description = description;
if (sortOrder !== undefined) patch.sortOrder = sortOrder;
if (isDefault !== undefined) patch.isDefault = isDefault;
if (color !== undefined) patch.color = color;
const response = await this.request([
["AddressBook/set", {
accountId,
update: { [addressBookId]: patch },
}, "0"]
], this.contactUsing());
if (response.methodResponses?.[0]?.[0] === "AddressBook/set") {
const result = response.methodResponses[0][1];
if (result.notUpdated?.[addressBookId]) {
const error = result.notUpdated[addressBookId];
throw new Error(error.description || "Failed to update address book");
}
return;
}
throw new Error("Failed to update address book");
}
private async fetchPaginatedContacts(
accountId: string,
filter?: Record<string, unknown>,
+18 -2
View File
@@ -1230,7 +1230,13 @@
"import_label": "Import Contacts",
"import_description": "Import contacts from a vCard (.vcf) file",
"export_label": "Export Contacts",
"export_description": "Export all contacts as a vCard (.vcf) file"
"export_description": "Export all contacts as a vCard (.vcf) file",
"manage_title": "Adressbücher",
"manage_description": "Adressbücher umbenennen",
"no_address_books": "Keine Adressbücher gefunden",
"categories_title": "Kategorien",
"categories_description": "Kontaktkategorien umbenennen",
"no_categories": "Keine Kategorien gefunden"
},
"filters": {
"title": "E-Mail-Filter",
@@ -1635,6 +1641,10 @@
"search_placeholder": "Kontakte suchen...",
"create_new": "Neuer Kontakt",
"no_category": "Ohne Kategorie",
"rename_category": "Kategorie umbenennen",
"category_name_label": "Kategoriename",
"category_renamed": "Kategorie umbenannt",
"category_rename_failed": "Kategorie konnte nicht umbenannt werden",
"category_added": "Kontakt zu {name} hinzugefügt",
"category_added_plural": "{count} Kontakte zu {name} hinzugefügt",
"empty_state": "Keine Kontakte",
@@ -1661,7 +1671,13 @@
"moved": "Kontakt verschoben nach {name}",
"moved_plural": "{count} Kontakte verschoben nach {name}",
"move_failed": "Kontakt konnte nicht verschoben werden",
"address_book": "Adressbuch"
"address_book": "Adressbuch",
"rename": "Adressbuch umbenennen",
"name_label": "Name des Adressbuchs",
"renamed": "Adressbuch umbenannt",
"rename_failed": "Adressbuch konnte nicht umbenannt werden",
"default": "Standard",
"manage": "Adressbücher verwalten"
},
"detail": {
"emails": "E-Mail-Adressen",
+18 -2
View File
@@ -1230,7 +1230,13 @@
"import_label": "Import Contacts",
"import_description": "Import contacts from a vCard (.vcf) file",
"export_label": "Export Contacts",
"export_description": "Export all contacts as a vCard (.vcf) file"
"export_description": "Export all contacts as a vCard (.vcf) file",
"manage_title": "Address Books",
"manage_description": "Rename your address books",
"no_address_books": "No address books found",
"categories_title": "Categories",
"categories_description": "Rename contact categories",
"no_categories": "No categories found"
},
"filters": {
"title": "Email Filters",
@@ -1635,6 +1641,10 @@
"search_placeholder": "Search contacts...",
"create_new": "New Contact",
"no_category": "No Category",
"rename_category": "Rename category",
"category_name_label": "Category name",
"category_renamed": "Category renamed",
"category_rename_failed": "Failed to rename category",
"category_added": "Contact added to {name}",
"category_added_plural": "{count} contacts added to {name}",
"empty_state": "No contacts yet",
@@ -1661,7 +1671,13 @@
"moved": "Contact moved to {name}",
"moved_plural": "{count} contacts moved to {name}",
"move_failed": "Failed to move contact",
"address_book": "Address Book"
"address_book": "Address Book",
"rename": "Rename address book",
"name_label": "Address book name",
"renamed": "Address book renamed",
"rename_failed": "Failed to rename address book",
"default": "Default",
"manage": "Manage address books"
},
"detail": {
"emails": "Email Addresses",
+18 -2
View File
@@ -1230,7 +1230,13 @@
"import_label": "Import Contacts",
"import_description": "Import contacts from a vCard (.vcf) file",
"export_label": "Export Contacts",
"export_description": "Export all contacts as a vCard (.vcf) file"
"export_description": "Export all contacts as a vCard (.vcf) file",
"manage_title": "Libretas de direcciones",
"manage_description": "Renombrar tus libretas de direcciones",
"no_address_books": "No se encontraron libretas de direcciones",
"categories_title": "Categorías",
"categories_description": "Renombrar categorías de contactos",
"no_categories": "No se encontraron categorías"
},
"filters": {
"title": "Filtros de correo",
@@ -1635,6 +1641,10 @@
"search_placeholder": "Buscar contactos...",
"create_new": "Nuevo contacto",
"no_category": "Sin categoría",
"rename_category": "Renombrar categoría",
"category_name_label": "Nombre de la categoría",
"category_renamed": "Categoría renombrada",
"category_rename_failed": "Error al renombrar la categoría",
"category_added": "Contacto añadido a {name}",
"category_added_plural": "{count} contactos añadidos a {name}",
"empty_state": "No hay contactos",
@@ -1661,7 +1671,13 @@
"moved": "Contacto movido a {name}",
"moved_plural": "{count} contactos movidos a {name}",
"move_failed": "Error al mover el contacto",
"address_book": "Libreta de direcciones"
"address_book": "Libreta de direcciones",
"rename": "Renombrar libreta de direcciones",
"name_label": "Nombre de la libreta de direcciones",
"renamed": "Libreta de direcciones renombrada",
"rename_failed": "Error al renombrar la libreta de direcciones",
"default": "Predeterminada",
"manage": "Administrar libretas de direcciones"
},
"detail": {
"emails": "Direcciones de correo",
+18 -2
View File
@@ -1230,7 +1230,13 @@
"import_label": "Import Contacts",
"import_description": "Import contacts from a vCard (.vcf) file",
"export_label": "Export Contacts",
"export_description": "Export all contacts as a vCard (.vcf) file"
"export_description": "Export all contacts as a vCard (.vcf) file",
"manage_title": "Carnets d'adresses",
"manage_description": "Renommer vos carnets d'adresses",
"no_address_books": "Aucun carnet d'adresses trouvé",
"categories_title": "Catégories",
"categories_description": "Renommer les catégories de contacts",
"no_categories": "Aucune catégorie trouvée"
},
"filters": {
"title": "Filtres de courrier",
@@ -1635,6 +1641,10 @@
"search_placeholder": "Rechercher des contacts...",
"create_new": "Nouveau contact",
"no_category": "Sans catégorie",
"rename_category": "Renommer la catégorie",
"category_name_label": "Nom de la catégorie",
"category_renamed": "Catégorie renommée",
"category_rename_failed": "Échec du renommage de la catégorie",
"category_added": "Contact ajouté à {name}",
"category_added_plural": "{count} contacts ajoutés à {name}",
"empty_state": "Aucun contact",
@@ -1661,7 +1671,13 @@
"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": "Carnet d'adresses"
"address_book": "Carnet d'adresses",
"rename": "Renommer le carnet d'adresses",
"name_label": "Nom du carnet d'adresses",
"renamed": "Carnet d'adresses renommé",
"rename_failed": "Échec du renommage du carnet d'adresses",
"default": "Par défaut",
"manage": "Gérer les carnets d'adresses"
},
"detail": {
"emails": "Adresses e-mail",
+18 -2
View File
@@ -1230,7 +1230,13 @@
"import_label": "Import Contacts",
"import_description": "Import contacts from a vCard (.vcf) file",
"export_label": "Export Contacts",
"export_description": "Export all contacts as a vCard (.vcf) file"
"export_description": "Export all contacts as a vCard (.vcf) file",
"manage_title": "Rubriche",
"manage_description": "Rinomina le tue rubriche",
"no_address_books": "Nessuna rubrica trovata",
"categories_title": "Categorie",
"categories_description": "Rinomina le categorie dei contatti",
"no_categories": "Nessuna categoria trovata"
},
"filters": {
"title": "Filtri email",
@@ -1635,6 +1641,10 @@
"search_placeholder": "Cerca contatti...",
"create_new": "Nuovo contatto",
"no_category": "Senza categoria",
"rename_category": "Rinomina categoria",
"category_name_label": "Nome della categoria",
"category_renamed": "Categoria rinominata",
"category_rename_failed": "Impossibile rinominare la categoria",
"category_added": "Contatto aggiunto a {name}",
"category_added_plural": "{count} contatti aggiunti a {name}",
"empty_state": "Nessun contatto",
@@ -1661,7 +1671,13 @@
"moved": "Contatto spostato in {name}",
"moved_plural": "{count} contatti spostati in {name}",
"move_failed": "Impossibile spostare il contatto",
"address_book": "Rubrica"
"address_book": "Rubrica",
"rename": "Rinomina rubrica",
"name_label": "Nome della rubrica",
"renamed": "Rubrica rinominata",
"rename_failed": "Impossibile rinominare la rubrica",
"default": "Predefinita",
"manage": "Gestisci rubriche"
},
"detail": {
"emails": "Indirizzi email",
+18 -2
View File
@@ -1230,7 +1230,13 @@
"import_label": "Import Contacts",
"import_description": "Import contacts from a vCard (.vcf) file",
"export_label": "Export Contacts",
"export_description": "Export all contacts as a vCard (.vcf) file"
"export_description": "Export all contacts as a vCard (.vcf) file",
"manage_title": "アドレス帳",
"manage_description": "アドレス帳の名前を変更",
"no_address_books": "アドレス帳が見つかりません",
"categories_title": "カテゴリ",
"categories_description": "連絡先カテゴリの名前を変更",
"no_categories": "カテゴリが見つかりません"
},
"filters": {
"title": "メールフィルター",
@@ -1635,6 +1641,10 @@
"search_placeholder": "連絡先を検索...",
"create_new": "新しい連絡先",
"no_category": "カテゴリなし",
"rename_category": "カテゴリの名前を変更",
"category_name_label": "カテゴリ名",
"category_renamed": "カテゴリの名前を変更しました",
"category_rename_failed": "カテゴリの名前変更に失敗しました",
"category_added": "{name} に連絡先を追加しました",
"category_added_plural": "{count} 件の連絡先を {name} に追加しました",
"empty_state": "連絡先がありません",
@@ -1661,7 +1671,13 @@
"moved": "連絡先を {name} に移動しました",
"moved_plural": "{count} 件の連絡先を {name} に移動しました",
"move_failed": "連絡先の移動に失敗しました",
"address_book": "アドレス帳"
"address_book": "アドレス帳",
"rename": "アドレス帳の名前を変更",
"name_label": "アドレス帳名",
"renamed": "アドレス帳の名前を変更しました",
"rename_failed": "アドレス帳の名前変更に失敗しました",
"default": "デフォルト",
"manage": "アドレス帳を管理"
},
"detail": {
"emails": "メールアドレス",
+18 -2
View File
@@ -1230,7 +1230,13 @@
"import_label": "연락처 가져오기",
"import_description": "vCard(.vcf) 파일에서 연락처를 가져와요",
"export_label": "연락처 내보내기",
"export_description": "모든 연락처를 vCard(.vcf) 파일로 저장해요"
"export_description": "모든 연락처를 vCard(.vcf) 파일로 저장해요",
"manage_title": "주소록",
"manage_description": "주소록 이름 변경",
"no_address_books": "주소록을 찾을 수 없음",
"categories_title": "카테고리",
"categories_description": "연락처 카테고리 이름 변경",
"no_categories": "카테고리를 찾을 수 없음"
},
"filters": {
"title": "이메일 필터",
@@ -1635,6 +1641,10 @@
"search_placeholder": "연락처 검색...",
"create_new": "새 연락처",
"no_category": "카테고리 없음",
"rename_category": "카테고리 이름 변경",
"category_name_label": "카테고리 이름",
"category_renamed": "카테고리 이름이 변경되었습니다",
"category_rename_failed": "카테고리 이름 변경 실패",
"category_added": "{name}에 연락처가 추가되었어요",
"category_added_plural": "{name}에 {count}개의 연락처가 추가되었어요",
"empty_state": "아직 연락처가 없어요",
@@ -1661,7 +1671,13 @@
"moved": "연락처가 {name}(으)로 이동되었어요",
"moved_plural": "{count}개의 연락처가 {name}(으)로 이동되었어요",
"move_failed": "연락처를 이동하지 못했어요",
"address_book": "주소록"
"address_book": "주소록",
"rename": "주소록 이름 변경",
"name_label": "주소록 이름",
"renamed": "주소록 이름이 변경되었습니다",
"rename_failed": "주소록 이름 변경 실패",
"default": "기본",
"manage": "주소록 관리"
},
"detail": {
"emails": "이메일",
+18 -2
View File
@@ -1230,7 +1230,13 @@
"import_label": "Import Contacts",
"import_description": "Import contacts from a vCard (.vcf) file",
"export_label": "Export Contacts",
"export_description": "Export all contacts as a vCard (.vcf) file"
"export_description": "Export all contacts as a vCard (.vcf) file",
"manage_title": "Adresboeken",
"manage_description": "Hernoem uw adresboeken",
"no_address_books": "Geen adresboeken gevonden",
"categories_title": "Categorieën",
"categories_description": "Contactcategorieën hernoemen",
"no_categories": "Geen categorieën gevonden"
},
"filters": {
"title": "E-mailfilters",
@@ -1635,6 +1641,10 @@
"search_placeholder": "Contacten zoeken...",
"create_new": "Nieuw contact",
"no_category": "Geen categorie",
"rename_category": "Categorie hernoemen",
"category_name_label": "Categorienaam",
"category_renamed": "Categorie hernoemd",
"category_rename_failed": "Categorie hernoemen mislukt",
"category_added": "Contact toegevoegd aan {name}",
"category_added_plural": "{count} contacten toegevoegd aan {name}",
"empty_state": "Geen contacten",
@@ -1661,7 +1671,13 @@
"moved": "Contact verplaatst naar {name}",
"moved_plural": "{count} contacten verplaatst naar {name}",
"move_failed": "Verplaatsen van contact mislukt",
"address_book": "Adresboek"
"address_book": "Adresboek",
"rename": "Adresboek hernoemen",
"name_label": "Naam van adresboek",
"renamed": "Adresboek hernoemd",
"rename_failed": "Adresboek hernoemen mislukt",
"default": "Standaard",
"manage": "Adresboeken beheren"
},
"detail": {
"emails": "E-mailadressen",
+18 -2
View File
@@ -1232,7 +1232,13 @@
"import_label": "Importuj kontakty",
"import_description": "Importuj kontakty z pliku vCard (.vcf)",
"export_label": "Eksportuj kontakty",
"export_description": "Eksportuj wszystkie kontakty jako plik vCard (.vcf)"
"export_description": "Eksportuj wszystkie kontakty jako plik vCard (.vcf)",
"manage_title": "Książki adresowe",
"manage_description": "Zmień nazwy swoich książek adresowych",
"no_address_books": "Nie znaleziono książek adresowych",
"categories_title": "Kategorie",
"categories_description": "Zmień nazwy kategorii kontaktów",
"no_categories": "Nie znaleziono kategorii"
},
"filters": {
"title": "Filtry wiadomości e-mail",
@@ -1637,6 +1643,10 @@
"search_placeholder": "Szukaj kontaktów...",
"create_new": "Nowy kontakt",
"no_category": "Brak kategorii",
"rename_category": "Zmień nazwę kategorii",
"category_name_label": "Nazwa kategorii",
"category_renamed": "Zmieniono nazwę kategorii",
"category_rename_failed": "Nie udało się zmienić nazwy kategorii",
"category_added": "Kontakt dodany do {name}",
"category_added_plural": "{count} kontaktów dodano do {name}",
"empty_state": "Brak kontaktów",
@@ -1663,7 +1673,13 @@
"moved": "Kontakt przeniesiono do {name}",
"moved_plural": "{count} kontaktów przeniesiono do {name}",
"move_failed": "Nie udało się przenieść kontaktu",
"address_book": "Książka adresowa"
"address_book": "Książka adresowa",
"rename": "Zmień nazwę książki adresowej",
"name_label": "Nazwa książki adresowej",
"renamed": "Zmieniono nazwę książki adresowej",
"rename_failed": "Nie udało się zmienić nazwy książki adresowej",
"default": "Domyślna",
"manage": "Zarządzaj książkami adresowymi"
},
"detail": {
"emails": "Adresy e-mail",
+18 -2
View File
@@ -1230,7 +1230,13 @@
"import_label": "Import Contacts",
"import_description": "Import contacts from a vCard (.vcf) file",
"export_label": "Export Contacts",
"export_description": "Export all contacts as a vCard (.vcf) file"
"export_description": "Export all contacts as a vCard (.vcf) file",
"manage_title": "Catálogos de endereços",
"manage_description": "Renomeie seus catálogos de endereços",
"no_address_books": "Nenhum catálogo de endereços encontrado",
"categories_title": "Categorias",
"categories_description": "Renomear categorias de contatos",
"no_categories": "Nenhuma categoria encontrada"
},
"filters": {
"title": "Filtros de e-mail",
@@ -1635,6 +1641,10 @@
"search_placeholder": "Pesquisar contatos...",
"create_new": "Novo contato",
"no_category": "Sem categoria",
"rename_category": "Renomear categoria",
"category_name_label": "Nome da categoria",
"category_renamed": "Categoria renomeada",
"category_rename_failed": "Falha ao renomear a categoria",
"category_added": "Contato adicionado a {name}",
"category_added_plural": "{count} contatos adicionados a {name}",
"empty_state": "Nenhum contato",
@@ -1661,7 +1671,13 @@
"moved": "Contato movido para {name}",
"moved_plural": "{count} contatos movidos para {name}",
"move_failed": "Falha ao mover o contato",
"address_book": "Catálogo de endereços"
"address_book": "Catálogo de endereços",
"rename": "Renomear catálogo de endereços",
"name_label": "Nome do catálogo de endereços",
"renamed": "Catálogo de endereços renomeado",
"rename_failed": "Falha ao renomear o catálogo de endereços",
"default": "Padrão",
"manage": "Gerenciar catálogos de endereços"
},
"detail": {
"emails": "Endereços de e-mail",
+18 -2
View File
@@ -1230,7 +1230,13 @@
"import_label": "Импорт контактов",
"import_description": "Импортировать контакты из файла vCard (.vcf)",
"export_label": "Экспорт контактов",
"export_description": "Экспортировать все контакты в виде файла vCard (.vcf)"
"export_description": "Экспортировать все контакты в виде файла vCard (.vcf)",
"manage_title": "Адресные книги",
"manage_description": "Переименуйте ваши адресные книги",
"no_address_books": "Адресные книги не найдены",
"categories_title": "Категории",
"categories_description": "Переименование категорий контактов",
"no_categories": "Категории не найдены"
},
"filters": {
"title": "Фильтры почты",
@@ -1635,6 +1641,10 @@
"search_placeholder": "Поиск контактов...",
"create_new": "Новый контакт",
"no_category": "Без категории",
"rename_category": "Переименовать категорию",
"category_name_label": "Название категории",
"category_renamed": "Категория переименована",
"category_rename_failed": "Не удалось переименовать категорию",
"category_added": "Контакт добавлен в {name}",
"category_added_plural": "{count} контактов добавлено в {name}",
"empty_state": "Контактов пока нет",
@@ -1661,7 +1671,13 @@
"moved": "Контакт перемещён в {name}",
"moved_plural": "{count} контактов перемещено в {name}",
"move_failed": "Не удалось переместить контакт",
"address_book": "Адресная книга"
"address_book": "Адресная книга",
"rename": "Переименовать адресную книгу",
"name_label": "Название адресной книги",
"renamed": "Адресная книга переименована",
"rename_failed": "Не удалось переименовать адресную книгу",
"default": "По умолчанию",
"manage": "Управление адресными книгами"
},
"detail": {
"emails": "Адреса электронной почты",
+18 -2
View File
@@ -1230,7 +1230,13 @@
"import_label": "导入联系人",
"import_description": "从 vCard (.vcf) 文件导入联系人",
"export_label": "导出联系人",
"export_description": "将所有联系人导出为 vCard (.vcf) 文件"
"export_description": "将所有联系人导出为 vCard (.vcf) 文件",
"manage_title": "地址簿",
"manage_description": "重命名您的地址簿",
"no_address_books": "未找到地址簿",
"categories_title": "类别",
"categories_description": "重命名联系人类别",
"no_categories": "未找到类别"
},
"filters": {
"title": "邮件过滤器",
@@ -1635,6 +1641,10 @@
"search_placeholder": "搜索联系人...",
"create_new": "新联系人",
"no_category": "没有类别",
"rename_category": "重命名类别",
"category_name_label": "类别名称",
"category_renamed": "类别已重命名",
"category_rename_failed": "重命名类别失败",
"category_added": "联系人已添加至 {name}",
"category_added_plural": "{count} 联系人已添加到 {name}",
"empty_state": "还没有联系人",
@@ -1661,7 +1671,13 @@
"moved": "联系人已移至 {name}",
"moved_plural": "{count} 联系人已移至 {name}",
"move_failed": "无法移动联系人",
"address_book": "地址簿"
"address_book": "地址簿",
"rename": "重命名地址簿",
"name_label": "地址簿名称",
"renamed": "地址簿已重命名",
"rename_failed": "重命名地址簿失败",
"default": "默认",
"manage": "管理地址簿"
},
"detail": {
"emails": "邮箱地址",
+53
View File
@@ -82,6 +82,8 @@ interface ContactStore {
bulkDeleteContacts: (client: IJMAPClient | null, ids: string[]) => Promise<void>;
bulkAddToGroup: (client: IJMAPClient | null, groupId: string, contactIds: string[]) => Promise<void>;
moveContactToAddressBook: (client: IJMAPClient, contactIds: string[], addressBook: AddressBook) => Promise<void>;
renameAddressBook: (client: IJMAPClient, addressBook: AddressBook, newName: string) => Promise<void>;
renameKeyword: (client: IJMAPClient | null, oldKeyword: string, newKeyword: string) => Promise<void>;
importContacts: (client: IJMAPClient | null, contacts: ContactCard[]) => Promise<number>;
}
@@ -605,6 +607,57 @@ export const useContactStore = create<ContactStore>()(
}
},
renameAddressBook: async (client, addressBook, newName) => {
set({ error: null });
const trimmed = newName.trim();
if (!trimmed) return;
try {
const originalId = addressBook.originalId || addressBook.id;
const accountId = addressBook.isShared ? addressBook.accountId : undefined;
await client.updateAddressBook(originalId, { name: trimmed }, accountId);
set((state) => ({
addressBooks: state.addressBooks.map(b =>
b.id === addressBook.id ? { ...b, name: trimmed } : b
),
}));
} catch (error) {
const msg = error instanceof Error ? error.message : 'Failed to rename address book';
set({ error: msg });
throw error;
}
},
renameKeyword: async (client, oldKeyword, newKeyword) => {
set({ error: null });
const oldKw = oldKeyword.trim();
const newKw = newKeyword.trim();
if (!oldKw || !newKw || oldKw === newKw) return;
const { contacts, supportsSync } = get();
const affected = contacts.filter(c => c.keywords?.[oldKw]);
for (const contact of affected) {
const { [oldKw]: _old, ...rest } = contact.keywords || {};
const updatedKeywords: Record<string, boolean> = { ...rest, [newKw]: true };
try {
if (supportsSync && client) {
const originalId = contact.originalId || contact.id;
const accountId = contact.isShared ? contact.accountId : undefined;
await client.updateContact(originalId, { keywords: updatedKeywords }, accountId);
}
set((state) => ({
contacts: state.contacts.map(c =>
c.id === contact.id ? { ...c, keywords: updatedKeywords } : c
),
}));
} catch (error) {
const msg = error instanceof Error ? error.message : 'Failed to rename category';
set({ error: msg });
throw error;
}
}
},
importContacts: async (client, contacts) => {
const { supportsSync } = get();
let imported = 0;