mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
fix: reach address book, category and group actions by ⋮, long-press and keyboard, move contacts with Move to…, and delete categories
This commit is contained in:
@@ -193,4 +193,17 @@ describe('ContactList', () => {
|
||||
expect(onComposeContact).toHaveBeenCalledWith(alice);
|
||||
expect(window.location.href).toBe(href);
|
||||
});
|
||||
|
||||
it("moves a contact to another address book from its right-click menu, and the selection from the bulk bar", () => {
|
||||
const onMoveContacts = vi.fn();
|
||||
const { unmount } = render(<ContactList {...defaultProps} onMoveContacts={onMoveContacts} />);
|
||||
fireEvent.contextMenu(screen.getByText('Alice Smith'));
|
||||
fireEvent.click(screen.getByText('address_books.move_to'));
|
||||
expect(onMoveContacts).toHaveBeenCalledWith(['1']);
|
||||
unmount();
|
||||
|
||||
render(<ContactList {...defaultProps} onMoveContacts={onMoveContacts} selectedContactIds={new Set(['1', '2'])} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /address_books.move_to/ }));
|
||||
expect(onMoveContacts).toHaveBeenLastCalledWith(['1', '2']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ vi.mock('@/hooks/use-sidebar-apps', () => ({
|
||||
import { ContactsApp } from '../contacts-app';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useContactStore } from '@/stores/contact-store';
|
||||
import { useToastStore } from '@/stores/toast-store';
|
||||
|
||||
export const alice: ContactCard = {
|
||||
id: 'c-alice',
|
||||
@@ -151,4 +152,19 @@ describe('ContactsApp', () => {
|
||||
fireEvent.keyDown(dialog, { key: 'Escape' });
|
||||
expect(screen.queryByRole('dialog', { name: 'import.title' })).toBeNull();
|
||||
});
|
||||
|
||||
it('deletes a category without asking and brings it back on Undo', async () => {
|
||||
const tagged = { ...alice, keywords: { Friends: true } };
|
||||
renderContacts([tagged, bob]);
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'open_categories' })[0]);
|
||||
fireEvent.click(within(drawer()).getByRole('button', { name: 'row_actions' }));
|
||||
fireEvent.click(screen.getByText('delete_category'));
|
||||
|
||||
await waitFor(() => expect(useContactStore.getState().contacts.find((c) => c.id === 'c-alice')?.keywords?.Friends).toBeUndefined());
|
||||
expect(screen.queryByRole('alertdialog')).toBeNull();
|
||||
const toastState = useToastStore.getState().toasts.at(-1)!;
|
||||
expect(toastState.title).toBe('category_deleted');
|
||||
await act(async () => { toastState.action!.onClick(); });
|
||||
await waitFor(() => expect(useContactStore.getState().contacts.find((c) => c.id === 'c-alice')?.keywords?.Friends).toBe(true));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { act, render, screen, within } from '@testing-library/react';
|
||||
import { fireEvent } from '@testing-library/dom';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ContactsSidebar } from '../contacts-sidebar';
|
||||
@@ -111,3 +111,55 @@ describe('ContactsSidebar - set default address book', () => {
|
||||
expect(screen.queryByText('address_books.set_default')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContactsSidebar — row menus without a right-click', () => {
|
||||
const carol = {
|
||||
id: 'c1',
|
||||
addressBookIds: {},
|
||||
name: { full: 'Carol' },
|
||||
keywords: { Friends: true },
|
||||
} as unknown as ContactCard;
|
||||
|
||||
function renderRows(onDeleteKeyword = vi.fn(), onRenameKeyword = vi.fn()) {
|
||||
render(
|
||||
<ContactsSidebar
|
||||
groups={[group]}
|
||||
individuals={[carol]}
|
||||
addressBooks={[]}
|
||||
activeCategory="all"
|
||||
onSelectCategory={vi.fn()}
|
||||
onCreateGroup={vi.fn()}
|
||||
onCreateContact={vi.fn()}
|
||||
onEditGroup={vi.fn()}
|
||||
onDeleteGroup={vi.fn()}
|
||||
onRenameKeyword={onRenameKeyword}
|
||||
onDeleteKeyword={onDeleteKeyword}
|
||||
/>,
|
||||
);
|
||||
return { onDeleteKeyword, onRenameKeyword };
|
||||
}
|
||||
|
||||
it('opens a category menu from its ⋮, with Delete category', () => {
|
||||
const { onDeleteKeyword } = renderRows();
|
||||
const row = screen.getByText('Friends').closest('div')!;
|
||||
fireEvent.click(within(row).getByRole('button', { name: 'row_actions' }));
|
||||
expect(screen.getByText('rename_category')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('delete_category'));
|
||||
expect(onDeleteKeyword).toHaveBeenCalledWith('Friends');
|
||||
});
|
||||
|
||||
it('opens a group menu on long-press', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
renderRows();
|
||||
const row = screen.getByText('Team').closest('div')!;
|
||||
const touchStart = new Event('touchstart', { bubbles: true });
|
||||
Object.defineProperty(touchStart, 'touches', { value: [{ clientX: 5, clientY: 5 }] });
|
||||
act(() => { row.dispatchEvent(touchStart); });
|
||||
act(() => { vi.advanceTimersByTime(600); });
|
||||
expect(screen.getByText('groups.edit')).toBeInTheDocument();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Book, Check } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AddressBook } from "@/lib/jmap/types";
|
||||
|
||||
interface AddressBookPickerDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
/** The books contacts can be moved to (writable ones). */
|
||||
addressBooks: AddressBook[];
|
||||
/** The book the contacts are in now, when they are all in one. */
|
||||
currentId?: string | null;
|
||||
count: number;
|
||||
onPick: (book: AddressBook) => void;
|
||||
}
|
||||
|
||||
/** "Move to…" for contacts: pick the address book they go to. */
|
||||
export function AddressBookPickerDialog({ isOpen, onClose, addressBooks, currentId, count, onPick }: AddressBookPickerDialogProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const choice = addressBooks.find((b) => b.id === selected && b.id !== currentId) ?? null;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={t("address_books.move_title")}
|
||||
description={t("bulk.adding_contacts", { count })}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!choice) return;
|
||||
onPick(choice);
|
||||
onClose();
|
||||
}}
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" variant="outline" onClick={onClose}>{t("form.cancel")}</Button>
|
||||
<Button type="submit" disabled={!choice}>{t("address_books.move_here")}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div role="radiogroup" aria-label={t("address_books.title")} className="mt-4 max-h-80 overflow-y-auto rounded-md border border-border py-1">
|
||||
{addressBooks.map((book) => {
|
||||
const isCurrent = book.id === currentId;
|
||||
const isSelected = selected === book.id;
|
||||
return (
|
||||
<button
|
||||
key={book.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isSelected}
|
||||
disabled={isCurrent}
|
||||
onClick={() => setSelected(book.id)}
|
||||
onDoubleClick={() => { if (!isCurrent) { onPick(book); onClose(); } }}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-3 h-9 text-sm text-start outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
|
||||
isSelected ? "bg-selection" : "hover:bg-muted",
|
||||
isCurrent && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Book className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate flex-1" title={book.name}>{book.name}</span>
|
||||
{book.isShared && book.accountName && (
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[40%]" title={book.accountName}>{book.accountName}</span>
|
||||
)}
|
||||
{isSelected && <Check className="w-4 h-4 shrink-0 text-primary" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Trash2,
|
||||
Copy,
|
||||
Printer,
|
||||
FolderInput,
|
||||
} from "@/components/icons";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
import { getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
@@ -50,6 +51,9 @@ interface ContactContextMenuProps {
|
||||
/** Writes to the contact in the app's composer. */
|
||||
onCompose?: () => void;
|
||||
onDuplicate?: () => void;
|
||||
/** Move to another address book; undefined where there is none to move to. */
|
||||
onMoveTo?: () => void;
|
||||
onBatchMoveTo?: () => void;
|
||||
onBatchExport?: () => void;
|
||||
onBatchAddToGroup?: () => void;
|
||||
onBatchDelete?: () => void;
|
||||
@@ -69,6 +73,8 @@ export function ContactContextMenu({
|
||||
onAddToGroup,
|
||||
onCompose,
|
||||
onDuplicate,
|
||||
onMoveTo,
|
||||
onBatchMoveTo,
|
||||
onBatchExport,
|
||||
onBatchAddToGroup,
|
||||
onBatchDelete,
|
||||
@@ -121,6 +127,9 @@ export function ContactContextMenu({
|
||||
onClick={handle(() => onBatchAddToGroup?.())}
|
||||
disabled={!onBatchAddToGroup}
|
||||
/>
|
||||
{onBatchMoveTo && (
|
||||
<ContextMenuItem icon={FolderInput} label={t("address_books.move_to")} onClick={handle(onBatchMoveTo)} />
|
||||
)}
|
||||
<ContextMenuItem
|
||||
icon={Download}
|
||||
label={t("bulk.export")}
|
||||
@@ -185,6 +194,9 @@ export function ContactContextMenu({
|
||||
onClick={handle(onDuplicate)}
|
||||
/>
|
||||
)}
|
||||
{onMoveTo && (
|
||||
<ContextMenuItem icon={FolderInput} label={t("address_books.move_to")} onClick={handle(onMoveTo)} />
|
||||
)}
|
||||
<ContextMenuItem
|
||||
icon={Download}
|
||||
label={t("context_menu.export_vcard")}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, Download, MoreHorizontal, Printer } from "@/components/icons";
|
||||
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, Download, MoreHorizontal, Printer, FolderInput } from "@/components/icons";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { MailtoLink } from "@/components/ui/mailto-link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -36,6 +36,8 @@ interface ContactDetailProps {
|
||||
onDelete: () => void;
|
||||
onAddToGroup?: () => void;
|
||||
onDuplicate?: () => void;
|
||||
/** Move to another address book; undefined where there is none to move to. */
|
||||
onMoveTo?: () => void;
|
||||
/** Compose an email to this contact in the app (no OS mailto handoff). */
|
||||
onCompose?: () => void;
|
||||
isMobile?: boolean;
|
||||
@@ -138,7 +140,7 @@ function formatDate(dateInput: AnniversaryDate): string {
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onCompose, isMobile, className }: ContactDetailProps) {
|
||||
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onMoveTo, onCompose, isMobile, className }: ContactDetailProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const contextLabel = (key: string) => (CONTEXT_LABEL_KEYS[key] ? t(CONTEXT_LABEL_KEYS[key]) : key);
|
||||
const featureLabel = (key: string) => (PHONE_FEATURE_LABEL_KEYS[key] ? t(PHONE_FEATURE_LABEL_KEYS[key]) : key);
|
||||
@@ -187,6 +189,9 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
||||
if (onDuplicate) {
|
||||
moreItems.push({ icon: Copy, label: t("context_menu.duplicate"), onClick: onDuplicate });
|
||||
}
|
||||
if (onMoveTo) {
|
||||
moreItems.push({ icon: FolderInput, label: t("address_books.move_to"), onClick: onMoveTo });
|
||||
}
|
||||
moreItems.push({ icon: Download, label: t("context_menu.export_vcard"), onClick: handleExport, disabled: exporting });
|
||||
moreItems.push({ icon: Printer, label: t("context_menu.print"), onClick: handlePrint });
|
||||
moreItems.push({ separator: true });
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw, Menu, ArrowDownAZ, Loader2 } from "@/components/icons";
|
||||
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw, Menu, ArrowDownAZ, Loader2, FolderInput } from "@/components/icons";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ContactListItem } from "./contact-list-item";
|
||||
@@ -116,6 +116,8 @@ interface ContactListProps {
|
||||
onAddContactToGroup: (id: string) => void;
|
||||
/** Opens the app's composer to the contact. */
|
||||
onComposeContact?: (contact: ContactCard) => void;
|
||||
/** Move contacts to another address book; undefined where there is none. */
|
||||
onMoveContacts?: (ids: string[]) => void;
|
||||
/** Show a burger button at the start that opens the (overlay) categories sidebar. */
|
||||
onMenuClick?: () => void;
|
||||
/** The first fetch is still running: show placeholders, not "No contacts yet". */
|
||||
@@ -142,6 +144,7 @@ export function ContactList({
|
||||
onDeleteContact,
|
||||
onAddContactToGroup,
|
||||
onComposeContact,
|
||||
onMoveContacts,
|
||||
onMenuClick,
|
||||
isLoading = false,
|
||||
}: ContactListProps) {
|
||||
@@ -515,6 +518,12 @@ export function ContactList({
|
||||
<Users className="w-3.5 h-3.5 me-1" />
|
||||
{t("bulk.add_to_group")}
|
||||
</Button>
|
||||
{onMoveContacts && (
|
||||
<Button variant="ghost" size="sm" onClick={() => onMoveContacts([...selectedContactIds])} className="h-7 text-xs">
|
||||
<FolderInput className="w-3.5 h-3.5 me-1" />
|
||||
{t("address_books.move_to")}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={onBulkExport} disabled={exporting} aria-busy={exporting} className="h-7 text-xs">
|
||||
{exporting
|
||||
? <Loader2 className="w-3.5 h-3.5 me-1 animate-spin" />
|
||||
@@ -654,6 +663,8 @@ export function ContactList({
|
||||
onDelete={() => onDeleteContact(contextMenu.data!)}
|
||||
onAddToGroup={() => onAddContactToGroup(contextMenu.data!.id)}
|
||||
onCompose={onComposeContact ? () => onComposeContact(contextMenu.data!) : undefined}
|
||||
onMoveTo={onMoveContacts ? () => onMoveContacts([contextMenu.data!.id]) : undefined}
|
||||
onBatchMoveTo={onMoveContacts ? () => onMoveContacts([...selectedContactIds]) : undefined}
|
||||
onBatchExport={onBulkExport}
|
||||
onBatchAddToGroup={onBulkAddToGroup}
|
||||
onBatchDelete={onBulkDelete}
|
||||
|
||||
@@ -15,6 +15,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 { AddressBookPickerDialog } from "@/components/contacts/address-book-picker-dialog";
|
||||
import { DirectoryBrowser, directoryKey } from "@/components/contacts/directory-browser";
|
||||
import { DirectoryEntryDetail } from "@/components/contacts/directory-entry-detail";
|
||||
import { PromptDialog } from "@/components/ui/prompt-dialog";
|
||||
@@ -116,6 +117,8 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
shareAddressBook,
|
||||
setDefaultAddressBook,
|
||||
renameKeyword,
|
||||
removeKeyword,
|
||||
addKeyword,
|
||||
importContacts,
|
||||
} = useContactStore();
|
||||
|
||||
@@ -129,6 +132,8 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
const [createPrefill, setCreatePrefill] = useState<{ email?: string; name?: string } | undefined>(undefined);
|
||||
const [returnToEmail, setReturnToEmail] = useState(false);
|
||||
const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null);
|
||||
// "Move to…": the contacts waiting for an address book to be picked.
|
||||
const [movingContactIds, setMovingContactIds] = useState<string[] | null>(null);
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||
// Who a new group starts with: the selection when it came from "Add to group".
|
||||
const [newGroupMemberIds, setNewGroupMemberIds] = useState<string[]>([]);
|
||||
@@ -415,6 +420,44 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
}
|
||||
}, [client, moveContactToAddressBook, t]);
|
||||
|
||||
// Address books contacts can be moved to; "Move to…" needs a second one.
|
||||
const writableBooks = useMemo(() => addressBooks.filter((b) => b.myRights?.mayWrite !== false), [addressBooks]);
|
||||
const canMoveContacts = !!client && supportsSync && writableBooks.length > 1;
|
||||
const handleMoveContacts = useCallback((ids: string[]) => {
|
||||
if (ids.length > 0) setMovingContactIds(ids);
|
||||
}, []);
|
||||
// The book the contacts are in, when they share one, so the picker can leave it out.
|
||||
const movingFromBookId = useMemo(() => {
|
||||
if (!movingContactIds) return null;
|
||||
const books = new Set(movingContactIds.flatMap((id) => {
|
||||
const c = contacts.find((x) => x.id === id);
|
||||
return c?.addressBookIds ? Object.keys(c.addressBookIds).filter((k) => c.addressBookIds[k]) : [];
|
||||
}));
|
||||
return books.size === 1 ? [...books][0] : null;
|
||||
}, [movingContactIds, contacts]);
|
||||
|
||||
// Deleting a category only takes a label off contacts: no question, an Undo (R3).
|
||||
const handleDeleteKeyword = useCallback(async (keyword: string) => {
|
||||
const jmapClient = supportsSync && client ? client : null;
|
||||
try {
|
||||
const ids = await removeKeyword(jmapClient, keyword);
|
||||
if (typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === keyword) {
|
||||
setActiveCategory("all");
|
||||
}
|
||||
toast.success(t("category_deleted", { name: keyword }), {
|
||||
action: {
|
||||
label: t("undo"),
|
||||
onClick: () => {
|
||||
void addKeyword(jmapClient, keyword, ids).catch(() => toast.error(t("toast.error_update")));
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to delete category:", error);
|
||||
toast.error(t("category_delete_failed"));
|
||||
}
|
||||
}, [supportsSync, client, removeKeyword, addKeyword, activeCategory, t]);
|
||||
|
||||
const handleDropContactsToCategory = useCallback(async (contactIds: string[], keyword: string) => {
|
||||
if (!client && supportsSync) return;
|
||||
try {
|
||||
@@ -1002,6 +1045,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
? () => void handleDuplicateContact(selectedContact)
|
||||
: undefined
|
||||
}
|
||||
onMoveTo={selectedContact && canMoveContacts ? () => handleMoveContacts([selectedContact.id]) : undefined}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
);
|
||||
@@ -1146,6 +1190,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
}
|
||||
}) : undefined}
|
||||
onRenameKeyword={fromDrawer((kw: string) => setRenamingKeyword(kw))}
|
||||
onDeleteKeyword={fromDrawer((kw: string) => void handleDeleteKeyword(kw))}
|
||||
directoryCount={showDirectory ? directoryPrincipals.length : undefined}
|
||||
multiAccountMode={multiAccountEnabled && accountClients.length > 1}
|
||||
/>
|
||||
@@ -1223,6 +1268,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
onDeleteContact={handleDeleteContact}
|
||||
onAddContactToGroup={guard(handleAddContactToGroup)}
|
||||
onComposeContact={handleComposeContact}
|
||||
onMoveContacts={canMoveContacts ? handleMoveContacts : undefined}
|
||||
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined}
|
||||
isLoading={supportsSync && !contactsLoaded}
|
||||
/>
|
||||
@@ -1335,6 +1381,16 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
})();
|
||||
}}
|
||||
/>
|
||||
<AddressBookPickerDialog
|
||||
isOpen={movingContactIds !== null}
|
||||
onClose={() => setMovingContactIds(null)}
|
||||
addressBooks={writableBooks}
|
||||
currentId={movingFromBookId}
|
||||
count={movingContactIds?.length ?? 0}
|
||||
onPick={(book) => {
|
||||
if (movingContactIds) void handleDropContacts(movingContactIds, book);
|
||||
}}
|
||||
/>
|
||||
{showImportDialog && (
|
||||
<ContactImportDialog
|
||||
existingContacts={contacts}
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { BookUser, Building2, User, Users, Plus, Share2, Book, BookPlus, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings, Mail, Star } from "@/components/icons";
|
||||
import { BookUser, Building2, User, Users, Plus, Share2, Book, BookPlus, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings, Mail, Star, MoreVertical } from "@/components/icons";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { isContextMenuKey, useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { useLongPress } from "@/hooks/use-long-press";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName } from "@/stores/contact-store";
|
||||
@@ -35,6 +37,8 @@ interface ContactsSidebarProps {
|
||||
onCreateContactInBook?: (addressBook: AddressBook) => void;
|
||||
onDeleteAddressBook?: (addressBook: AddressBook) => void;
|
||||
onRenameKeyword?: (keyword: string) => void;
|
||||
/** Take the category off every contact. */
|
||||
onDeleteKeyword?: (keyword: string) => void;
|
||||
/** Entries in the company directory; the Directory entry shows only when set. */
|
||||
directoryCount?: number;
|
||||
className?: string;
|
||||
@@ -108,16 +112,24 @@ export function ContactsSidebar({
|
||||
onCreateContactInBook,
|
||||
onDeleteAddressBook,
|
||||
onRenameKeyword,
|
||||
onDeleteKeyword,
|
||||
directoryCount,
|
||||
className,
|
||||
multiAccountMode,
|
||||
}: 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: groupContextMenu, openContextMenu: openGroupContextMenu, openContextMenuFrom: openGroupMenuFrom, closeContextMenu: closeGroupContextMenu, menuRef: groupMenuRef } = useContextMenu<ContactCard>();
|
||||
const { contextMenu: bookContextMenu, openContextMenu: openBookContextMenu, openContextMenuFrom: openBookMenuFrom, closeContextMenu: closeBookContextMenu, menuRef: bookMenuRef } = useContextMenu<AddressBook>();
|
||||
const hasBookMenu = !!(onRenameAddressBook || onShareAddressBook || onSetDefaultAddressBook || onCreateContactInBook || onDeleteAddressBook);
|
||||
const { contextMenu: keywordContextMenu, openContextMenu: openKeywordContextMenu, closeContextMenu: closeKeywordContextMenu, menuRef: keywordMenuRef } = useContextMenu<string>();
|
||||
const { contextMenu: keywordContextMenu, openContextMenu: openKeywordContextMenu, openContextMenuFrom: openKeywordMenuFrom, closeContextMenu: closeKeywordContextMenu, menuRef: keywordMenuRef } = useContextMenu<string>();
|
||||
const hasKeywordMenu = !!(onRenameKeyword || onDeleteKeyword);
|
||||
// Every row with a menu opens it by right-click, its ⋮, long-press and the menu key.
|
||||
const bookMenu = (book: AddressBook) => hasBookMenu ? {
|
||||
onContextMenu: (e: React.MouseEvent) => openBookContextMenu(e, book),
|
||||
onOpenMenu: (anchor: HTMLElement) => openBookMenuFrom(anchor, book, "end"),
|
||||
menuLabel: t("row_actions", { name: book.name }),
|
||||
} : {};
|
||||
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(loadCollapsed);
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
@@ -413,7 +425,7 @@ export function ContactsSidebar({
|
||||
contactCount={contactCountByBook[book.id] || 0}
|
||||
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||
onDropContacts={onDropContacts}
|
||||
onContextMenu={hasBookMenu ? (e) => openBookContextMenu(e, book) : undefined}
|
||||
{...bookMenu(book)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -432,7 +444,7 @@ export function ContactsSidebar({
|
||||
contactCount={contactCountByBook[book.id] || 0}
|
||||
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||
onDropContacts={onDropContacts}
|
||||
onContextMenu={hasBookMenu ? (e) => openBookContextMenu(e, book) : undefined}
|
||||
{...bookMenu(book)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -479,7 +491,7 @@ export function ContactsSidebar({
|
||||
contactCount={contactCountByBook[book.id] || 0}
|
||||
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||
onDropContacts={onDropContacts}
|
||||
onContextMenu={hasBookMenu ? (e) => openBookContextMenu(e, book) : undefined}
|
||||
{...bookMenu(book)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -507,25 +519,19 @@ export function ContactsSidebar({
|
||||
const isActive = typeof activeCategory === "object" && "groupId" in activeCategory && activeCategory.groupId === group.id;
|
||||
const memberCount = memberCountByGroup[group.id] || 0;
|
||||
|
||||
const groupName = getContactDisplayName(group);
|
||||
return (
|
||||
<button
|
||||
<SidebarRow
|
||||
key={group.id}
|
||||
onClick={() => onSelectCategory({ groupId: group.id })}
|
||||
isActive={isActive}
|
||||
icon={<Users className="w-4 h-4 flex-shrink-0" />}
|
||||
label={groupName}
|
||||
count={memberCount}
|
||||
onSelect={() => onSelectCategory({ groupId: group.id })}
|
||||
onContextMenu={(e) => openGroupContextMenu(e, group)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 ps-5 pe-3 text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-foreground/80 hover:bg-muted"
|
||||
)}
|
||||
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }}
|
||||
>
|
||||
<Users className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="truncate">{getContactDisplayName(group)}</span>
|
||||
<span className="ms-auto text-xs text-muted-foreground tabular-nums">
|
||||
{memberCount}
|
||||
</span>
|
||||
</button>
|
||||
onOpenMenu={(anchor) => openGroupMenuFrom(anchor, group, "end")}
|
||||
menuLabel={t("row_actions", { name: groupName })}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -576,7 +582,9 @@ export function ContactsSidebar({
|
||||
isActive={isActive}
|
||||
onSelect={() => onSelectCategory({ keyword })}
|
||||
onDropContacts={onDropContactsToCategory}
|
||||
onContextMenu={onRenameKeyword ? (e) => openKeywordContextMenu(e, keyword) : undefined}
|
||||
onContextMenu={hasKeywordMenu ? (e) => openKeywordContextMenu(e, keyword) : undefined}
|
||||
onOpenMenu={hasKeywordMenu ? (anchor) => openKeywordMenuFrom(anchor, keyword, "end") : undefined}
|
||||
menuLabel={t("row_actions", { name: keyword })}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -623,7 +631,7 @@ export function ContactsSidebar({
|
||||
contactCount={contactCountByBook[book.id] || 0}
|
||||
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||
onDropContacts={onDropContacts}
|
||||
onContextMenu={hasBookMenu ? (e) => openBookContextMenu(e, book) : undefined}
|
||||
{...bookMenu(book)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -705,22 +713,37 @@ export function ContactsSidebar({
|
||||
})()}
|
||||
|
||||
{/* Keyword (category) context menu */}
|
||||
{keywordContextMenu.data && onRenameKeyword && (
|
||||
{keywordContextMenu.data && hasKeywordMenu && (
|
||||
<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);
|
||||
}}
|
||||
/>
|
||||
{onRenameKeyword && (
|
||||
<ContextMenuItem
|
||||
icon={Pencil}
|
||||
label={t("rename_category")}
|
||||
onClick={() => {
|
||||
const kw = keywordContextMenu.data!;
|
||||
closeKeywordContextMenu();
|
||||
onRenameKeyword(kw);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{onRenameKeyword && onDeleteKeyword && <ContextMenuSeparator />}
|
||||
{onDeleteKeyword && (
|
||||
<ContextMenuItem
|
||||
icon={Trash2}
|
||||
label={t("delete_category")}
|
||||
destructive
|
||||
onClick={() => {
|
||||
const kw = keywordContextMenu.data!;
|
||||
closeKeywordContextMenu();
|
||||
onDeleteKeyword(kw);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ContextMenu>
|
||||
)}
|
||||
|
||||
@@ -771,6 +794,83 @@ export function ContactsSidebar({
|
||||
);
|
||||
}
|
||||
|
||||
const ROW_STYLE = { paddingBlock: "var(--density-sidebar-py, 4px)", minHeight: "32px" } as const;
|
||||
|
||||
interface RowMenuProps {
|
||||
/** Right-click: the menu at the pointer. */
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
/** The ⋮, long-press and the menu key: the menu under the element. */
|
||||
onOpenMenu?: (anchor: HTMLElement) => void;
|
||||
menuLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A sidebar row: the row itself selects, a ⋮ at its end (shown on hover and
|
||||
* focus, always on touch) opens its menu, as do right-click, long-press and
|
||||
* Shift+F10 or the Menu key (R6, R7).
|
||||
*/
|
||||
function SidebarRow({
|
||||
isActive, isDragOver, icon, label, count, extra, onSelect, onContextMenu, onOpenMenu, menuLabel, dropProps, rowProps,
|
||||
}: RowMenuProps & {
|
||||
isActive: boolean;
|
||||
isDragOver?: boolean;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
count: number;
|
||||
extra?: React.ReactNode;
|
||||
onSelect: () => void;
|
||||
dropProps?: React.HTMLAttributes<HTMLDivElement>;
|
||||
rowProps?: Record<string, string>;
|
||||
}) {
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
const longPress = useLongPress(() => {
|
||||
if (rowRef.current && onOpenMenu) onOpenMenu(rowRef.current);
|
||||
}, !!onOpenMenu);
|
||||
return (
|
||||
<div
|
||||
ref={rowRef}
|
||||
{...rowProps}
|
||||
{...dropProps}
|
||||
{...(onOpenMenu ? longPress : {})}
|
||||
onContextMenu={onContextMenu}
|
||||
className={cn(
|
||||
"group w-full flex items-center ps-5 pe-1 text-sm transition-colors select-none",
|
||||
isActive ? "bg-accent text-accent-foreground font-medium" : "text-foreground/80 hover:bg-muted",
|
||||
isDragOver && "bg-primary/20 ring-2 ring-primary/50",
|
||||
)}
|
||||
style={ROW_STYLE}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
onKeyDown={(e) => {
|
||||
if (onOpenMenu && isContextMenuKey(e)) {
|
||||
e.preventDefault();
|
||||
onOpenMenu(e.currentTarget);
|
||||
}
|
||||
}}
|
||||
aria-current={isActive ? "true" : undefined}
|
||||
className="flex flex-1 min-w-0 items-center gap-2 self-stretch text-start rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
>
|
||||
{icon}
|
||||
<span className="truncate" title={label}>{label}</span>
|
||||
{extra}
|
||||
<span className={cn("pe-2 text-xs text-muted-foreground tabular-nums", !extra && "ms-auto")}>{count}</span>
|
||||
</button>
|
||||
{onOpenMenu && menuLabel && (
|
||||
<IconButton
|
||||
label={menuLabel}
|
||||
aria-haspopup="menu"
|
||||
onClick={(e) => onOpenMenu(e.currentTarget)}
|
||||
className="shrink-0 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100"
|
||||
>
|
||||
<MoreVertical />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryItem({
|
||||
keyword,
|
||||
count,
|
||||
@@ -778,17 +878,18 @@ function CategoryItem({
|
||||
onSelect,
|
||||
onDropContacts,
|
||||
onContextMenu,
|
||||
}: {
|
||||
onOpenMenu,
|
||||
menuLabel,
|
||||
}: RowMenuProps & {
|
||||
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);
|
||||
|
||||
const handleDragOver = useCallback((e: DragEvent<HTMLButtonElement>) => {
|
||||
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
if (!e.dataTransfer.types.includes("application/x-contact-ids")) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
@@ -799,7 +900,7 @@ function CategoryItem({
|
||||
setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: DragEvent<HTMLButtonElement>) => {
|
||||
const handleDrop = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
const data = e.dataTransfer.getData("application/x-contact-ids");
|
||||
@@ -815,27 +916,18 @@ function CategoryItem({
|
||||
}, [keyword, onDropContacts]);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
<SidebarRow
|
||||
isActive={isActive}
|
||||
isDragOver={isDragOver}
|
||||
icon={<Tag className="w-3.5 h-3.5 flex-shrink-0" />}
|
||||
label={keyword}
|
||||
count={count}
|
||||
onSelect={onSelect}
|
||||
onContextMenu={onContextMenu}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 ps-5 pe-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' }}
|
||||
>
|
||||
<Tag className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="truncate">{keyword}</span>
|
||||
<span className="ms-auto text-xs text-muted-foreground tabular-nums">
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
onOpenMenu={onOpenMenu}
|
||||
menuLabel={menuLabel}
|
||||
dropProps={{ onDragOver: handleDragOver, onDragLeave: handleDragLeave, onDrop: handleDrop }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -846,17 +938,18 @@ function AddressBookItem({
|
||||
onSelect,
|
||||
onDropContacts,
|
||||
onContextMenu,
|
||||
}: {
|
||||
onOpenMenu,
|
||||
menuLabel,
|
||||
}: RowMenuProps & {
|
||||
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);
|
||||
|
||||
const handleDragOver = useCallback((e: DragEvent<HTMLButtonElement>) => {
|
||||
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
if (!e.dataTransfer.types.includes("application/x-contact-ids")) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
@@ -867,7 +960,7 @@ function AddressBookItem({
|
||||
setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: DragEvent<HTMLButtonElement>) => {
|
||||
const handleDrop = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
const data = e.dataTransfer.getData("application/x-contact-ids");
|
||||
@@ -882,36 +975,21 @@ function AddressBookItem({
|
||||
}
|
||||
}, [book, onDropContacts]);
|
||||
|
||||
const sharedOut = !book.isShared && Object.keys(book.shareWith || {}).length > 0;
|
||||
return (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
<SidebarRow
|
||||
isActive={isActive}
|
||||
isDragOver={isDragOver}
|
||||
icon={<Book className="w-4 h-4 flex-shrink-0" />}
|
||||
label={book.name}
|
||||
count={contactCount}
|
||||
extra={sharedOut ? <Users className="w-3 h-3 text-muted-foreground flex-shrink-0 ms-auto" /> : undefined}
|
||||
onSelect={onSelect}
|
||||
onContextMenu={onContextMenu}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
data-testid="address-book-item"
|
||||
data-book-name={book.name}
|
||||
data-account={book.accountName ?? ''}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 ps-5 pe-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>
|
||||
{!book.isShared && Object.keys(book.shareWith || {}).length > 0 && (
|
||||
<Users className="w-3 h-3 text-muted-foreground flex-shrink-0 ms-auto" />
|
||||
)}
|
||||
<span className={cn(
|
||||
"text-xs text-muted-foreground tabular-nums",
|
||||
!(!book.isShared && Object.keys(book.shareWith || {}).length > 0) && "ms-auto"
|
||||
)}>
|
||||
{contactCount}
|
||||
</span>
|
||||
</button>
|
||||
onOpenMenu={onOpenMenu}
|
||||
menuLabel={menuLabel}
|
||||
dropProps={{ onDragOver: handleDragOver, onDragLeave: handleDragLeave, onDrop: handleDrop }}
|
||||
rowProps={{ "data-testid": "address-book-item", "data-book-name": book.name, "data-account": book.accountName ?? "" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2911,6 +2911,10 @@
|
||||
"category_name_label": "اسم الفئة",
|
||||
"category_renamed": "تمت إعادة تسمية الفئة",
|
||||
"category_rename_failed": "فشلت إعادة تسمية الفئة",
|
||||
"delete_category": "حذف الفئة",
|
||||
"category_deleted": "حُذفت الفئة \"{name}\"",
|
||||
"category_delete_failed": "تعذّر حذف الفئة",
|
||||
"undo": "تراجع",
|
||||
"category_added": "تمت إضافة جهة الاتصال إلى {name}",
|
||||
"category_added_plural": "تمت إضافة {count} جهات اتصال إلى {name}",
|
||||
"empty_state": "لا توجد جهات اتصال بعد",
|
||||
@@ -2928,6 +2932,7 @@
|
||||
"back_to_contacts": "العودة إلى جهات الاتصال",
|
||||
"back_to_email": "العودة إلى البريد",
|
||||
"open_categories": "فتح الفئات",
|
||||
"row_actions": "إجراءات {name}",
|
||||
"tabs": {
|
||||
"all": "الكل",
|
||||
"groups": "المجموعات"
|
||||
@@ -2960,7 +2965,10 @@
|
||||
"delete": "حذف دفتر العناوين",
|
||||
"confirm_delete": "حذف \"{name}\"؟ ستتم إزالة جميع جهات الاتصال في دفتر العناوين هذا.",
|
||||
"deleted": "تم حذف دفتر العناوين",
|
||||
"delete_failed": "فشل حذف دفتر العناوين"
|
||||
"delete_failed": "فشل حذف دفتر العناوين",
|
||||
"move_to": "نقل إلى…",
|
||||
"move_title": "نقل إلى دفتر عناوين",
|
||||
"move_here": "نقل إلى هنا"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "عناوين البريد الإلكتروني",
|
||||
|
||||
@@ -2911,6 +2911,10 @@
|
||||
"category_name_label": "Nom de la categoria",
|
||||
"category_renamed": "Nom de la categoria canviat",
|
||||
"category_rename_failed": "No s'ha pogut canviar el nom de la categoria",
|
||||
"delete_category": "Suprimeix la categoria",
|
||||
"category_deleted": "S'ha suprimit la categoria «{name}»",
|
||||
"category_delete_failed": "No s'ha pogut suprimir la categoria",
|
||||
"undo": "Desfés",
|
||||
"category_added": "Contacte afegit a {name}",
|
||||
"category_added_plural": "{count} contactes afegits a {name}",
|
||||
"empty_state": "Encara no hi ha contactes",
|
||||
@@ -2928,6 +2932,7 @@
|
||||
"back_to_contacts": "Torna als contactes",
|
||||
"back_to_email": "Torna al correu",
|
||||
"open_categories": "Obre les categories",
|
||||
"row_actions": "Accions per a {name}",
|
||||
"tabs": {
|
||||
"all": "Tots",
|
||||
"groups": "Grups"
|
||||
@@ -2960,7 +2965,10 @@
|
||||
"delete": "Suprimeix la llibreta d'adreces",
|
||||
"confirm_delete": "Voleu suprimir «{name}»? S'eliminaran tots els contactes d'aquesta llibreta d'adreces.",
|
||||
"deleted": "Llibreta d'adreces suprimida",
|
||||
"delete_failed": "No s'ha pogut suprimir la llibreta d'adreces"
|
||||
"delete_failed": "No s'ha pogut suprimir la llibreta d'adreces",
|
||||
"move_to": "Mou a…",
|
||||
"move_title": "Mou a una llibreta d'adreces",
|
||||
"move_here": "Mou aquí"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Adreces electròniques",
|
||||
|
||||
+10
-2
@@ -2911,6 +2911,10 @@
|
||||
"category_name_label": "Název kategorie",
|
||||
"category_renamed": "Kategorie byla přejmenována",
|
||||
"category_rename_failed": "Přejmenování kategorie selhalo",
|
||||
"delete_category": "Smazat kategorii",
|
||||
"category_deleted": "Kategorie „{name}“ byla smazána",
|
||||
"category_delete_failed": "Kategorii se nepodařilo smazat",
|
||||
"undo": "Zpět",
|
||||
"category_added": "Kontakt přidán do {name}",
|
||||
"category_added_plural": "{count} kontaktů bylo přidáno do {name}",
|
||||
"empty_state": "Žádné kontakty",
|
||||
@@ -2959,7 +2963,10 @@
|
||||
"delete": "Smazat adresář",
|
||||
"confirm_delete": "Smazat „{name}\"? Všechny kontakty v tomto adresáři budou odstraněny.",
|
||||
"deleted": "Adresář smazán",
|
||||
"delete_failed": "Adresář se nepodařilo smazat"
|
||||
"delete_failed": "Adresář se nepodařilo smazat",
|
||||
"move_to": "Přesunout do…",
|
||||
"move_title": "Přesunout do adresáře",
|
||||
"move_here": "Přesunout sem"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "E-mailové adresy",
|
||||
@@ -3248,7 +3255,8 @@
|
||||
"sort_first_name": "Jméno",
|
||||
"sort_last_name": "Příjmení"
|
||||
},
|
||||
"open_categories": "Otevřít kategorie"
|
||||
"open_categories": "Otevřít kategorie",
|
||||
"row_actions": "Akce pro {name}"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Kalendář",
|
||||
|
||||
+10
-2
@@ -2911,6 +2911,10 @@
|
||||
"category_name_label": "Kategorinavn",
|
||||
"category_renamed": "Kategori omdøbt",
|
||||
"category_rename_failed": "Kunne ikke omdøbe kategori",
|
||||
"delete_category": "Slet kategori",
|
||||
"category_deleted": "Kategorien \"{name}\" er slettet",
|
||||
"category_delete_failed": "Kunne ikke slette kategorien",
|
||||
"undo": "Fortryd",
|
||||
"category_added": "Kontakt tilføjet til {name}",
|
||||
"category_added_plural": "{count} kontakter tilføjet til {name}",
|
||||
"empty_state": "Ingen kontakter endnu",
|
||||
@@ -2959,7 +2963,10 @@
|
||||
"delete": "Slet adressebog",
|
||||
"confirm_delete": "Slet \"{name}\"? Alle kontakter i denne adressebog fjernes.",
|
||||
"deleted": "Adressebog slettet",
|
||||
"delete_failed": "Kunne ikke slette adressebog"
|
||||
"delete_failed": "Kunne ikke slette adressebog",
|
||||
"move_to": "Flyt til…",
|
||||
"move_title": "Flyt til adressebog",
|
||||
"move_here": "Flyt hertil"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "E-mailadresser",
|
||||
@@ -3248,7 +3255,8 @@
|
||||
"sort_first_name": "Fornavn",
|
||||
"sort_last_name": "Efternavn"
|
||||
},
|
||||
"open_categories": "Åbn kategorier"
|
||||
"open_categories": "Åbn kategorier",
|
||||
"row_actions": "Handlinger for {name}"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Kalender",
|
||||
|
||||
+10
-2
@@ -2911,6 +2911,10 @@
|
||||
"category_name_label": "Kategoriename",
|
||||
"category_renamed": "Kategorie umbenannt",
|
||||
"category_rename_failed": "Kategorie konnte nicht umbenannt werden",
|
||||
"delete_category": "Kategorie löschen",
|
||||
"category_deleted": "Kategorie „{name}“ gelöscht",
|
||||
"category_delete_failed": "Kategorie konnte nicht gelöscht werden",
|
||||
"undo": "Rückgängig",
|
||||
"category_added": "Kontakt zu {name} hinzugefügt",
|
||||
"category_added_plural": "{count} Kontakte zu {name} hinzugefügt",
|
||||
"empty_state": "Keine Kontakte",
|
||||
@@ -2959,7 +2963,10 @@
|
||||
"delete": "Adressbuch löschen",
|
||||
"confirm_delete": "„{name}\" löschen? Alle Kontakte in diesem Adressbuch werden entfernt.",
|
||||
"deleted": "Adressbuch gelöscht",
|
||||
"delete_failed": "Adressbuch konnte nicht gelöscht werden"
|
||||
"delete_failed": "Adressbuch konnte nicht gelöscht werden",
|
||||
"move_to": "Verschieben nach…",
|
||||
"move_title": "In Adressbuch verschieben",
|
||||
"move_here": "Hierher verschieben"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "E-Mail-Adressen",
|
||||
@@ -3248,7 +3255,8 @@
|
||||
"sort_first_name": "Vorname",
|
||||
"sort_last_name": "Nachname"
|
||||
},
|
||||
"open_categories": "Kategorien öffnen"
|
||||
"open_categories": "Kategorien öffnen",
|
||||
"row_actions": "Aktionen für {name}"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Kalender",
|
||||
|
||||
@@ -2911,6 +2911,10 @@
|
||||
"category_name_label": "Category name",
|
||||
"category_renamed": "Category renamed",
|
||||
"category_rename_failed": "Failed to rename category",
|
||||
"delete_category": "Delete category",
|
||||
"category_deleted": "Category \"{name}\" deleted",
|
||||
"category_delete_failed": "Couldn't delete the category",
|
||||
"undo": "Undo",
|
||||
"category_added": "Contact added to {name}",
|
||||
"category_added_plural": "{count} contacts added to {name}",
|
||||
"empty_state": "No contacts yet",
|
||||
@@ -2928,6 +2932,7 @@
|
||||
"back_to_contacts": "Back to contacts",
|
||||
"back_to_email": "Back to email",
|
||||
"open_categories": "Open categories",
|
||||
"row_actions": "Actions for {name}",
|
||||
"tabs": {
|
||||
"all": "All",
|
||||
"groups": "Groups"
|
||||
@@ -2960,7 +2965,10 @@
|
||||
"delete": "Delete address book",
|
||||
"confirm_delete": "Delete \"{name}\"? All contacts in this address book will be removed.",
|
||||
"deleted": "Address book deleted",
|
||||
"delete_failed": "Failed to delete address book"
|
||||
"delete_failed": "Failed to delete address book",
|
||||
"move_to": "Move to…",
|
||||
"move_title": "Move to address book",
|
||||
"move_here": "Move here"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Email Addresses",
|
||||
|
||||
+10
-2
@@ -2911,6 +2911,10 @@
|
||||
"category_name_label": "Nombre de la categoría",
|
||||
"category_renamed": "Categoría renombrada",
|
||||
"category_rename_failed": "Error al renombrar la categoría",
|
||||
"delete_category": "Eliminar categoría",
|
||||
"category_deleted": "Categoría «{name}» eliminada",
|
||||
"category_delete_failed": "No se ha podido eliminar la categoría",
|
||||
"undo": "Deshacer",
|
||||
"category_added": "Contacto añadido a {name}",
|
||||
"category_added_plural": "{count} contactos añadidos a {name}",
|
||||
"empty_state": "No hay contactos",
|
||||
@@ -2959,7 +2963,10 @@
|
||||
"delete": "Eliminar libreta de direcciones",
|
||||
"confirm_delete": "¿Eliminar «{name}»? Todos los contactos de esta libreta se eliminarán.",
|
||||
"deleted": "Libreta de direcciones eliminada",
|
||||
"delete_failed": "No se pudo eliminar la libreta de direcciones"
|
||||
"delete_failed": "No se pudo eliminar la libreta de direcciones",
|
||||
"move_to": "Mover a…",
|
||||
"move_title": "Mover a una libreta de direcciones",
|
||||
"move_here": "Mover aquí"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Direcciones de correo",
|
||||
@@ -3248,7 +3255,8 @@
|
||||
"sort_first_name": "Nombre",
|
||||
"sort_last_name": "Apellido"
|
||||
},
|
||||
"open_categories": "Abrir categorías"
|
||||
"open_categories": "Abrir categorías",
|
||||
"row_actions": "Acciones para {name}"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Calendario",
|
||||
|
||||
@@ -2911,6 +2911,10 @@
|
||||
"category_name_label": "Kategooria nimi",
|
||||
"category_renamed": "Kategooria nimetati ümber",
|
||||
"category_rename_failed": "Kategooria ümbernimetamine ebaõnnestus",
|
||||
"delete_category": "Kustuta kategooria",
|
||||
"category_deleted": "Kategooria „{name}“ kustutati",
|
||||
"category_delete_failed": "Kategooriat ei õnnestunud kustutada",
|
||||
"undo": "Võta tagasi",
|
||||
"category_added": "Kontakt lisati kategooriasse {name}",
|
||||
"category_added_plural": "{count} kontakti lisati kategooriasse {name}",
|
||||
"empty_state": "Kontakte veel pole",
|
||||
@@ -2928,6 +2932,7 @@
|
||||
"back_to_contacts": "Tagasi kontaktide juurde",
|
||||
"back_to_email": "Tagasi e-posti juurde",
|
||||
"open_categories": "Ava kategooriad",
|
||||
"row_actions": "Toimingud: {name}",
|
||||
"tabs": {
|
||||
"all": "Kõik",
|
||||
"groups": "Grupid"
|
||||
@@ -2960,7 +2965,10 @@
|
||||
"delete": "Kustuta aadressiraamat",
|
||||
"confirm_delete": "Kustutada \"{name}\"? Kõik selle aadressiraamatu kontaktid eemaldatakse.",
|
||||
"deleted": "Aadressiraamat kustutatud",
|
||||
"delete_failed": "Aadressiraamatu kustutamine ebaõnnestus"
|
||||
"delete_failed": "Aadressiraamatu kustutamine ebaõnnestus",
|
||||
"move_to": "Teisalda…",
|
||||
"move_title": "Teisalda aadressiraamatusse",
|
||||
"move_here": "Teisalda siia"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "E-posti aadressid",
|
||||
|
||||
@@ -2911,6 +2911,10 @@
|
||||
"category_name_label": "نام دستهبندی",
|
||||
"category_renamed": "نام دستهبندی تغییر کرد",
|
||||
"category_rename_failed": "تغییر نام دستهبندی ناموفق بود",
|
||||
"delete_category": "حذف دسته",
|
||||
"category_deleted": "دسته «{name}» حذف شد",
|
||||
"category_delete_failed": "دسته حذف نشد",
|
||||
"undo": "واگرد",
|
||||
"category_added": "مخاطب به {name} اضافه شد",
|
||||
"category_added_plural": "{count} مخاطب به {name} اضافه شد",
|
||||
"empty_state": "هنوز مخاطبی نیست",
|
||||
@@ -2928,6 +2932,7 @@
|
||||
"back_to_contacts": "بازگشت به مخاطبین",
|
||||
"back_to_email": "بازگشت به ایمیل",
|
||||
"open_categories": "باز کردن دستهبندیها",
|
||||
"row_actions": "کنشهای {name}",
|
||||
"tabs": {
|
||||
"all": "همه",
|
||||
"groups": "گروهها"
|
||||
@@ -2960,7 +2965,10 @@
|
||||
"delete": "حذف دفترچه آدرس",
|
||||
"confirm_delete": "حذف \"{name}\"؟ همه مخاطبین این دفترچه آدرس حذف خواهند شد.",
|
||||
"deleted": "دفترچه آدرس حذف شد",
|
||||
"delete_failed": "حذف دفترچه آدرس ناموفق بود"
|
||||
"delete_failed": "حذف دفترچه آدرس ناموفق بود",
|
||||
"move_to": "انتقال به…",
|
||||
"move_title": "انتقال به دفترچه نشانی",
|
||||
"move_here": "انتقال به اینجا"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "آدرسهای ایمیل",
|
||||
|
||||
+10
-2
@@ -2911,6 +2911,10 @@
|
||||
"category_name_label": "Nom de la catégorie",
|
||||
"category_renamed": "Catégorie renommée",
|
||||
"category_rename_failed": "Échec du renommage de la catégorie",
|
||||
"delete_category": "Supprimer la catégorie",
|
||||
"category_deleted": "Catégorie « {name} » supprimée",
|
||||
"category_delete_failed": "Impossible de supprimer la catégorie",
|
||||
"undo": "Annuler",
|
||||
"category_added": "Contact ajouté à {name}",
|
||||
"category_added_plural": "{count} contacts ajoutés à {name}",
|
||||
"empty_state": "Aucun contact",
|
||||
@@ -2959,7 +2963,10 @@
|
||||
"delete": "Supprimer le carnet d'adresses",
|
||||
"confirm_delete": "Supprimer « {name} » ? Tous les contacts de ce carnet d'adresses seront supprimés.",
|
||||
"deleted": "Carnet d'adresses supprimé",
|
||||
"delete_failed": "Échec de la suppression du carnet d'adresses"
|
||||
"delete_failed": "Échec de la suppression du carnet d'adresses",
|
||||
"move_to": "Déplacer vers…",
|
||||
"move_title": "Déplacer vers un carnet d'adresses",
|
||||
"move_here": "Déplacer ici"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Adresses e-mail",
|
||||
@@ -3248,7 +3255,8 @@
|
||||
"sort_first_name": "Prénom",
|
||||
"sort_last_name": "Nom de famille"
|
||||
},
|
||||
"open_categories": "Ouvrir les catégories"
|
||||
"open_categories": "Ouvrir les catégories",
|
||||
"row_actions": "Actions pour {name}"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Calendrier",
|
||||
|
||||
@@ -2829,6 +2829,10 @@
|
||||
"category_name_label": "שם קטגוריה",
|
||||
"category_renamed": "שם קטגוריה שונה",
|
||||
"category_rename_failed": "שינוי שם הקטגוריה נכשל",
|
||||
"delete_category": "מחק קטגוריה",
|
||||
"category_deleted": "הקטגוריה \"{name}\" נמחקה",
|
||||
"category_delete_failed": "לא ניתן היה למחוק את הקטגוריה",
|
||||
"undo": "בטל",
|
||||
"category_added": "איש קשר נוסף ל-{name}",
|
||||
"category_added_plural": "אנשי הקשר{count}נוספו ל-{name}",
|
||||
"empty_state": "עדיין אין אנשי קשר",
|
||||
@@ -2874,7 +2878,10 @@
|
||||
"delete": "מחק ספר כתובות",
|
||||
"confirm_delete": "מחק \"{name}\"? כל אנשי הקשר בספר כתובות זה יוסרו.",
|
||||
"deleted": "ספר כתובות נמחק",
|
||||
"delete_failed": "נכשל במחיקת ספר כתובות"
|
||||
"delete_failed": "נכשל במחיקת ספר כתובות",
|
||||
"move_to": "העבר אל…",
|
||||
"move_title": "העבר לפנקס כתובות",
|
||||
"move_here": "העבר לכאן"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "כתובות אימייל",
|
||||
@@ -3122,6 +3129,7 @@
|
||||
"empty_filtered_hint": "נסה להתאים או להביא סננים",
|
||||
"back_to_email": "חזור לדוא״ל",
|
||||
"open_categories": "קטגוריות פתוחות",
|
||||
"row_actions": "פעולות עבור {name}",
|
||||
"activity": {
|
||||
"recent_emails": "דוא״ל אחרונים",
|
||||
"upcoming_events": "אירועים קרובים",
|
||||
|
||||
@@ -2911,6 +2911,10 @@
|
||||
"category_name_label": "Kategória neve",
|
||||
"category_renamed": "Kategória átnevezve",
|
||||
"category_rename_failed": "Nem sikerült átnevezni a kategóriát",
|
||||
"delete_category": "Kategória törlése",
|
||||
"category_deleted": "A(z) „{name}” kategória törölve",
|
||||
"category_delete_failed": "Nem sikerült törölni a kategóriát",
|
||||
"undo": "Visszavonás",
|
||||
"category_added": "Névjegy hozzáadva ide: {name}",
|
||||
"category_added_plural": "{count} névjegy hozzáadva ide: {name}",
|
||||
"empty_state": "Még nincsenek névjegyek",
|
||||
@@ -2928,6 +2932,7 @@
|
||||
"back_to_contacts": "Vissza a névjegyekhez",
|
||||
"back_to_email": "Vissza az e-mailekhez",
|
||||
"open_categories": "Kategóriák megnyitása",
|
||||
"row_actions": "Műveletek: {name}",
|
||||
"tabs": {
|
||||
"all": "Összes",
|
||||
"groups": "Csoportok"
|
||||
@@ -2960,7 +2965,10 @@
|
||||
"delete": "Címjegyzék törlése",
|
||||
"confirm_delete": "Törlöd a \"{name}\" címjegyzéket? Az összes névjegy eltávolításra kerül.",
|
||||
"deleted": "Címjegyzék törölve",
|
||||
"delete_failed": "Nem sikerült törölni a címjegyzéket"
|
||||
"delete_failed": "Nem sikerült törölni a címjegyzéket",
|
||||
"move_to": "Áthelyezés ide…",
|
||||
"move_title": "Áthelyezés címjegyzékbe",
|
||||
"move_here": "Áthelyezés ide"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "E-mail címek",
|
||||
|
||||
+10
-2
@@ -2911,6 +2911,10 @@
|
||||
"category_name_label": "Nome della categoria",
|
||||
"category_renamed": "Categoria rinominata",
|
||||
"category_rename_failed": "Impossibile rinominare la categoria",
|
||||
"delete_category": "Elimina categoria",
|
||||
"category_deleted": "Categoria «{name}» eliminata",
|
||||
"category_delete_failed": "Impossibile eliminare la categoria",
|
||||
"undo": "Annulla",
|
||||
"category_added": "Contatto aggiunto a {name}",
|
||||
"category_added_plural": "{count} contatti aggiunti a {name}",
|
||||
"empty_state": "Nessun contatto",
|
||||
@@ -2959,7 +2963,10 @@
|
||||
"delete": "Elimina rubrica",
|
||||
"confirm_delete": "Eliminare \"{name}\"? Tutti i contatti in questa rubrica verranno rimossi.",
|
||||
"deleted": "Rubrica eliminata",
|
||||
"delete_failed": "Impossibile eliminare la rubrica"
|
||||
"delete_failed": "Impossibile eliminare la rubrica",
|
||||
"move_to": "Sposta in…",
|
||||
"move_title": "Sposta in una rubrica",
|
||||
"move_here": "Sposta qui"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Indirizzi email",
|
||||
@@ -3248,7 +3255,8 @@
|
||||
"sort_first_name": "Nome",
|
||||
"sort_last_name": "Cognome"
|
||||
},
|
||||
"open_categories": "Apri categorie"
|
||||
"open_categories": "Apri categorie",
|
||||
"row_actions": "Azioni per {name}"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Calendario",
|
||||
|
||||
+10
-2
@@ -2911,6 +2911,10 @@
|
||||
"category_name_label": "カテゴリ名",
|
||||
"category_renamed": "カテゴリの名前を変更しました",
|
||||
"category_rename_failed": "カテゴリの名前変更に失敗しました",
|
||||
"delete_category": "カテゴリを削除",
|
||||
"category_deleted": "カテゴリ「{name}」を削除しました",
|
||||
"category_delete_failed": "カテゴリを削除できませんでした",
|
||||
"undo": "元に戻す",
|
||||
"category_added": "{name} に連絡先を追加しました",
|
||||
"category_added_plural": "{count} 件の連絡先を {name} に追加しました",
|
||||
"empty_state": "連絡先がありません",
|
||||
@@ -2959,7 +2963,10 @@
|
||||
"delete": "アドレス帳を削除",
|
||||
"confirm_delete": "「{name}」を削除しますか?このアドレス帳のすべての連絡先が削除されます。",
|
||||
"deleted": "アドレス帳を削除しました",
|
||||
"delete_failed": "アドレス帳の削除に失敗しました"
|
||||
"delete_failed": "アドレス帳の削除に失敗しました",
|
||||
"move_to": "移動先…",
|
||||
"move_title": "アドレス帳に移動",
|
||||
"move_here": "ここに移動"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "メールアドレス",
|
||||
@@ -3248,7 +3255,8 @@
|
||||
"sort_first_name": "名",
|
||||
"sort_last_name": "姓"
|
||||
},
|
||||
"open_categories": "カテゴリを開く"
|
||||
"open_categories": "カテゴリを開く",
|
||||
"row_actions": "{name} の操作"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "カレンダー",
|
||||
|
||||
+10
-2
@@ -2911,6 +2911,10 @@
|
||||
"category_name_label": "카테고리 이름",
|
||||
"category_renamed": "카테고리 이름이 변경되었습니다",
|
||||
"category_rename_failed": "카테고리 이름 변경 실패",
|
||||
"delete_category": "카테고리 삭제",
|
||||
"category_deleted": "\"{name}\" 카테고리를 삭제했습니다",
|
||||
"category_delete_failed": "카테고리를 삭제하지 못했습니다",
|
||||
"undo": "실행 취소",
|
||||
"category_added": "{name}에 연락처가 추가되었어요",
|
||||
"category_added_plural": "{name}에 {count}개의 연락처가 추가되었어요",
|
||||
"empty_state": "아직 연락처가 없어요",
|
||||
@@ -2959,7 +2963,10 @@
|
||||
"delete": "주소록 삭제",
|
||||
"confirm_delete": "\"{name}\"을(를) 삭제하시겠습니까? 이 주소록의 모든 연락처가 삭제됩니다.",
|
||||
"deleted": "주소록이 삭제되었습니다",
|
||||
"delete_failed": "주소록 삭제에 실패했습니다"
|
||||
"delete_failed": "주소록 삭제에 실패했습니다",
|
||||
"move_to": "이동…",
|
||||
"move_title": "주소록으로 이동",
|
||||
"move_here": "여기로 이동"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "이메일",
|
||||
@@ -3248,7 +3255,8 @@
|
||||
"sort_first_name": "이름",
|
||||
"sort_last_name": "성"
|
||||
},
|
||||
"open_categories": "카테고리 열기"
|
||||
"open_categories": "카테고리 열기",
|
||||
"row_actions": "{name} 작업"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "캘린더",
|
||||
|
||||
+10
-2
@@ -2955,7 +2955,10 @@
|
||||
"delete": "Dzēst adrešu grāmatu",
|
||||
"confirm_delete": "Dzēst \"{name}\"? Visi kontakti šajā adrešu grāmatā tiks noņemti.",
|
||||
"deleted": "Adrešu grāmata dzēsta",
|
||||
"delete_failed": "Neizdevās dzēst adrešu grāmatu"
|
||||
"delete_failed": "Neizdevās dzēst adrešu grāmatu",
|
||||
"move_to": "Pārvietot uz…",
|
||||
"move_title": "Pārvietot uz adrešu grāmatu",
|
||||
"move_here": "Pārvietot šeit"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "E-pasta adreses",
|
||||
@@ -3214,6 +3217,10 @@
|
||||
"category_name_label": "Kategorijas nosaukums",
|
||||
"category_renamed": "Kategorija pārdēvēta",
|
||||
"category_rename_failed": "Neizdevās pārdēvēt kategoriju",
|
||||
"delete_category": "Dzēst kategoriju",
|
||||
"category_deleted": "Kategorija “{name}” dzēsta",
|
||||
"category_delete_failed": "Neizdevās dzēst kategoriju",
|
||||
"undo": "Atsaukt",
|
||||
"context_menu": {
|
||||
"open": "Atvērt",
|
||||
"edit": "Rediģēt",
|
||||
@@ -3248,7 +3255,8 @@
|
||||
"sort_first_name": "Vārds",
|
||||
"sort_last_name": "Uzvārds"
|
||||
},
|
||||
"open_categories": "Atvērt kategorijas"
|
||||
"open_categories": "Atvērt kategorijas",
|
||||
"row_actions": "Darbības: {name}"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Kalendārs",
|
||||
|
||||
@@ -2911,6 +2911,10 @@
|
||||
"category_name_label": "Ангиллын нэр",
|
||||
"category_renamed": "Ангиллын нэрийг өөрчилсөн",
|
||||
"category_rename_failed": "Ангиллын нэрийг өөрчилж чадсангүй",
|
||||
"delete_category": "Ангиллыг устгах",
|
||||
"category_deleted": "\"{name}\" ангиллыг устгалаа",
|
||||
"category_delete_failed": "Ангиллыг устгаж чадсангүй",
|
||||
"undo": "Буцаах",
|
||||
"category_added": "Харилцагчийг {name}",
|
||||
"category_added_plural": "{count} харилцагчдыг {name}",
|
||||
"empty_state": "Харилцагч байхгүй",
|
||||
@@ -2928,6 +2932,7 @@
|
||||
"back_to_contacts": "Харилцагч руу буцах",
|
||||
"back_to_email": "Имэйл рүү буцах",
|
||||
"open_categories": "Нээлттэй ангилалууд",
|
||||
"row_actions": "{name}-н үйлдлүүд",
|
||||
"tabs": {
|
||||
"all": "Бүгд",
|
||||
"groups": "Бүлэг"
|
||||
@@ -2960,7 +2965,10 @@
|
||||
"delete": "Хаягийн дэвтэр устгах",
|
||||
"confirm_delete": "\"{name}\"-г устгах уу? Энэ хаягийн дэвтэр дэх бүх харилцагчийг устгах болно.",
|
||||
"deleted": "Хаягийн дэвтэр устсан",
|
||||
"delete_failed": "Хаягийн дэвтрийг устгаж чадсангүй"
|
||||
"delete_failed": "Хаягийн дэвтрийг устгаж чадсангүй",
|
||||
"move_to": "Зөөх…",
|
||||
"move_title": "Хаягийн дэвтэр рүү зөөх",
|
||||
"move_here": "Энд зөөх"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Имэйл хаягууд",
|
||||
|
||||
@@ -2911,6 +2911,10 @@
|
||||
"category_name_label": "Kategorinavn",
|
||||
"category_renamed": "Kategorien har fått nytt navn",
|
||||
"category_rename_failed": "Kunne ikke gi kategorien nytt navn",
|
||||
"delete_category": "Slett kategori",
|
||||
"category_deleted": "Kategorien «{name}» er slettet",
|
||||
"category_delete_failed": "Kunne ikke slette kategorien",
|
||||
"undo": "Angre",
|
||||
"category_added": "Kontakt lagt til i {name}",
|
||||
"category_added_plural": "{count} kontakter lagt til i {name}",
|
||||
"empty_state": "Ingen kontakter ennå",
|
||||
@@ -2928,6 +2932,7 @@
|
||||
"back_to_contacts": "Tilbake til kontaktene",
|
||||
"back_to_email": "Tilbake til e-post",
|
||||
"open_categories": "Åpne kategorier",
|
||||
"row_actions": "Handlinger for {name}",
|
||||
"tabs": {
|
||||
"all": "Alle",
|
||||
"groups": "Grupper"
|
||||
@@ -2960,7 +2965,10 @@
|
||||
"delete": "Slett adressebok",
|
||||
"confirm_delete": "Vil du slette «{name}»? Alle kontaktene i denne adresseboken fjernes.",
|
||||
"deleted": "Adresseboken er slettet",
|
||||
"delete_failed": "Kunne ikke slette adresseboken"
|
||||
"delete_failed": "Kunne ikke slette adresseboken",
|
||||
"move_to": "Flytt til…",
|
||||
"move_title": "Flytt til adressebok",
|
||||
"move_here": "Flytt hit"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "E-postadresser",
|
||||
|
||||
+10
-2
@@ -2911,6 +2911,10 @@
|
||||
"category_name_label": "Categorienaam",
|
||||
"category_renamed": "Categorie hernoemd",
|
||||
"category_rename_failed": "Categorie hernoemen mislukt",
|
||||
"delete_category": "Categorie verwijderen",
|
||||
"category_deleted": "Categorie '{name}' verwijderd",
|
||||
"category_delete_failed": "Kan de categorie niet verwijderen",
|
||||
"undo": "Ongedaan maken",
|
||||
"category_added": "Contact toegevoegd aan {name}",
|
||||
"category_added_plural": "{count} contacten toegevoegd aan {name}",
|
||||
"empty_state": "Geen contacten",
|
||||
@@ -2959,7 +2963,10 @@
|
||||
"delete": "Adresboek verwijderen",
|
||||
"confirm_delete": "\"{name}\" verwijderen? Alle contacten in dit adresboek worden verwijderd.",
|
||||
"deleted": "Adresboek verwijderd",
|
||||
"delete_failed": "Adresboek kon niet worden verwijderd"
|
||||
"delete_failed": "Adresboek kon niet worden verwijderd",
|
||||
"move_to": "Verplaatsen naar…",
|
||||
"move_title": "Naar adresboek verplaatsen",
|
||||
"move_here": "Hierheen verplaatsen"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "E-mailadressen",
|
||||
@@ -3248,7 +3255,8 @@
|
||||
"sort_first_name": "Voornaam",
|
||||
"sort_last_name": "Achternaam"
|
||||
},
|
||||
"open_categories": "Categorieën openen"
|
||||
"open_categories": "Categorieën openen",
|
||||
"row_actions": "Acties voor {name}"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Agenda",
|
||||
|
||||
+10
-2
@@ -2911,6 +2911,10 @@
|
||||
"category_name_label": "Nazwa kategorii",
|
||||
"category_renamed": "Zmieniono nazwę kategorii",
|
||||
"category_rename_failed": "Nie udało się zmienić nazwy kategorii",
|
||||
"delete_category": "Usuń kategorię",
|
||||
"category_deleted": "Usunięto kategorię „{name}”",
|
||||
"category_delete_failed": "Nie udało się usunąć kategorii",
|
||||
"undo": "Cofnij",
|
||||
"category_added": "Kontakt dodany do {name}",
|
||||
"category_added_plural": "{count} kontaktów dodano do {name}",
|
||||
"empty_state": "Brak kontaktów",
|
||||
@@ -2959,7 +2963,10 @@
|
||||
"delete": "Usuń książkę adresową",
|
||||
"confirm_delete": "Usunąć „{name}\"? Wszystkie kontakty w tej książce adresowej zostaną usunięte.",
|
||||
"deleted": "Książka adresowa usunięta",
|
||||
"delete_failed": "Nie udało się usunąć książki adresowej"
|
||||
"delete_failed": "Nie udało się usunąć książki adresowej",
|
||||
"move_to": "Przenieś do…",
|
||||
"move_title": "Przenieś do książki adresowej",
|
||||
"move_here": "Przenieś tutaj"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Adresy e-mail",
|
||||
@@ -3248,7 +3255,8 @@
|
||||
"sort_first_name": "Imię",
|
||||
"sort_last_name": "Nazwisko"
|
||||
},
|
||||
"open_categories": "Otwórz kategorie"
|
||||
"open_categories": "Otwórz kategorie",
|
||||
"row_actions": "Działania dla {name}"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Kalendarz",
|
||||
|
||||
+10
-2
@@ -2911,6 +2911,10 @@
|
||||
"category_name_label": "Nome da categoria",
|
||||
"category_renamed": "Categoria renomeada",
|
||||
"category_rename_failed": "Falha ao renomear a categoria",
|
||||
"delete_category": "Excluir categoria",
|
||||
"category_deleted": "Categoria \"{name}\" excluída",
|
||||
"category_delete_failed": "Não foi possível excluir a categoria",
|
||||
"undo": "Desfazer",
|
||||
"category_added": "Contato adicionado a {name}",
|
||||
"category_added_plural": "{count} contatos adicionados a {name}",
|
||||
"empty_state": "Nenhum contato",
|
||||
@@ -2959,7 +2963,10 @@
|
||||
"delete": "Excluir lista de contatos",
|
||||
"confirm_delete": "Excluir \"{name}\"? Todos os contatos desta lista serão removidos.",
|
||||
"deleted": "Lista de contatos excluída",
|
||||
"delete_failed": "Falha ao excluir a lista de contatos"
|
||||
"delete_failed": "Falha ao excluir a lista de contatos",
|
||||
"move_to": "Mover para…",
|
||||
"move_title": "Mover para catálogo de endereços",
|
||||
"move_here": "Mover para cá"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Endereços de e-mail",
|
||||
@@ -3248,7 +3255,8 @@
|
||||
"sort_first_name": "Nome",
|
||||
"sort_last_name": "Sobrenome"
|
||||
},
|
||||
"open_categories": "Abrir categorias"
|
||||
"open_categories": "Abrir categorias",
|
||||
"row_actions": "Ações para {name}"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Calendário",
|
||||
|
||||
@@ -2911,6 +2911,10 @@
|
||||
"category_name_label": "Numele categoriei",
|
||||
"category_renamed": "Categorie redenumită",
|
||||
"category_rename_failed": "Nu s-a putut redenumi categoria",
|
||||
"delete_category": "Ștergeți categoria",
|
||||
"category_deleted": "Categoria „{name}” a fost ștearsă",
|
||||
"category_delete_failed": "Nu s-a putut șterge categoria",
|
||||
"undo": "Anulați",
|
||||
"category_added": "Contact adăugat la {name}",
|
||||
"category_added_plural": "{count} contacte adăugate la {name}",
|
||||
"empty_state": "Nu există încă contacte",
|
||||
@@ -2928,6 +2932,7 @@
|
||||
"back_to_contacts": "Înapoi la contacte",
|
||||
"back_to_email": "Înapoi la e-mail",
|
||||
"open_categories": "Deschideți categoriile",
|
||||
"row_actions": "Acțiuni pentru {name}",
|
||||
"tabs": {
|
||||
"all": "Toate",
|
||||
"groups": "Grupuri"
|
||||
@@ -2960,7 +2965,10 @@
|
||||
"delete": "Șterge agenda de adrese",
|
||||
"confirm_delete": "Doriți să ștergeți „{name}”? Toate contactele din această agendă vor fi eliminate.",
|
||||
"deleted": "Agenda de adrese a fost ștersă",
|
||||
"delete_failed": "Nu s-a putut șterge agenda de adrese"
|
||||
"delete_failed": "Nu s-a putut șterge agenda de adrese",
|
||||
"move_to": "Mutați în…",
|
||||
"move_title": "Mutați în agendă",
|
||||
"move_here": "Mutați aici"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Adrese de e-mail",
|
||||
|
||||
+10
-2
@@ -2911,6 +2911,10 @@
|
||||
"category_name_label": "Название категории",
|
||||
"category_renamed": "Категория переименована",
|
||||
"category_rename_failed": "Не удалось переименовать категорию",
|
||||
"delete_category": "Удалить категорию",
|
||||
"category_deleted": "Категория «{name}» удалена",
|
||||
"category_delete_failed": "Не удалось удалить категорию",
|
||||
"undo": "Отменить",
|
||||
"category_added": "Контакт добавлен в {name}",
|
||||
"category_added_plural": "{count} контактов добавлено в {name}",
|
||||
"empty_state": "Контактов пока нет",
|
||||
@@ -2959,7 +2963,10 @@
|
||||
"delete": "Удалить адресную книгу",
|
||||
"confirm_delete": "Удалить «{name}»? Все контакты в этой адресной книге будут удалены.",
|
||||
"deleted": "Адресная книга удалена",
|
||||
"delete_failed": "Не удалось удалить адресную книгу"
|
||||
"delete_failed": "Не удалось удалить адресную книгу",
|
||||
"move_to": "Переместить в…",
|
||||
"move_title": "Переместить в адресную книгу",
|
||||
"move_here": "Переместить сюда"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Адреса электронной почты",
|
||||
@@ -3248,7 +3255,8 @@
|
||||
"sort_first_name": "Имя",
|
||||
"sort_last_name": "Фамилия"
|
||||
},
|
||||
"open_categories": "Открыть категории"
|
||||
"open_categories": "Открыть категории",
|
||||
"row_actions": "Действия для {name}"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Календарь",
|
||||
|
||||
@@ -2911,6 +2911,10 @@
|
||||
"category_name_label": "Názov kategórie",
|
||||
"category_renamed": "Kategória bola premenovaná",
|
||||
"category_rename_failed": "Premenovanie kategórie zlyhalo",
|
||||
"delete_category": "Odstrániť kategóriu",
|
||||
"category_deleted": "Kategória „{name}“ bola odstránená",
|
||||
"category_delete_failed": "Kategóriu sa nepodarilo odstrániť",
|
||||
"undo": "Späť",
|
||||
"category_added": "Kontakt pridaný do {name}",
|
||||
"category_added_plural": "{count} kontaktov bolo pridaných do {name}",
|
||||
"empty_state": "Žiadne kontakty",
|
||||
@@ -2928,6 +2932,7 @@
|
||||
"back_to_contacts": "Späť na kontakty",
|
||||
"back_to_email": "Späť na e-mail",
|
||||
"open_categories": "Otvoriť kategórie",
|
||||
"row_actions": "Akcie pre {name}",
|
||||
"tabs": {
|
||||
"all": "Všetky",
|
||||
"groups": "Skupiny"
|
||||
@@ -2960,7 +2965,10 @@
|
||||
"delete": "Zmazať adresár",
|
||||
"confirm_delete": "Zmazať \"{name}\"? Všetky kontakty v tomto adresári budú odstránené.",
|
||||
"deleted": "Adresár zmazaný",
|
||||
"delete_failed": "Adresár sa nepodarilo zmazať"
|
||||
"delete_failed": "Adresár sa nepodarilo zmazať",
|
||||
"move_to": "Presunúť do…",
|
||||
"move_title": "Presunúť do adresára",
|
||||
"move_here": "Presunúť sem"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "E-mailové adresy",
|
||||
|
||||
@@ -2246,6 +2246,10 @@
|
||||
"category_name_label": "ชื่อหมวดหมู่",
|
||||
"category_renamed": "เปลี่ยนชื่อหมวดหมู่แล้ว",
|
||||
"category_rename_failed": "เปลี่ยนชื่อหมวดหมู่ไม่สำเร็จ",
|
||||
"delete_category": "ลบหมวดหมู่",
|
||||
"category_deleted": "ลบหมวดหมู่ \"{name}\" แล้ว",
|
||||
"category_delete_failed": "ลบหมวดหมู่ไม่ได้",
|
||||
"undo": "เลิกทำ",
|
||||
"category_added": "เพิ่มผู้ติดต่อใน {name} แล้ว",
|
||||
"category_added_plural": "เพิ่มผู้ติดต่อ {count} รายการใน {name} แล้ว",
|
||||
"empty_state": "ยังไม่มีผู้ติดต่อ",
|
||||
@@ -2263,6 +2267,7 @@
|
||||
"back_to_contacts": "กลับไปยังผู้ติดต่อ",
|
||||
"back_to_email": "กลับไปยังอีเมล",
|
||||
"open_categories": "เปิดหมวดหมู่",
|
||||
"row_actions": "การทำงานสำหรับ {name}",
|
||||
"tabs": {
|
||||
"all": "ทั้งหมด",
|
||||
"groups": "กลุ่มผู้ติดต่อ"
|
||||
@@ -2295,7 +2300,10 @@
|
||||
"delete": "ลบสมุดที่อยู่",
|
||||
"confirm_delete": "ลบ “{name}” หรือไม่? ผู้ติดต่อทั้งหมดในสมุดที่อยู่นี้จะถูกลบ",
|
||||
"deleted": "ลบสมุดที่อยู่แล้ว",
|
||||
"delete_failed": "ลบสมุดที่อยู่ไม่สำเร็จ"
|
||||
"delete_failed": "ลบสมุดที่อยู่ไม่สำเร็จ",
|
||||
"move_to": "ย้ายไปที่…",
|
||||
"move_title": "ย้ายไปยังสมุดที่อยู่",
|
||||
"move_here": "ย้ายมาที่นี่"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "ที่อยู่อีเมล",
|
||||
|
||||
+10
-2
@@ -2911,6 +2911,10 @@
|
||||
"category_name_label": "Kategori adı",
|
||||
"category_renamed": "Kategori yeniden adlandırıldı",
|
||||
"category_rename_failed": "Kategori yeniden adlandırılamadı",
|
||||
"delete_category": "Kategoriyi sil",
|
||||
"category_deleted": "\"{name}\" kategorisi silindi",
|
||||
"category_delete_failed": "Kategori silinemedi",
|
||||
"undo": "Geri al",
|
||||
"category_added": "{name} kategorisine kişi eklendi",
|
||||
"category_added_plural": "{name} kategorisine {count} kişi eklendi",
|
||||
"empty_state": "Henüz kişi yok",
|
||||
@@ -2959,7 +2963,10 @@
|
||||
"delete": "Adres defterini sil",
|
||||
"confirm_delete": "\"{name}\" silinsin mi? Bu adres defterindeki tüm kişiler kaldırılacak.",
|
||||
"deleted": "Adres defteri silindi",
|
||||
"delete_failed": "Adres defteri silinemedi"
|
||||
"delete_failed": "Adres defteri silinemedi",
|
||||
"move_to": "Taşı…",
|
||||
"move_title": "Adres defterine taşı",
|
||||
"move_here": "Buraya taşı"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "E-posta Adresleri",
|
||||
@@ -3248,7 +3255,8 @@
|
||||
"sort_first_name": "Ad",
|
||||
"sort_last_name": "Soyad"
|
||||
},
|
||||
"open_categories": "Kategorileri aç"
|
||||
"open_categories": "Kategorileri aç",
|
||||
"row_actions": "{name} için işlemler"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Takvim",
|
||||
|
||||
+10
-2
@@ -2911,6 +2911,10 @@
|
||||
"category_name_label": "Назва категорії",
|
||||
"category_renamed": "Категорію перейменовано",
|
||||
"category_rename_failed": "Не вдалося перейменувати категорію",
|
||||
"delete_category": "Видалити категорію",
|
||||
"category_deleted": "Категорію «{name}» видалено",
|
||||
"category_delete_failed": "Не вдалося видалити категорію",
|
||||
"undo": "Скасувати",
|
||||
"category_added": "Контакт додано до {name}",
|
||||
"category_added_plural": "{count} контактів додано до {name}",
|
||||
"empty_state": "Контактів ще немає",
|
||||
@@ -2959,7 +2963,10 @@
|
||||
"delete": "Видалити адресну книгу",
|
||||
"confirm_delete": "Видалити «{name}»? Усі контакти в цій адресній книзі будуть видалені.",
|
||||
"deleted": "Адресну книгу видалено",
|
||||
"delete_failed": "Не вдалося видалити адресну книгу"
|
||||
"delete_failed": "Не вдалося видалити адресну книгу",
|
||||
"move_to": "Перемістити до…",
|
||||
"move_title": "Перемістити до адресної книги",
|
||||
"move_here": "Перемістити сюди"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Адреси електронної пошти",
|
||||
@@ -3248,7 +3255,8 @@
|
||||
"sort_first_name": "Ім'я",
|
||||
"sort_last_name": "Прізвище"
|
||||
},
|
||||
"open_categories": "Відкрити категорії"
|
||||
"open_categories": "Відкрити категорії",
|
||||
"row_actions": "Дії для {name}"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Календар",
|
||||
|
||||
@@ -2911,6 +2911,10 @@
|
||||
"category_name_label": "類別名稱",
|
||||
"category_renamed": "類別已重新命名",
|
||||
"category_rename_failed": "重新命名類別失敗",
|
||||
"delete_category": "刪除類別",
|
||||
"category_deleted": "已刪除類別「{name}」",
|
||||
"category_delete_failed": "無法刪除類別",
|
||||
"undo": "復原",
|
||||
"category_added": "聯絡人已新增至 {name}",
|
||||
"category_added_plural": "{count} 位聯絡人已新增至 {name}",
|
||||
"empty_state": "尚無聯絡人",
|
||||
@@ -2928,6 +2932,7 @@
|
||||
"back_to_contacts": "回到聯絡人",
|
||||
"back_to_email": "回到郵件",
|
||||
"open_categories": "開啟類別",
|
||||
"row_actions": "{name} 的動作",
|
||||
"tabs": {
|
||||
"all": "全部",
|
||||
"groups": "群組"
|
||||
@@ -2960,7 +2965,10 @@
|
||||
"delete": "刪除通訊錄",
|
||||
"confirm_delete": "要刪除「{name}」嗎?此通訊錄中的所有聯絡人都會移除。",
|
||||
"deleted": "通訊錄已刪除",
|
||||
"delete_failed": "刪除通訊錄失敗"
|
||||
"delete_failed": "刪除通訊錄失敗",
|
||||
"move_to": "移至…",
|
||||
"move_title": "移至通訊錄",
|
||||
"move_here": "移到這裡"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "電子郵件地址",
|
||||
|
||||
+10
-2
@@ -2911,6 +2911,10 @@
|
||||
"category_name_label": "类别名称",
|
||||
"category_renamed": "类别已重命名",
|
||||
"category_rename_failed": "重命名类别失败",
|
||||
"delete_category": "删除类别",
|
||||
"category_deleted": "已删除类别“{name}”",
|
||||
"category_delete_failed": "无法删除类别",
|
||||
"undo": "撤销",
|
||||
"category_added": "联系人已添加至 {name}",
|
||||
"category_added_plural": "{count} 联系人已添加到 {name}",
|
||||
"empty_state": "还没有联系人",
|
||||
@@ -2959,7 +2963,10 @@
|
||||
"delete": "删除通讯录",
|
||||
"confirm_delete": "删除「{name}」?此通讯录中的所有联系人将被移除。",
|
||||
"deleted": "通讯录已删除",
|
||||
"delete_failed": "删除通讯录失败"
|
||||
"delete_failed": "删除通讯录失败",
|
||||
"move_to": "移动到…",
|
||||
"move_title": "移动到通讯录",
|
||||
"move_here": "移到此处"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "邮箱地址",
|
||||
@@ -3248,7 +3255,8 @@
|
||||
"sort_first_name": "名",
|
||||
"sort_last_name": "姓"
|
||||
},
|
||||
"open_categories": "打开分类"
|
||||
"open_categories": "打开分类",
|
||||
"row_actions": "{name} 的操作"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "日历",
|
||||
|
||||
@@ -463,6 +463,10 @@ interface ContactStore {
|
||||
removeAddressBook: (client: IJMAPClient, addressBook: AddressBook) => Promise<void>;
|
||||
shareAddressBook: (client: IJMAPClient, addressBook: AddressBook, principalId: string, rights: AddressBookRights | null) => Promise<void>;
|
||||
renameKeyword: (client: IJMAPClient | null, oldKeyword: string, newKeyword: string) => Promise<void>;
|
||||
/** Take a category off every contact; returns the ids of the contacts that had it, for undo. */
|
||||
removeKeyword: (client: IJMAPClient | null, keyword: string) => Promise<string[]>;
|
||||
/** Put a category on contacts, e.g. to undo removing it. */
|
||||
addKeyword: (client: IJMAPClient | null, keyword: string, contactIds: string[]) => Promise<void>;
|
||||
|
||||
importContacts: (client: IJMAPClient | null, contacts: ContactCard[]) => Promise<number>;
|
||||
|
||||
@@ -1405,6 +1409,48 @@ export const useContactStore = create<ContactStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
removeKeyword: async (client, keyword) => {
|
||||
set({ error: null });
|
||||
const kw = keyword.trim();
|
||||
const { contacts, supportsSync } = get();
|
||||
const affected = contacts.filter(c => c.keywords?.[kw]);
|
||||
const done: string[] = [];
|
||||
for (const contact of affected) {
|
||||
const { [kw]: _gone, ...rest } = contact.keywords || {};
|
||||
void _gone;
|
||||
if (supportsSync && client) {
|
||||
const originalId = contact.originalId || contact.id;
|
||||
const accountId = contact.isShared ? contact.accountId : undefined;
|
||||
// JMAP patch: only this keyword goes; the card's others stay.
|
||||
await client.updateContact(originalId, { keywords: rest }, accountId);
|
||||
}
|
||||
set((state) => ({
|
||||
contacts: state.contacts.map(c => (c.id === contact.id ? { ...c, keywords: rest } : c)),
|
||||
}));
|
||||
done.push(contact.id);
|
||||
}
|
||||
return done;
|
||||
},
|
||||
|
||||
addKeyword: async (client, keyword, contactIds) => {
|
||||
set({ error: null });
|
||||
const kw = keyword.trim();
|
||||
const { contacts, supportsSync } = get();
|
||||
for (const id of contactIds) {
|
||||
const contact = contacts.find(c => c.id === id);
|
||||
if (!contact || contact.keywords?.[kw]) continue;
|
||||
const keywords = { ...(contact.keywords || {}), [kw]: true };
|
||||
if (supportsSync && client) {
|
||||
const originalId = contact.originalId || contact.id;
|
||||
const accountId = contact.isShared ? contact.accountId : undefined;
|
||||
await client.updateContact(originalId, { keywords }, accountId);
|
||||
}
|
||||
set((state) => ({
|
||||
contacts: state.contacts.map(c => (c.id === id ? { ...c, keywords } : c)),
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
loadRecentRecipients: async (client, sentMailboxId) => {
|
||||
if (sentMailboxId) set({ sentMailboxId });
|
||||
if (get().recentRecipientsLoaded || !sentMailboxId) return;
|
||||
|
||||
Reference in New Issue
Block a user