Merge branch 'ux/contacts-files'

This commit is contained in:
Linus Rath
2026-10-28 05:52:26 +01:00
97 changed files with 6967 additions and 1844 deletions
@@ -9,6 +9,10 @@ vi.mock('@/lib/keyboard-shortcuts/calendar', () => ({
CALENDAR_SECTIONS: [{ id: 'views', title: 'calendar.views', entries: [{ key: 'd', description: 'calendar.day_view' }] }],
}));
// Apps without keys get no tab; Contacts and Files are left empty here for that.
vi.mock('@/lib/keyboard-shortcuts/contacts', () => ({ CONTACTS_SECTIONS: [] }));
vi.mock('@/lib/keyboard-shortcuts/files', () => ({ FILES_SECTIONS: [] }));
const { KeyboardShortcutsModal } = await import('../keyboard-shortcuts-modal');
describe('keyboard shortcuts help per app', () => {
@@ -124,3 +124,12 @@ describe('vCard export of contact photos', () => {
expect(await exported!.text()).toContain('FN:Contact 1');
});
});
describe('vCard file names', () => {
it('keep letters of any script instead of turning them into underscores', async () => {
const { sanitizeFilename } = await import('../contact-export');
expect(sanitizeFilename('Jürgen Müller')).toBe('Jürgen_Müller');
expect(sanitizeFilename('李 雷')).toBe('李_雷');
expect(sanitizeFilename('a/b:c')).toBe('a_b_c');
});
});
@@ -44,7 +44,7 @@ describe('ContactForm', () => {
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
fireEvent.submit(screen.getByText('save').closest('form')!);
await waitFor(() => {
expect(screen.getByText('name_required')).toBeInTheDocument();
expect(screen.getByRole('alert')).toHaveTextContent('name_required');
});
expect(onSave).not.toHaveBeenCalled();
});
@@ -460,7 +460,7 @@ describe('ContactForm', () => {
fireEvent.submit(screen.getByText('save').closest('form')!);
await waitFor(() => {
expect(screen.getByText('name_required')).toBeInTheDocument();
expect(screen.getByRole('alert')).toHaveTextContent('name_required');
});
expect(onSave).not.toHaveBeenCalled();
});
@@ -513,4 +513,15 @@ describe('ContactForm', () => {
});
expect(onSave.mock.calls[0][0].kind).toBe('individual');
});
it("names the account of an address book once, not twice", () => {
const books = [
{ id: 'b1', name: 'Stalwart Address Book (usera@example.org)', accountName: 'usera@example.org', isDefault: true },
{ id: 'b2', name: 'Team', accountName: 'userb@example.org', isShared: true },
] as never;
render(<ContactForm addressBooks={books} onSave={vi.fn()} onCancel={vi.fn()} />);
const options = screen.getAllByRole('option').map((o) => o.textContent);
expect(options).toContain('Stalwart Address Book (usera@example.org)');
expect(options).toContain('Team (userb@example.org)');
});
});
@@ -183,4 +183,75 @@ describe('ContactList', () => {
});
});
it("writes to a contact from the right-click menu in the app's composer, not through mailto:", () => {
const onComposeContact = vi.fn();
const href = window.location.href;
render(<ContactList {...defaultProps} onComposeContact={onComposeContact} />);
fireEvent.contextMenu(screen.getByText('Alice Smith'));
fireEvent.click(screen.getByText('context_menu.send_email'));
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']);
});
it("moves through the list with the arrow keys and opens the contact, like Mail and Files", () => {
const onSelectContact = vi.fn();
render(<ContactList {...defaultProps} onSelectContact={onSelectContact} />);
const rows = screen.getAllByRole('option');
expect(rows[0]).toHaveAttribute('tabindex', '0');
expect(rows[1]).toHaveAttribute('tabindex', '-1');
rows[0].focus();
fireEvent.keyDown(rows[0], { key: 'ArrowDown' });
expect(onSelectContact).toHaveBeenLastCalledWith('2');
expect(document.activeElement).toBe(screen.getAllByRole('option')[1]);
});
it("selects all with Ctrl+A, deletes with Delete, clears with Escape, and searches with /", () => {
const onSelectAll = vi.fn();
const onBulkDelete = vi.fn();
const onClearSelection = vi.fn();
const { rerender } = render(<ContactList {...defaultProps} onSelectAll={onSelectAll} onBulkDelete={onBulkDelete} onClearSelection={onClearSelection} />);
const row = screen.getAllByRole('option')[0];
fireEvent.keyDown(row, { key: 'a', code: 'KeyA', ctrlKey: true });
expect(onSelectAll).toHaveBeenCalledWith(['1', '2']);
rerender(<ContactList {...defaultProps} selectedContactIds={new Set(['1'])} onSelectAll={onSelectAll} onBulkDelete={onBulkDelete} onClearSelection={onClearSelection} />);
fireEvent.keyDown(screen.getAllByRole('option')[0], { key: 'Delete' });
expect(onBulkDelete).toHaveBeenCalled();
fireEvent.keyDown(screen.getAllByRole('option')[0], { key: 'Escape' });
expect(onClearSelection).toHaveBeenCalled();
fireEvent.keyDown(document.body, { key: '/' });
expect(document.activeElement).toBe(screen.getByPlaceholderText('search_placeholder'));
});
it("opens a contact's menu on long-press, with Select to start selecting", () => {
vi.useFakeTimers();
try {
const onToggleSelection = vi.fn();
render(<ContactList {...defaultProps} onToggleSelection={onToggleSelection} />);
const row = screen.getAllByRole('option')[0];
const touchStart = new Event('touchstart', { bubbles: true });
Object.defineProperty(touchStart, 'touches', { value: [{ clientX: 5, clientY: 5 }] });
act(() => { row.dispatchEvent(touchStart); });
act(() => { vi.advanceTimersByTime(600); });
fireEvent.click(screen.getByText('filters.select'));
expect(onToggleSelection).toHaveBeenCalledWith('1');
} finally {
vi.useRealTimers();
}
});
});
@@ -1,9 +1,11 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { printContact } from '../contact-print';
import { printContact, printLabels } from '../contact-print';
import { useAuthStore } from '@/stores/auth-store';
import { clearContactPhotoCache } from '@/lib/contact-photo-cache';
import type { ContactCard } from '@/lib/jmap/types';
const LABELS = printLabels((key) => key, 'en');
const base: ContactCard = {
id: 'c1',
addressBookIds: {},
@@ -36,7 +38,7 @@ describe('printContact', () => {
it('opens the window right away and prints a Stalwart 1.0 photo inline as a data: URI', async () => {
fetchBlob.mockResolvedValue(new Blob([new Uint8Array([0xff, 0xd8, 0xff])], { type: 'image/jpeg' }));
const done = printContact({ ...base, media: { photo: { kind: 'photo', blobId: 'dbcX', mediaType: 'image/jpeg' } } });
const done = printContact({ ...base, media: { photo: { kind: 'photo', blobId: 'dbcX', mediaType: 'image/jpeg' } } }, LABELS);
// Before anything is awaited, while the click still allows a popup.
expect(open).toHaveBeenCalledTimes(1);
await done;
@@ -47,7 +49,7 @@ describe('printContact', () => {
});
it('prints a Stalwart 0.16 photo as it is', async () => {
await printContact({ ...base, media: { photo: { kind: 'photo', uri: 'data:base64,/9j/' } } });
await printContact({ ...base, media: { photo: { kind: 'photo', uri: 'data:base64,/9j/' } } }, LABELS);
expect(fetchBlob).not.toHaveBeenCalled();
expect(written()).toContain('src="data:image/jpeg;base64,/9j/"');
@@ -57,7 +59,7 @@ describe('printContact', () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
fetchBlob.mockRejectedValue(new Error('Failed to fetch blob: 404'));
const done = printContact({ ...base, media: { photo: { kind: 'photo', blobId: 'dbcGone' } } });
const done = printContact({ ...base, media: { photo: { kind: 'photo', blobId: 'dbcGone' } } }, LABELS);
await vi.advanceTimersByTimeAsync(1000);
await done;
@@ -68,9 +70,21 @@ describe('printContact', () => {
it('does not write into a window the user already closed', async () => {
fetchBlob.mockResolvedValue(new Blob(['x'], { type: 'image/jpeg' }));
const done = printContact({ ...base, media: { photo: { kind: 'photo', blobId: 'dbcX' } } });
const done = printContact({ ...base, media: { photo: { kind: 'photo', blobId: 'dbcX' } } }, LABELS);
printWindow.closed = true;
await done;
expect(printWindow.document.write).not.toHaveBeenCalled();
});
it('prints the headings and dates in the language of the app', async () => {
const de = printLabels((key) => ({ 'detail.emails': 'E-Mail-Adressen', 'detail.anniversaries': 'Jahrestage', 'detail.anniversary_birth': 'Geburtstag' } as Record<string, string>)[key] ?? key, 'de');
await printContact({
...base,
emails: { e: { address: 'ann@example.com' } },
anniversaries: { b: { kind: 'birth', date: { year: 1988, month: 3, day: 15 } } },
} as ContactCard, de);
expect(written()).toContain('<h2>E-Mail-Adressen</h2>');
expect(written()).toContain('<dt>Geburtstag</dt><dd>15. März 1988</dd>');
expect(written()).not.toContain('>Email<');
});
});
@@ -0,0 +1,255 @@
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ContactCard } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
// The whole Contacts app in local mode (no JMAP client): the stores hold the
// contacts, and the shell around it is stubbed out.
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn(), back: vi.fn() }),
useParams: () => ({ locale: 'en' }),
usePathname: () => '/en/contacts',
useSearchParams: () => new URLSearchParams(),
}));
vi.mock('@/components/contacts/contact-activity', () => ({ ContactActivity: () => null }));
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => <nav data-testid="rail" /> }));
vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
vi.mock('@/components/layout/inline-app-view', () => ({ InlineAppView: () => null }));
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null }));
vi.mock('@/hooks/use-sidebar-apps', () => ({
useSidebarApps: () => ({
showAppsModal: false, inlineApp: null, loadedApps: [], handleManageApps: vi.fn(),
handleInlineApp: vi.fn(), closeInlineApp: vi.fn(), closeAppsModal: vi.fn(),
}),
}));
import { ContactsApp } from '../contacts-app';
import { useRegisteredCommandGroups, type CommandGroup } from '@/lib/command-registry';
/** The palette's command groups as the palette would read them now. */
function getRegisteredGroups(): CommandGroup[] {
let groups: CommandGroup[] = [];
function Probe() {
groups = useRegisteredCommandGroups();
return null;
}
const { unmount } = render(<Probe />);
unmount();
return groups;
}
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',
addressBookIds: {},
name: { components: [{ kind: 'given', value: 'Alice' }, { kind: 'surname', value: 'Smith' }], isOrdered: true },
emails: { e0: { address: 'alice@example.com' } },
};
export const bob: ContactCard = {
id: 'c-bob',
addressBookIds: {},
name: { components: [{ kind: 'given', value: 'Bob' }, { kind: 'surname', value: 'Jones' }], isOrdered: true },
emails: { e0: { address: 'bob@example.com' } },
};
// Only asked whether the server has a directory; supportsSync stays false.
const FAKE_CLIENT = {
supportsPrincipals: () => false,
} as unknown as IJMAPClient;
export function renderContacts(contacts: ContactCard[] = [alice, bob]) {
act(() => {
useAuthStore.setState({ isAuthenticated: true, client: FAKE_CLIENT, isLoading: false });
useContactStore.setState({
contacts,
addressBooks: [],
selectedContactId: null,
selectedContactIds: new Set(),
searchQuery: '',
supportsSync: false,
contactsLoaded: true,
});
});
return render(<ContactsApp />);
}
/** The categories drawer of a narrow window (jsdom matches no media query). */
function drawer() {
return screen.getByTestId('contacts-sidebar-drawer');
}
describe('ContactsApp', () => {
beforeEach(() => localStorage.clear());
afterEach(() => {
act(() => {
useAuthStore.setState({ isAuthenticated: false, client: null });
useContactStore.setState({ contacts: [], selectedContactId: null, selectedContactIds: new Set() });
});
});
it("takes the closed drawer out of reach, so it can't cover the nav rail or catch Tab", () => {
renderContacts();
expect(drawer()).toHaveAttribute('inert');
expect(drawer().className).toContain('invisible');
fireEvent.click(screen.getAllByRole('button', { name: 'open_categories' })[0]);
expect(drawer()).not.toHaveAttribute('inert');
expect(within(drawer()).getByText('tabs.all')).toBeInTheDocument();
});
it("closes the drawer when something in it is picked, so it doesn't stay over the form", () => {
renderContacts();
fireEvent.click(screen.getAllByRole('button', { name: 'open_categories' })[0]);
expect(drawer()).not.toHaveAttribute('inert');
// The header's "+" menu, then "New contact".
fireEvent.click(within(drawer()).getByRole('button', { name: 'new' }));
fireEvent.click(screen.getByText('create_new'));
expect(drawer()).toHaveAttribute('inert');
});
it('asks before another contact replaces a contact form with changes, and keeps them on "Keep editing"', async () => {
renderContacts();
fireEvent.click(screen.getByText('Alice Smith'));
fireEvent.click(screen.getByRole('button', { name: /detail.edit|edit/ }));
fireEvent.change(screen.getByPlaceholderText('middle_name'), { target: { value: 'Marie' } });
fireEvent.click(screen.getByText('Bob Jones'));
const dialog = await screen.findByRole('alertdialog');
expect(within(dialog).getByText('form.discard_title')).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('button', { name: 'form.keep_editing' }));
await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
expect(screen.getByPlaceholderText('middle_name')).toHaveValue('Marie');
fireEvent.click(screen.getByText('Bob Jones'));
fireEvent.click(within(await screen.findByRole('alertdialog')).getByRole('button', { name: 'form.discard' }));
await waitFor(() => expect(screen.queryByPlaceholderText('middle_name')).toBeNull());
expect(useContactStore.getState().selectedContactId).toBe('c-bob');
});
it('leaves an untouched contact form without asking', () => {
renderContacts();
fireEvent.click(screen.getByText('Alice Smith'));
fireEvent.click(screen.getByRole('button', { name: /detail.edit|edit/ }));
fireEvent.click(screen.getByText('Bob Jones'));
expect(screen.queryByRole('alertdialog')).toBeNull();
expect(useContactStore.getState().selectedContactId).toBe('c-bob');
});
it('starts a new group with the contact when "Add to group" finds no group to add to', () => {
renderContacts();
fireEvent.contextMenu(screen.getByText('Alice Smith'));
fireEvent.click(screen.getByText('context_menu.add_to_group'));
expect(screen.getByText('groups.members_label (1)')).toBeInTheDocument();
});
it('offers a new group, with the selection in it, next to the existing groups', () => {
const team: ContactCard = { id: 'g-team', kind: 'group', addressBookIds: {}, name: { full: 'Team' }, members: {} };
renderContacts([alice, bob, team]);
fireEvent.contextMenu(screen.getByText('Bob Jones'));
fireEvent.click(screen.getByText('context_menu.add_to_group'));
fireEvent.click(screen.getByText('bulk.new_group'));
expect(screen.getByText('groups.members_label (1)')).toBeInTheDocument();
});
it('imports in a real dialog that Escape closes', () => {
renderContacts();
fireEvent.click(screen.getAllByRole('button', { name: 'open_categories' })[0]);
fireEvent.click(within(drawer()).getByRole('button', { name: 'new' }));
fireEvent.click(screen.getByText('import.title'));
const dialog = screen.getByRole('dialog', { name: 'import.title' });
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));
});
it('has a create button on the phone, as Mail and Files do', () => {
const matchMedia = window.matchMedia;
window.matchMedia = ((query: string) => ({ ...matchMedia(query), matches: query.includes('max-width') })) as typeof window.matchMedia;
try {
renderContacts();
fireEvent.click(screen.getByRole('button', { name: 'create_new' }));
expect(screen.getByPlaceholderText('middle_name')).toBeInTheDocument();
} finally {
window.matchMedia = matchMedia;
}
});
it('shows a new contact once it is saved, instead of an empty pane', async () => {
renderContacts();
fireEvent.click(screen.getAllByRole('button', { name: 'open_categories' })[0]);
fireEvent.click(within(drawer()).getByRole('button', { name: 'new' }));
fireEvent.click(screen.getByText('create_new'));
fireEvent.change(screen.getByPlaceholderText('given_name'), { target: { value: 'Zed' } });
fireEvent.click(screen.getByRole('button', { name: 'save' }));
await waitFor(() => expect(screen.getByRole('heading', { name: 'Zed' })).toBeInTheDocument());
});
it('adds New contact, Import and New group to the command palette while open', () => {
renderContacts();
const groups = getRegisteredGroups();
const contacts = groups.find((g) => g.label === 'contacts');
expect(contacts?.commands.map((c) => c.label)).toEqual(expect.arrayContaining(['create_new', 'import.title', 'groups.create']));
act(() => { contacts!.commands.find((c) => c.id === 'new-contact')!.run(); });
expect(screen.getByPlaceholderText('middle_name')).toBeInTheDocument();
});
it('puts New group and Import in the phone header menu', () => {
const matchMedia = window.matchMedia;
window.matchMedia = ((query: string) => ({ ...matchMedia(query), matches: query.includes('max-width') })) as typeof window.matchMedia;
try {
renderContacts();
fireEvent.click(screen.getByRole('button', { name: 'more' }));
fireEvent.click(screen.getByText('import.title'));
expect(screen.getByRole('dialog', { name: 'import.title' })).toBeInTheDocument();
} finally {
window.matchMedia = matchMedia;
}
});
it('says an empty address book is empty, not that the account has no contacts, and offers Import', () => {
renderContacts([alice]);
act(() => { useContactStore.setState({ addressBooks: [{ id: 'b2', name: 'Work', isDefault: false } as never] }); });
fireEvent.click(screen.getAllByRole('button', { name: 'open_categories' })[0]);
fireEvent.click(within(drawer()).getByText('Work'));
expect(screen.getByText('empty_view_title')).toBeInTheDocument();
expect(screen.queryByText('empty_state_title')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: /import.title/ }));
expect(screen.getByRole('dialog', { name: 'import.title' })).toBeInTheDocument();
});
it("marks a message started from Contacts as the app's own, so Mail does not ask for an account", async () => {
const { consumePendingMailto } = await import('@/lib/protocol-handlers/session');
renderContacts();
fireEvent.contextMenu(screen.getByText('Alice Smith'));
fireEvent.click(screen.getByText('context_menu.send_email'));
expect(consumePendingMailto()).toMatchObject({ to: [expect.stringContaining('alice@example.com')], inApp: true });
});
it('says so when a duplicate fails', async () => {
renderContacts();
const addLocalContact = vi.fn(async () => { throw new Error('quota'); });
act(() => { useContactStore.setState({ addLocalContact }); });
fireEvent.contextMenu(screen.getByText('Alice Smith'));
fireEvent.click(screen.getByText('context_menu.duplicate'));
await waitFor(() => expect(useToastStore.getState().toasts.at(-1)?.title).toBe('toast.error_create'));
});
});
@@ -0,0 +1,56 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { ContactForm } from '../contact-form';
import { ContactGroupDetail } from '../contact-group-detail';
import { ContactList } from '../contact-list';
import { FilePreviewModal } from '@/components/files/file-preview-modal';
import type { ContactCard } from '@/lib/jmap/types';
vi.mock('@/lib/standalone-display', () => ({ canOpenUrlInNewWindow: () => false }));
// Every button says what it does to a screen reader (R1): aria-label, or text.
function unnamedButtons(): HTMLElement[] {
return screen.getAllByRole('button').filter((b) => !(b.getAttribute('aria-label') || b.textContent?.trim()));
}
const alice: ContactCard = {
id: '1',
addressBookIds: {},
name: { components: [{ kind: 'given', value: 'Alice' }], isOrdered: true },
emails: { e0: { address: 'alice@example.com' }, e1: { address: 'a@example.org' } },
phones: { p0: { number: '+1 555' } },
addresses: { a0: { street: 'Main St' } },
keywords: { Friends: true },
};
const bob: ContactCard = { id: '2', addressBookIds: {}, name: { full: 'Bob' } };
describe('icon buttons in Contacts and Files have names', () => {
it('in the contact form', () => {
render(<ContactForm contact={alice} onSave={vi.fn()} onCancel={vi.fn()} />);
expect(unnamedButtons()).toEqual([]);
});
it('on a group page', () => {
const group = { id: 'g', kind: 'group', addressBookIds: {}, name: { full: 'Team' } } as ContactCard;
render(<ContactGroupDetail group={group} members={[alice, bob]} onEdit={vi.fn()} onDelete={vi.fn()} onRemoveMember={vi.fn()} onSelectMember={vi.fn()} />);
expect(unnamedButtons()).toEqual([]);
});
it('in the contact list while selecting', () => {
render(
<ContactList
contacts={[alice, bob]} selectedContactId={null} searchQuery="" onSearchChange={vi.fn()} onSelectContact={vi.fn()}
onCreateNew={vi.fn()} selectedContactIds={new Set(['1'])} onToggleSelection={vi.fn()} onSelectRangeContacts={vi.fn()}
onSelectAll={vi.fn()} onClearSelection={vi.fn()} onBulkDelete={vi.fn()} onBulkAddToGroup={vi.fn()} onBulkExport={vi.fn()}
onEditContact={vi.fn()} onDeleteContact={vi.fn()} onAddContactToGroup={vi.fn()} onMenuClick={vi.fn()}
/>,
);
expect(unnamedButtons()).toEqual([]);
expect(screen.getAllByRole('checkbox').length).toBeGreaterThan(2);
});
it('in the file preview', () => {
render(<FilePreviewModal name="a.txt" onClose={vi.fn()} onDownload={vi.fn()} getFileContent={() => new Promise(() => {})} />);
expect(unnamedButtons()).toEqual([]);
});
});
@@ -0,0 +1,27 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
const nav = vi.hoisted(() => ({ pathname: '/' }));
vi.mock('@/i18n/navigation', () => ({ usePathname: () => nav.pathname }));
vi.mock('@/components/pro/pro-interface-redirect', () => ({ useProInterfaceActive: () => false }));
vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ startTour: vi.fn() }) }));
const { KeyboardShortcutsModal } = await import('@/components/keyboard-shortcuts-modal');
describe('Contacts and Files keys in the shortcuts help', () => {
it('lists the Contacts keys on the Contacts tab', () => {
nav.pathname = '/contacts';
render(<KeyboardShortcutsModal isOpen onClose={() => {}} />);
expect(screen.getByRole('tab', { name: 'sidebar.contacts' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByText('contacts.shortcuts.move')).toBeInTheDocument();
expect(screen.getByText('contacts.shortcuts.search')).toBeInTheDocument();
});
it('lists the Files keys on the Files tab, with Backspace deleting and Alt+Up going up', () => {
nav.pathname = '/files';
render(<KeyboardShortcutsModal isOpen onClose={() => {}} />);
expect(screen.getByRole('tab', { name: 'sidebar.files' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByText('files.shortcuts.up')).toBeInTheDocument();
expect(screen.getByText('files.shortcuts.delete')).toBeInTheDocument();
});
});
@@ -0,0 +1,52 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { ContactDetail } from '../contact-detail';
import { ContactGroupDetail } from '../contact-group-detail';
import { ContactList } from '../contact-list';
import type { ContactCard } from '@/lib/jmap/types';
vi.mock('@/components/contacts/contact-activity', () => ({ ContactActivity: () => null }));
const alice: ContactCard = {
id: '1',
addressBookIds: {},
name: { components: [{ kind: 'given', value: 'Alice' }], isOrdered: true },
emails: { e0: { address: 'alice@example.com' } },
};
const menuLabels = () => within(screen.getByRole('menu')).getAllByRole('menuitem').map((i) => i.textContent);
describe('one action list per contact (R6)', () => {
it('offers Duplicate in the right-click menu as in the detail ⋮', () => {
const noop = vi.fn();
render(
<ContactList
contacts={[alice]} selectedContactId={null} searchQuery="" onSearchChange={noop} onSelectContact={noop}
onCreateNew={noop} selectedContactIds={new Set()} onToggleSelection={noop} onSelectRangeContacts={noop}
onSelectAll={noop} onClearSelection={noop} onBulkDelete={noop} onBulkAddToGroup={noop} onBulkExport={noop}
onEditContact={noop} onDeleteContact={noop} onAddContactToGroup={noop} onDuplicateContact={noop} onComposeContact={noop}
/>,
);
fireEvent.contextMenu(screen.getByText('Alice'));
const rightClick = menuLabels();
expect(rightClick).toContain('context_menu.duplicate');
fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' });
render(<ContactDetail contact={alice} onEdit={noop} onDelete={noop} onDuplicate={noop} onAddToGroup={noop} onCompose={noop} />);
fireEvent.click(screen.getByRole('button', { name: 'detail.more_actions' }));
const detail = menuLabels();
// The detail's ⋮ is the same list, without what the page has as buttons.
expect(detail).toEqual(rightClick.filter((l) => !['context_menu.open', 'context_menu.send_email', 'context_menu.edit'].includes(l ?? '')));
});
it('says "Edit group" on a group page and keeps Delete in a ⋮ like a contact page', () => {
const group = { id: 'g', kind: 'group', addressBookIds: {}, name: { full: 'Team' } } as ContactCard;
const onDelete = vi.fn();
render(<ContactGroupDetail group={group} members={[]} onEdit={vi.fn()} onDelete={onDelete} onRemoveMember={vi.fn()} onSelectMember={vi.fn()} />);
expect(screen.getByRole('button', { name: /groups.edit/ })).toBeInTheDocument();
expect(screen.queryByText('form.edit_title')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'detail.more_actions' }));
fireEvent.click(screen.getByText('groups.delete'));
expect(onDelete).toHaveBeenCalled();
});
});
@@ -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>
);
}
+54 -30
View File
@@ -1,6 +1,6 @@
"use client";
import { useTranslations } from "next-intl";
import { useLocale, useTranslations } from "next-intl";
import {
ContextMenu,
ContextMenuItem,
@@ -18,11 +18,13 @@ import {
Trash2,
Copy,
Printer,
FolderInput,
CheckSquare,
} from "@/components/icons";
import type { ContactCard } from "@/lib/jmap/types";
import { getContactPrimaryEmail } from "@/stores/contact-store";
import { exportContact, useContactExportBusy } from "./contact-export";
import { printContact } from "./contact-print";
import { printContact, printLabels } from "./contact-print";
import { toast } from "@/stores/toast-store";
function getContactPrimaryPhone(contact: ContactCard): string {
@@ -43,11 +45,22 @@ interface ContactContextMenuProps {
menuRef: React.RefObject<HTMLDivElement | null>;
isMultiSelect?: boolean;
selectedCount?: number;
onOpen: () => void;
onEdit: () => void;
/** Left out where the contact is already open (the detail's ⋮). */
onOpen?: () => void;
/** Touch: start selecting with this contact (there is no Ctrl+click). */
onSelect?: () => void;
/** Left out where Edit is a button next to the menu. */
onEdit?: () => void;
onDelete: () => void;
onAddToGroup: () => void;
onAddToGroup?: () => void;
/** Leave out what the detail shows as buttons of its own. */
inDetail?: boolean;
/** 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;
@@ -62,15 +75,21 @@ export function ContactContextMenu({
isMultiSelect = false,
selectedCount = 1,
onOpen,
onSelect,
onEdit,
onDelete,
onAddToGroup,
onCompose,
onDuplicate,
onMoveTo,
onBatchMoveTo,
inDetail = false,
onBatchExport,
onBatchAddToGroup,
onBatchDelete,
}: ContactContextMenuProps) {
const t = useTranslations("contacts");
const locale = useLocale();
const email = getContactPrimaryEmail(contact);
const phone = getContactPrimaryPhone(contact);
const showBatchActions = isMultiSelect && selectedCount > 1;
@@ -81,11 +100,6 @@ export function ContactContextMenu({
onClose();
};
const handleSendEmail = () => {
if (!email) return;
window.location.href = `mailto:${email}`;
};
const handleCall = () => {
if (!phone) return;
window.location.href = `tel:${phone}`;
@@ -108,7 +122,7 @@ export function ContactContextMenu({
};
const handlePrint = () => {
void printContact(contact);
void printContact(contact, printLabels(t, locale));
};
if (showBatchActions) {
@@ -123,6 +137,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")}
@@ -143,23 +160,43 @@ export function ContactContextMenu({
return (
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
<ContextMenuItem icon={Eye} label={t("context_menu.open")} onClick={handle(onOpen)} />
<ContextMenuItem icon={Pencil} label={t("context_menu.edit")} onClick={handle(onEdit)} />
{(email || phone) && <ContextMenuSeparator />}
{email && (
{/* R6 order: Open, write, Edit, then the rest; the detail's ⋮ leaves
out what it has as buttons (Edit, Send email, Call). */}
{onOpen && <ContextMenuItem icon={Eye} label={t("context_menu.open")} onClick={handle(onOpen)} />}
{onSelect && <ContextMenuItem icon={CheckSquare} label={t("filters.select")} onClick={handle(onSelect)} />}
{email && onCompose && !inDetail && (
<ContextMenuItem
icon={Mail}
label={t("context_menu.send_email")}
onClick={handle(handleSendEmail)}
onClick={handle(onCompose)}
/>
)}
{phone && (
{phone && !inDetail && (
<ContextMenuItem
icon={Phone}
label={t("context_menu.call")}
onClick={handle(handleCall)}
/>
)}
{onEdit && <ContextMenuItem icon={Pencil} label={t("context_menu.edit")} onClick={handle(onEdit)} />}
{!inDetail && <ContextMenuSeparator />}
{onAddToGroup && (
<ContextMenuItem
icon={Users}
label={t("context_menu.add_to_group")}
onClick={handle(onAddToGroup)}
/>
)}
{onDuplicate && (
<ContextMenuItem
icon={Copy}
label={t("context_menu.duplicate")}
onClick={handle(onDuplicate)}
/>
)}
{onMoveTo && (
<ContextMenuItem icon={FolderInput} label={t("address_books.move_to")} onClick={handle(onMoveTo)} />
)}
{email && (
<ContextMenuItem
icon={ClipboardCopy}
@@ -174,19 +211,6 @@ export function ContactContextMenu({
onClick={handle(() => handleCopy(phone))}
/>
)}
<ContextMenuSeparator />
<ContextMenuItem
icon={Users}
label={t("context_menu.add_to_group")}
onClick={handle(onAddToGroup)}
/>
{onDuplicate && (
<ContextMenuItem
icon={Copy}
label={t("context_menu.duplicate")}
onClick={handle(onDuplicate)}
/>
)}
<ContextMenuItem
icon={Download}
label={t("context_menu.export_vcard")}
+45 -169
View File
@@ -1,41 +1,33 @@
"use client";
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 { useEffect, useRef, useMemo } from "react";
import { useLocale, useTranslations } from "next-intl";
import { contactDateParts, formatContactDate } from "@/lib/contact-dates";
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, MoreVertical } from "@/components/icons";
import { Avatar } from "@/components/ui/avatar";
import { MailtoLink } from "@/components/ui/mailto-link";
import { Button } from "@/components/ui/button";
import { ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { cn } from "@/lib/utils";
import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
import type { ContactCard, AnniversaryDate } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoRef } from "@/stores/contact-store";
import { ContactActivity } from "./contact-activity";
import { toast } from "@/stores/toast-store";
import { exportContact, useContactExportBusy } from "./contact-export";
import { printContact } from "./contact-print";
import { ContactContextMenu } from "./contact-context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import { IconButton } from "@/components/ui/icon-button";
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { contactHooks } from "@/lib/plugin-hooks";
import { contactToView } from "@/lib/plugin-projection";
type MoreItem =
| {
icon: React.ComponentType<{ className?: string }>;
label: string;
onClick: () => void;
destructive?: boolean;
disabled?: boolean;
separator?: false;
}
| { separator: true };
interface ContactDetailProps {
contact: ContactCard | null;
onEdit: () => void;
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;
@@ -64,32 +56,8 @@ function formatPhoneFeatures(features: Record<string, boolean> | undefined, labe
return Object.keys(features).filter(k => features[k]).map(label).join(", ");
}
function getDateParts(dateInput: AnniversaryDate): { year?: number; month?: number; day?: number } {
if (typeof dateInput === "object" && dateInput !== null) {
if (dateInput["@type"] === "Timestamp" && typeof dateInput.utc === "string") {
const d = new Date(dateInput.utc);
if (!isNaN(d.getTime())) {
return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };
}
return {};
}
const pd = dateInput as PartialDate;
return { year: pd.year, month: pd.month, day: pd.day };
}
const s = String(dateInput);
if (s.startsWith("--")) {
const parts = s.substring(2).split("-");
return { month: parseInt(parts[0], 10), day: parts[1] ? parseInt(parts[1], 10) : undefined };
}
const d = new Date(s);
if (!isNaN(d.getTime())) {
return { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate() };
}
return {};
}
function getCompletedYears(dateInput: AnniversaryDate): number | null {
const { year, month, day } = getDateParts(dateInput);
const { year, month, day } = contactDateParts(dateInput);
if (!year) return null;
const now = new Date();
let years = now.getFullYear() - year;
@@ -102,50 +70,17 @@ function getCompletedYears(dateInput: AnniversaryDate): number | null {
return years;
}
function formatDate(dateInput: AnniversaryDate): string {
if (typeof dateInput === 'object' && dateInput !== null) {
if (dateInput['@type'] === 'Timestamp' && typeof dateInput.utc === 'string') {
try {
const d = new Date(dateInput.utc as string);
if (!isNaN(d.getTime())) {
return d.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" });
}
} catch { /* fallback */ }
return String(dateInput.utc);
}
const pd = dateInput as PartialDate;
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const parts: string[] = [];
if (pd.month && monthNames[pd.month - 1]) parts.push(monthNames[pd.month - 1]);
if (pd.day) parts.push(String(pd.day));
if (pd.year) parts.push(String(pd.year));
return parts.join(' ') || String(dateInput);
}
const dateStr = String(dateInput);
if (dateStr.startsWith("--")) {
const parts = dateStr.substring(2).split("-");
const month = parseInt(parts[0], 10);
const day = parts[1] ? parseInt(parts[1], 10) : undefined;
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
return day ? `${monthNames[month - 1]} ${day}` : monthNames[month - 1];
}
try {
const d = new Date(dateStr);
if (!isNaN(d.getTime())) {
return d.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" });
}
} catch { /* fallback */ }
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 locale = useLocale();
// The ⋮ shows the contact's menu, the one right-click shows in the list (R6).
const moreMenu = useContextMenu<ContactCard>();
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);
const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : [];
const hasCryptoKeysSlotOffers = usePluginSlotOffers('contact-cryptokeys').length > 0;
const exporting = useContactExportBusy();
const pluginContactProps = useMemo(() => (contact ? { contact: contactToView(contact) } : null), [contact]);
// Tell plugins once per contact shown here, not on every edit of that card.
@@ -172,25 +107,6 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
const photo = getContactPhotoRef(contact) ?? null;
const phone = contact.phones ? Object.values(contact.phones)[0]?.number : undefined;
const handleExport = async () => {
if (await exportContact(contact)) toast.success(t("export.success", { count: 1 }));
};
const handlePrint = () => {
void printContact(contact, name);
};
const moreItems: MoreItem[] = [];
if (onAddToGroup) {
moreItems.push({ icon: Users, label: t("context_menu.add_to_group"), onClick: onAddToGroup });
}
if (onDuplicate) {
moreItems.push({ icon: Copy, label: t("context_menu.duplicate"), onClick: onDuplicate });
}
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 });
moreItems.push({ icon: Trash2, label: t("context_menu.delete"), onClick: onDelete, destructive: true });
const emails = contact.emails ? Object.values(contact.emails) : [];
const phones = contact.phones ? Object.values(contact.phones) : [];
const orgs = contact.organizations ? Object.values(contact.organizations) : [];
@@ -221,7 +137,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
<div className={cn("border-b border-border", isMobile ? "px-4 py-4" : "px-6 py-6")}>
<div className={cn("flex gap-4", isMobile ? "flex-col" : "flex-wrap items-start justify-between")}>
<div className="flex items-center gap-4 min-w-0 flex-1 basis-60">
<div className={cn("flex items-center gap-4 min-w-0 flex-1", !isMobile && "basis-60")}>
<Avatar name={name} email={email} contactPhoto={photo} size={isMobile ? "md" : "lg"} />
<div className="min-w-0 flex-1">
<h2 className={cn("font-semibold truncate", isMobile ? "text-lg" : "text-xl")}>{name || "-"}</h2>
@@ -258,7 +174,32 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
<Pencil className="w-4 h-4 me-1" />
{t("form.edit_title")}
</Button>
<MoreActionsMenu items={moreItems} label={t("detail.more_actions")} />
<IconButton
variant="outline"
size="sm"
label={t("detail.more_actions")}
aria-haspopup="menu"
aria-expanded={moreMenu.contextMenu.isOpen}
onClick={(e) => moreMenu.openContextMenuFrom(e.currentTarget, contact, "end")}
className="touch-manipulation"
>
<MoreVertical />
</IconButton>
{moreMenu.contextMenu.data && (
<ContactContextMenu
contact={moreMenu.contextMenu.data}
position={moreMenu.contextMenu.position}
isOpen={moreMenu.contextMenu.isOpen}
onClose={moreMenu.closeContextMenu}
menuRef={moreMenu.menuRef}
inDetail
onCompose={onCompose}
onDelete={onDelete}
onAddToGroup={onAddToGroup}
onDuplicate={onDuplicate}
onMoveTo={onMoveTo}
/>
)}
</div>
</div>
</div>
@@ -391,7 +332,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
return (
<FieldRow key={`an${i}`} icon={Cake} label={t(`detail.anniversary_${ann.kind}`)}>
<div className="text-sm">
{formatDate(ann.date)}
{formatContactDate(ann.date, locale)}
{years !== null && (
<span className="text-muted-foreground"> · {t(suffixKey, { count: years })}</span>
)}
@@ -538,8 +479,8 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
{(contact.created || contact.updated) && (
<div className="py-4 text-xs text-muted-foreground space-y-1">
{contact.created && <div>{t("detail.created")}: {formatDate(contact.created)}</div>}
{contact.updated && <div>{t("detail.updated")}: {formatDate(contact.updated)}</div>}
{contact.created && <div>{t("detail.created")}: {formatContactDate(contact.created, locale)}</div>}
{contact.updated && <div>{t("detail.updated")}: {formatContactDate(contact.updated, locale)}</div>}
</div>
)}
</div>
@@ -581,71 +522,6 @@ function RowActions({ children }: { children: React.ReactNode }) {
);
}
function MoreActionsMenu({ items, label }: { items: MoreItem[]; label: string }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDocMouseDown = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onDocMouseDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDocMouseDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
if (items.length === 0) return null;
return (
<div ref={ref} className="relative">
<Button
variant="outline"
size="sm"
onClick={() => setOpen((o) => !o)}
title={label}
aria-label={label}
aria-haspopup="menu"
aria-expanded={open}
className="touch-manipulation"
>
<MoreHorizontal className="w-4 h-4" />
</Button>
{open && (
<div
role="menu"
className="absolute end-0 top-full mt-1 z-30 min-w-[200px] bg-background rounded-md shadow-lg border border-border py-1"
>
{items.map((item, i) => {
if (item.separator) {
return <ContextMenuSeparator key={i} />;
}
return (
<ContextMenuItem
key={i}
icon={item.icon}
label={item.label}
destructive={item.destructive}
disabled={item.disabled}
onClick={() => {
item.onClick();
setOpen(false);
}}
/>
);
})}
</div>
)}
</div>
);
}
function CopyButton({ value, label, successMsg, failMsg, className }: { value: string; label: string; successMsg: string; failMsg: string; className?: string }) {
return (
<button
+8 -2
View File
@@ -67,6 +67,12 @@ function downloadVcf(content: string, filename: string) {
URL.revokeObjectURL(url);
}
function sanitizeFilename(name: string): string {
return name.replace(/[^a-zA-Z0-9_-]/g, "_").substring(0, 50);
// Letters of any script stay ("Jürgen_Müller.vcf"); only what file systems
// refuse or treat specially becomes "_".
export function sanitizeFilename(name: string): string {
const safe = Array.from(name.normalize("NFC"), (ch) => (ch.charCodeAt(0) < 32 || '\\/:*?"<>|.'.includes(ch) || /\s/.test(ch) ? "_" : ch))
.join("")
.replace(/_+/g, "_")
.replace(/^_|_$/g, "");
return safe.substring(0, 50) || "contact";
}
+55 -37
View File
@@ -1,9 +1,10 @@
"use client";
import { useState, useMemo, useCallback, useRef } from "react";
import { useState, useMemo, useCallback, useRef, useEffect } from "react";
import { useTranslations } from "next-intl";
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book, Camera, Trash2 } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { IconButton } from "@/components/ui/icon-button";
import { Input } from "@/components/ui/input";
import { Avatar } from "@/components/ui/avatar";
import { findContactPhotoEntry, getContactPhotoRef, type ContactPhotoRef } from "@/stores/contact-store";
@@ -64,6 +65,8 @@ interface ContactFormProps {
* may be older.
*/
loadCurrentMedia?: () => Promise<Pick<ContactCard, "media"> | null>;
/** Reports whether the form holds changes not yet saved. */
onDirtyChange?: (dirty: boolean) => void;
}
/** What the user did to the photo: nothing, picked a new one (a data: URI), or removed it. */
@@ -209,7 +212,7 @@ function Select({ value, onChange, children, className }: {
);
}
export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, prefill, onSave, onCancel, loadCurrentMedia }: ContactFormProps) {
export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, prefill, onSave, onCancel, loadCurrentMedia, onDirtyChange }: ContactFormProps) {
const t = useTranslations("contacts.form");
const isEditing = !!contact;
@@ -447,6 +450,17 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
const [error, setError] = useState<string | null>(null);
const [emailErrors, setEmailErrors] = useState<Record<number, string>>({});
// Everything the user can edit, to tell whether anything changed since the
// form opened (R5: leaving asks first).
const fields = JSON.stringify([
prefix, givenName, additionalName, surname, suffix, nickname, emails, phones, organization,
department, isOrg, jobTitle, role, addresses, onlineServices, anniversaries, personalInfoEntries,
keywordsStr, note, genderSex, genderIdentity, calendarUri, schedulingUri, freeBusyUri, selectedBookId,
]);
const [initialFields] = useState(fields);
const dirty = fields !== initialFields || photoEdit.kind !== "unchanged";
useEffect(() => { onDirtyChange?.(dirty); }, [dirty, onDirtyChange]);
const handlePhotoSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = "";
@@ -676,15 +690,15 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
<h2 className="text-lg font-semibold">
{isEditing ? t("edit_title") : t("create_title")}
</h2>
<button type="button" onClick={onCancel} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground">
<X className="w-5 h-5" />
</button>
<IconButton label={t("close")} onClick={onCancel}>
<X />
</IconButton>
</div>
<div className="flex-1 overflow-y-auto">
<div className="px-6 py-4 max-w-3xl">
{error && (
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950 px-3 py-2 rounded-lg border border-red-200 dark:border-red-900 mb-4">
<div role="alert" className="text-sm text-destructive bg-destructive/10 px-3 py-2 rounded-md mb-4">
{error}
</div>
)}
@@ -702,6 +716,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
name={previewName || undefined}
email={previewEmail || undefined}
contactPhoto={previewPhoto}
// Nothing typed yet: a neutral circle, not a coloured "?".
fallbackColor={previewName || previewEmail ? undefined : "var(--color-muted-foreground)"}
size="lg"
className="!w-20 !h-20 !text-xl"
/>
@@ -723,7 +739,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
<div className="flex flex-col gap-1 min-w-0 flex-1">
<p className="text-xs text-muted-foreground">{t("photo_hint")}</p>
{photoError && (
<p className="text-xs text-red-600 dark:text-red-400">{photoError}</p>
<p className="text-xs text-destructive">{photoError}</p>
)}
{previewPhoto && (
<button
@@ -741,16 +757,17 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
<div className="divide-y divide-border/60">
{addressBooks && addressBooks.length > 1 && (
<FormSection icon={Book} title={t("section_address_book") || "Directory"}>
<FormSection icon={Book} title={t("section_address_book")}>
<select
value={selectedBookId}
onChange={(e) => setSelectedBookId(e.target.value)}
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
>
<option value="">{t("select_address_book") || "Select a directory..."}</option>
<option value="">{t("select_address_book")}</option>
{addressBooks.map((book) => (
<option key={book.id} value={book.id}>
{book.accountName ? `${book.name} (${book.accountName})` : book.name}
{/* A shared book says whose it is, unless its name already does. */}
{book.isShared && book.accountName && !book.name.includes(book.accountName) ? `${book.name} (${book.accountName})` : book.name}
</option>
))}
</select>
@@ -788,7 +805,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">
{t("organization")} <span className="text-red-500">*</span>
{t("organization")} <span className="text-destructive">*</span>
</label>
<Input value={organization} onChange={(e) => setOrganization(e.target.value)} placeholder={t("organization_placeholder")} autoFocus />
</div>
@@ -805,15 +822,11 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
<Input value={prefix} onChange={(e) => setPrefix(e.target.value)} placeholder={t("prefix_placeholder")} className="w-20" />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
{t("given_name")} <span className="text-red-500">*</span>
</label>
<label className="text-xs text-muted-foreground mb-1 block">{t("given_name")}</label>
<Input value={givenName} onChange={(e) => setGivenName(e.target.value)} placeholder={t("given_name")} autoFocus />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
{t("surname")} <span className="text-red-500">*</span>
</label>
<label className="text-xs text-muted-foreground mb-1 block">{t("surname")}</label>
<Input value={surname} onChange={(e) => setSurname(e.target.value)} placeholder={t("surname")} />
</div>
<div>
@@ -821,6 +834,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
<Input value={suffix} onChange={(e) => setSuffix(e.target.value)} placeholder={t("suffix_placeholder")} className="w-20" />
</div>
</div>
{/* Either name is enough (or an organization), so neither is starred. */}
<p className="text-xs text-muted-foreground -mt-1">{t("name_required")}</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("middle_name")}</label>
@@ -855,7 +870,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
}}
onBlur={() => handleEmailBlur(i, entry.address)}
placeholder={t("email_placeholder")}
className={cn("flex-1", emailErrors[i] && "border-red-500 focus:ring-red-500")}
className={cn("flex-1", emailErrors[i] && "border-destructive focus-visible:border-destructive")}
/>
<Select
value={entry.context}
@@ -870,13 +885,13 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
<option value="private">{t("context_private")}</option>
</Select>
{emails.length > 1 && (
<Button type="button" variant="ghost" size="icon" onClick={() => setEmails(emails.filter((_, j) => j !== i))} className="h-8 w-8 shrink-0">
<X className="w-3 h-3" />
</Button>
<IconButton label={t("remove_entry")} onClick={() => setEmails(emails.filter((_, j) => j !== i))} className="shrink-0">
<X />
</IconButton>
)}
</div>
{emailErrors[i] && (
<p className="text-xs text-red-600 dark:text-red-400 mt-1 ms-1">{emailErrors[i]}</p>
<p className="text-xs text-destructive mt-1 ms-1">{emailErrors[i]}</p>
)}
</div>
))}
@@ -933,9 +948,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
<option value="work">{t("context_work")}</option>
<option value="private">{t("context_private")}</option>
</Select>
<Button type="button" variant="ghost" size="icon" onClick={() => setPhones(phones.filter((_, j) => j !== i))} className="h-8 w-8 shrink-0">
<X className="w-3 h-3" />
</Button>
<IconButton label={t("remove_entry")} onClick={() => setPhones(phones.filter((_, j) => j !== i))} className="shrink-0">
<X />
</IconButton>
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setPhones([...phones, { number: "", context: "", feature: "" }])} className="text-xs">
@@ -975,9 +990,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
<div className="space-y-3">
{addresses.map((addr, i) => (
<div key={i} className="rounded-md border border-border/60 bg-muted/20 p-3 space-y-2 relative">
<Button type="button" variant="ghost" size="icon" onClick={() => setAddresses(addresses.filter((_, j) => j !== i))} className="h-6 w-6 absolute top-2 right-2">
<X className="w-3 h-3" />
</Button>
<IconButton label={t("remove_entry")} onClick={() => setAddresses(addresses.filter((_, j) => j !== i))} className="absolute top-1 end-1">
<X />
</IconButton>
<Input value={addr.street} onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], street: e.target.value }; setAddresses(n); }} placeholder={t("street")} />
<div className="grid grid-cols-2 gap-2">
<Input value={addr.locality} onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], locality: e.target.value }; setAddresses(n); }} placeholder={t("city")} />
@@ -1021,9 +1036,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
placeholder={t("service_placeholder")}
className="w-24"
/>
<Button type="button" variant="ghost" size="icon" onClick={() => setOnlineServices(onlineServices.filter((_, j) => j !== i))} className="h-8 w-8 shrink-0">
<X className="w-3 h-3" />
</Button>
<IconButton label={t("remove_entry")} onClick={() => setOnlineServices(onlineServices.filter((_, j) => j !== i))} className="shrink-0">
<X />
</IconButton>
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setOnlineServices([...onlineServices, { uri: "", service: "", label: "" }])} className="text-xs">
@@ -1053,9 +1068,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
<option value="death">{t("anniversary_death")}</option>
<option value="other">{t("anniversary_other")}</option>
</Select>
<Button type="button" variant="ghost" size="icon" onClick={() => setAnniversaries(anniversaries.filter((_, j) => j !== i))} className="h-8 w-8 shrink-0">
<X className="w-3 h-3" />
</Button>
<IconButton label={t("remove_entry")} onClick={() => setAnniversaries(anniversaries.filter((_, j) => j !== i))} className="shrink-0">
<X />
</IconButton>
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setAnniversaries([...anniversaries, { date: "", kind: "birth" }])} className="text-xs">
@@ -1094,9 +1109,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
<option value="medium">{t("level_medium")}</option>
<option value="low">{t("level_low")}</option>
</Select>
<Button type="button" variant="ghost" size="icon" onClick={() => setPersonalInfoEntries(personalInfoEntries.filter((_, j) => j !== i))} className="h-8 w-8 shrink-0">
<X className="w-3 h-3" />
</Button>
<IconButton label={t("remove_entry")} onClick={() => setPersonalInfoEntries(personalInfoEntries.filter((_, j) => j !== i))} className="shrink-0">
<X />
</IconButton>
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setPersonalInfoEntries([...personalInfoEntries, { value: "", kind: "hobby", level: "" }])} className="text-xs">
@@ -1198,6 +1213,7 @@ function CategoryComboBox({
hint: string;
addLabel: string;
}) {
const t = useTranslations("contacts.form");
const [isOpen, setIsOpen] = useState(false);
const [inputValue, setInputValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
@@ -1260,6 +1276,8 @@ function CategoryComboBox({
<button
type="button"
onClick={() => removeKeyword(kw)}
aria-label={t("remove_category", { name: kw })}
title={t("remove_category", { name: kw })}
className="hover:text-destructive transition-colors"
>
<X className="w-3 h-3" />
+26 -14
View File
@@ -1,9 +1,12 @@
"use client";
import { useTranslations } from "next-intl";
import { Users, Pencil, Trash2, UserMinus, Mail } from "@/components/icons";
import { Users, Pencil, Trash2, UserMinus, Mail, MoreVertical } from "@/components/icons";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { IconButton } from "@/components/ui/icon-button";
import { cn } from "@/lib/utils";
import type { ContactCard } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
@@ -34,6 +37,8 @@ export function ContactGroupDetail({
}: ContactGroupDetailProps) {
const t = useTranslations("contacts");
const groupName = getContactDisplayName(group);
// Delete sits in a ⋮, as on a contact's page (R6).
const menu = useContextMenu<null>();
const hasEmailMembers = members.some((m) => getContactPrimaryEmail(m).trim());
return (
@@ -54,16 +59,24 @@ export function ContactGroupDetail({
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
<Pencil className="w-4 h-4 me-1" />
{t("form.edit_title")}
{t("groups.edit")}
</Button>
<Button
<IconButton
variant="outline"
size="sm"
onClick={onDelete}
className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-950 touch-manipulation"
label={t("detail.more_actions")}
aria-haspopup="menu"
aria-expanded={menu.contextMenu.isOpen}
onClick={(e) => menu.openContextMenuFrom(e.currentTarget, null, "end")}
className="touch-manipulation"
>
<Trash2 className="w-4 h-4" />
</Button>
<MoreVertical />
</IconButton>
<ContextMenu ref={menu.menuRef} isOpen={menu.contextMenu.isOpen} position={menu.contextMenu.position} onClose={menu.closeContextMenu}>
<ContextMenuItem icon={Pencil} label={t("groups.edit")} onClick={() => { menu.closeContextMenu(); onEdit(); }} />
<ContextMenuSeparator />
<ContextMenuItem icon={Trash2} label={t("groups.delete")} destructive onClick={() => { menu.closeContextMenu(); onDelete(); }} />
</ContextMenu>
</div>
</div>
{onComposeGroup && hasEmailMembers && (
@@ -117,17 +130,16 @@ export function ContactGroupDetail({
)}
</div>
</button>
<Button
variant="ghost"
size="icon"
<IconButton
label={t("groups.remove_member", { name: mName })}
className={cn(
"h-8 w-8 transition-opacity",
isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"
"transition-opacity",
isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 pointer-coarse:opacity-100"
)}
onClick={() => onRemoveMember(member.id)}
>
<UserMinus className="w-4 h-4 text-muted-foreground" />
</Button>
<UserMinus className="text-muted-foreground" />
</IconButton>
</div>
);
})}
+13 -4
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useMemo } from "react";
import { useState, useMemo, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Search, Check, X } from "@/components/icons";
import { Button } from "@/components/ui/button";
@@ -16,6 +16,8 @@ interface ContactGroupFormProps {
currentMemberIds?: string[];
onSave: (name: string, memberIds: string[]) => Promise<void>;
onCancel: () => void;
/** Reports whether the form holds changes not yet saved. */
onDirtyChange?: (dirty: boolean) => void;
}
export function ContactGroupForm({
@@ -24,6 +26,7 @@ export function ContactGroupForm({
currentMemberIds = [],
onSave,
onCancel,
onDirtyChange,
}: ContactGroupFormProps) {
const t = useTranslations("contacts");
const isEditing = !!group;
@@ -38,6 +41,10 @@ export function ContactGroupForm({
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [initial] = useState(() => ({ name, members: [...selectedIds].sort().join(",") }));
const dirty = name !== initial.name || [...selectedIds].sort().join(",") !== initial.members;
useEffect(() => { onDirtyChange?.(dirty); }, [dirty, onDirtyChange]);
const filteredIndividuals = useMemo(() => {
if (!memberSearch) return individuals;
const lower = memberSearch.toLowerCase();
@@ -87,7 +94,7 @@ export function ContactGroupForm({
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{error && (
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950 px-3 py-2 rounded">
<div role="alert" className="text-sm text-destructive bg-destructive/10 px-3 py-2 rounded-md">
{error}
</div>
)}
@@ -109,7 +116,7 @@ export function ContactGroupForm({
{t("groups.members_label")} ({selectedIds.size})
</label>
<div className="relative mb-2">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder={t("groups.search_members")}
value={memberSearch}
@@ -175,7 +182,9 @@ export function ContactGroupForm({
<button
type="button"
onClick={() => toggleMember(id)}
className="hover:text-red-600 dark:hover:text-red-400"
aria-label={t("groups.remove_member", { name: getContactDisplayName(contact) })}
title={t("groups.remove_member", { name: getContactDisplayName(contact) })}
className="hover:text-destructive"
>
<X className="w-3 h-3" />
</button>
+34 -39
View File
@@ -2,8 +2,9 @@
import { useState, useRef, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Upload, FileText, AlertTriangle, X, Check } from "@/components/icons";
import { Upload, FileText, AlertTriangle, Check } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { Dialog } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { parseVCard, detectDuplicates } from "@/lib/vcard";
import type { ContactCard } from "@/lib/jmap/types";
@@ -99,16 +100,36 @@ export function ContactImportDialog({
}
};
return (
<div className="flex flex-col h-full">
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
<h2 className="text-lg font-semibold">{t("import.title")}</h2>
<Button variant="ghost" size="icon" onClick={onClose} className="h-8 w-8">
<X className="w-4 h-4" />
</Button>
</div>
const errorBanner = error && (
<div role="alert" className="text-sm text-destructive bg-destructive/10 px-3 py-2 rounded-md flex items-center gap-2">
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
{error}
</div>
);
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
return (
<Dialog
isOpen
onClose={onClose}
title={t("import.title")}
size="lg"
// Picked cards are not thrown away by a stray click outside.
dirty={parsed.length > 0 && result === null}
footer={parsed.length > 0 && result === null ? (
<>
<p className="text-sm text-muted-foreground me-auto self-center">
{t("import.selected", { count: selected.size })}
</p>
<Button variant="outline" onClick={onClose} disabled={isImporting}>
{t("form.cancel")}
</Button>
<Button onClick={handleImport} disabled={isImporting || selected.size === 0}>
{isImporting ? t("import.importing") : t("import.import_button")}
</Button>
</>
) : undefined}
>
<div className="space-y-4 pt-4">
{result !== null ? (
<div className="flex flex-col items-center justify-center py-12">
<div className="w-12 h-12 rounded-full bg-green-100 dark:bg-green-900 flex items-center justify-center mb-4">
@@ -144,21 +165,11 @@ export function ContactImportDialog({
<p className="text-xs">{t("import.file_types")}</p>
</button>
{error && (
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950 px-3 py-2 rounded flex items-center gap-2">
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
{error}
</div>
)}
{errorBanner}
</>
) : (
<>
{error && (
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950 px-3 py-2 rounded flex items-center gap-2">
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
{error}
</div>
)}
{errorBanner}
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
@@ -216,22 +227,6 @@ export function ContactImportDialog({
</>
)}
</div>
{parsed.length > 0 && result === null && (
<div className="flex items-center justify-between px-6 py-4 border-t border-border">
<p className="text-sm text-muted-foreground">
{t("import.selected", { count: selected.size })}
</p>
<div className="flex gap-2">
<Button variant="outline" onClick={onClose} disabled={isImporting}>
{t("form.cancel")}
</Button>
<Button onClick={handleImport} disabled={isImporting || selected.size === 0}>
{isImporting ? t("import.importing") : t("import.import_button")}
</Button>
</div>
</div>
)}
</div>
</Dialog>
);
}
+28 -5
View File
@@ -1,6 +1,8 @@
"use client";
import { useCallback, type DragEvent } from "react";
import { useCallback, useRef, type DragEvent } from "react";
import { useLongPress } from "@/hooks/use-long-press";
import { useTranslations } from "next-intl";
import { Avatar } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
import type { ContactCard } from "@/lib/jmap/types";
@@ -18,9 +20,19 @@ interface ContactListItemProps {
onClick: (e: React.MouseEvent) => void;
onCheckboxClick: (e: React.MouseEvent) => void;
onContextMenu?: (e: React.MouseEvent, contact: ContactCard) => void;
/** The keyboard cursor: the row that takes Tab and shows a focus ring. */
isFocused?: boolean;
onFocus?: () => void;
/** Long-press on touch: the row's menu, as right-click does (R7). */
onLongPress?: (row: HTMLElement) => void;
}
export function ContactListItem({ contact, isSelected, isChecked, hasSelection, density, selectedContactIds, onClick, onCheckboxClick, onContextMenu }: ContactListItemProps) {
export function ContactListItem({ contact, isSelected, isChecked, hasSelection, density, selectedContactIds, onClick, onCheckboxClick, onContextMenu, isFocused = false, onFocus, onLongPress }: ContactListItemProps) {
const rowRef = useRef<HTMLDivElement>(null);
const longPress = useLongPress(() => {
if (rowRef.current) onLongPress?.(rowRef.current);
}, !!onLongPress);
const t = useTranslations("contacts");
const name = getContactDisplayName(contact);
const email = getContactPrimaryEmail(contact);
const org = contact.organizations
@@ -46,20 +58,27 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection,
box-shadow: 0 4px 12px rgba(0,0,0,0.15); font-size: 14px; font-weight: 500;
z-index: 9999; white-space: nowrap; pointer-events: none;
`;
preview.textContent = ids.length === 1 ? (name || "1 contact") : `${ids.length} contacts`;
preview.textContent = ids.length === 1 && name ? name : t("drag_count", { count: ids.length });
document.body.appendChild(preview);
e.dataTransfer.setDragImage(preview, 0, 0);
requestAnimationFrame(() => preview.remove());
}, [contact.id, name, email, selectedContactIds]);
}, [contact.id, name, email, selectedContactIds, t]);
return (
<div
ref={rowRef}
{...(onLongPress ? longPress : {})}
role="option"
aria-selected={isSelected || isChecked}
tabIndex={isFocused ? 0 : -1}
data-contact-id={contact.id}
onFocus={onFocus}
draggable
onDragStart={handleDragStart}
onClick={onClick}
onContextMenu={onContextMenu ? (e) => onContextMenu(e, contact) : undefined}
className={cn(
"w-full flex items-center cursor-pointer select-none transition-all duration-200 border-b border-border",
"w-full flex items-center cursor-pointer select-none transition-all duration-200 border-b border-border outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
isSelected
? "bg-selection shadow-sm"
: "bg-background hover:bg-muted hover:shadow-sm",
@@ -69,6 +88,10 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection,
>
{hasSelection && (
<button
type="button"
role="checkbox"
aria-checked={isChecked}
aria-label={t("select_contact", { name: name || email || "-" })}
onClick={onCheckboxClick}
className={cn(
"p-1 rounded flex-shrink-0 transition-all duration-200",
+216 -93
View File
@@ -1,15 +1,17 @@
"use client";
import { useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, 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, Upload } from "@/components/icons";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { IconButton } from "@/components/ui/icon-button";
import { ContactListItem } from "./contact-list-item";
import { ContactContextMenu } from "./contact-context-menu";
import { useContactExportBusy } from "./contact-export";
import { PluginActions } from "@/components/plugins/plugin-actions";
import { useContextMenu } from "@/hooks/use-context-menu";
import { isContextMenuKey, useContextMenu } from "@/hooks/use-context-menu";
import { PhoneHeaderActions } from "@/components/layout/phone-header-actions";
import { cn } from "@/lib/utils";
import type { AnniversaryDate, ContactCard } from "@/lib/jmap/types";
@@ -115,8 +117,18 @@ interface ContactListProps {
onEditContact: (id: string) => void;
onDeleteContact: (contact: ContactCard) => void;
onAddContactToGroup: (id: string) => void;
/** Opens the app's composer to the contact. */
onComposeContact?: (contact: ContactCard) => void;
onDuplicateContact?: (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 list shows one address book, category or group, not the whole account. */
emptyView?: boolean;
onImport?: () => void;
/** Contacts' own entries in the phone header's ⋮. */
phoneMenuItems?: (close: () => void) => React.ReactNode;
/** The first fetch is still running: show placeholders, not "No contacts yet". */
isLoading?: boolean;
}
@@ -140,7 +152,13 @@ export function ContactList({
onEditContact,
onDeleteContact,
onAddContactToGroup,
onComposeContact,
onDuplicateContact,
onMoveContacts,
onMenuClick,
phoneMenuItems,
emptyView = false,
onImport,
isLoading = false,
}: ContactListProps) {
const t = useTranslations("contacts");
@@ -149,7 +167,9 @@ export function ContactList({
const groupByLetter = useSettingsStore((state) => state.groupContactsByLetter);
const sortByLastName = useSettingsStore((state) => state.sortContactsByLastName);
const updateSetting = useSettingsStore((state) => state.updateSetting);
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<ContactCard>();
const { contextMenu, openContextMenu, openContextMenuFrom, closeContextMenu, menuRef } = useContextMenu<ContactCard>();
// Opened by a long-press: the menu also offers Select, as touch has no Ctrl+click.
const [menuByTouch, setMenuByTouch] = useState(false);
const [filtersOpen, setFiltersOpen] = useState(false);
const [filters, setFilters] = useState<ListFilters>(EMPTY_FILTERS);
const activeFilters = countActiveFilters(filters);
@@ -275,6 +295,87 @@ export function ContactList({
const pluginContext = useMemo(() => ({ contactIds: [...selectedContactIds] }), [selectedContactIds]);
const allSelected = sorted.length > 0 && sorted.every(c => selectedContactIds.has(c.id));
// ---- Keyboard (R8): the list keys Mail and Files share --------------------
// One row takes Tab (roving focus): the open contact, else the first.
const listRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const [focusedId, setFocusedId] = useState<string | null>(null);
const cursorId = focusedId && sortedIds.includes(focusedId)
? focusedId
: selectedContactId && sortedIds.includes(selectedContactId) ? selectedContactId : sortedIds[0] ?? null;
const focusRow = useCallback((id: string) => {
setFocusedId(id);
const row = listRef.current?.querySelector<HTMLElement>(`[data-contact-id="${CSS.escape(id)}"]`);
row?.focus();
row?.scrollIntoView?.({ block: "nearest" });
}, []);
const handleListKeyDown = (e: React.KeyboardEvent) => {
const target = e.target as HTMLElement;
if (target.closest("input, textarea, select, [contenteditable=true]")) return;
const mod = e.ctrlKey || e.metaKey;
const index = cursorId ? sortedIds.indexOf(cursorId) : -1;
const contact = cursorId ? sorted.find((c) => c.id === cursorId) : undefined;
const moveTo = (next: number, extend: boolean) => {
if (sortedIds.length === 0) return;
const id = sortedIds[Math.max(0, Math.min(sortedIds.length - 1, next))];
focusRow(id);
if (extend) onSelectRangeContacts(id, sortedIds);
else if (!hasSelection) onSelectContact(id);
};
if (isContextMenuKey(e) && contact) {
e.preventDefault();
const row = listRef.current?.querySelector<HTMLElement>(`[data-contact-id="${CSS.escape(contact.id)}"]`);
if (row) openContextMenuFrom(row, contact);
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
moveTo(index + (e.key === "ArrowDown" ? 1 : -1), e.shiftKey);
} else if (e.key === "Home" || e.key === "End") {
e.preventDefault();
moveTo(e.key === "Home" ? 0 : sortedIds.length - 1, e.shiftKey);
} else if (e.key === "Enter" && contact) {
e.preventDefault();
onSelectContact(contact.id);
} else if (e.key === " " && contact) {
e.preventDefault();
onToggleSelection(contact.id);
} else if (mod && e.code === "KeyA") {
e.preventDefault();
onSelectAll(sortedIds);
} else if ((e.key === "Delete" || e.key === "Backspace") && !e.altKey) {
// Backspace deletes too (⌘⌫ on a Mac), as in Mail and Files.
if (hasSelection) {
e.preventDefault();
onBulkDelete();
} else if (contact) {
e.preventDefault();
onDeleteContact(contact);
}
} else if (e.key === "Escape") {
if (hasSelection) {
e.preventDefault();
onClearSelection();
} else if (searchQuery) {
e.preventDefault();
onSearchChange("");
}
}
};
// "/" goes to the search from anywhere in Contacts, as in Mail.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== "/" || e.ctrlKey || e.metaKey || e.altKey) return;
const target = e.target as HTMLElement | null;
if (target?.closest("input, textarea, select, [contenteditable=true]")) return;
if (document.querySelector('[role="dialog"], [role="alertdialog"], [role="menu"]')) return;
e.preventDefault();
searchRef.current?.focus();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
return (
<div className={cn("flex flex-col h-full", className)}>
{/* Toolbar: select / search / filter */}
@@ -282,17 +383,12 @@ export function ContactList({
<div className="px-3 h-14 flex items-center">
<div className="flex items-center gap-1.5 w-full">
{onMenuClick && (
<button
type="button"
onClick={onMenuClick}
className="flex-shrink-0 p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label={t("open_categories")}
>
<Menu className="w-4 h-4" />
</button>
<IconButton label={t("open_categories")} onClick={onMenuClick} className="flex-shrink-0 text-muted-foreground hover:text-foreground">
<Menu />
</IconButton>
)}
<button
type="button"
<IconButton
label={hasSelection ? (allSelected ? t("bulk.clear") : t("bulk.select_all")) : t("filters.select")}
onClick={() => {
if (hasSelection) {
if (allSelected) onClearSelection();
@@ -305,18 +401,18 @@ export function ContactList({
}
}}
className={cn(
"flex-shrink-0 p-2 rounded-md transition-colors",
"flex-shrink-0",
hasSelection
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
? "bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary"
: "text-muted-foreground hover:text-foreground",
)}
title={hasSelection ? (allSelected ? t("bulk.clear") : t("bulk.select_all")) : t("filters.select")}
>
{hasSelection ? <CheckSquare className="w-4 h-4" /> : <Square className="w-4 h-4" />}
</button>
{hasSelection ? <CheckSquare /> : <Square />}
</IconButton>
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
ref={searchRef}
type="text"
placeholder={t("search_placeholder")}
value={searchQuery}
@@ -327,33 +423,32 @@ export function ContactList({
<button
type="button"
onClick={() => onSearchChange("")}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
className="absolute end-2 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
aria-label={t("clear_search")}
>
<X className="w-4 h-4" />
</button>
)}
</div>
<button
type="button"
<IconButton
label={t("filters.toggle")}
onClick={() => setFiltersOpen((v) => !v)}
aria-expanded={filtersOpen}
className={cn(
"relative flex-shrink-0 p-2 rounded-md transition-colors",
"flex-shrink-0",
filtersOpen || activeFilters > 0
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
? "bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary"
: "text-muted-foreground hover:text-foreground",
)}
title={t("filters.toggle")}
aria-label={t("filters.toggle")}
>
<Filter className="w-4 h-4" />
<Filter />
{!filtersOpen && activeFilters > 0 && (
<span className="absolute -top-1 -right-1 flex items-center justify-center w-4 h-4 text-[10px] font-bold rounded-full bg-primary text-primary-foreground">
<span className="absolute -top-1 -end-1 flex items-center justify-center w-4 h-4 text-[10px] font-bold rounded-full bg-primary text-primary-foreground">
{activeFilters}
</span>
)}
</button>
<PhoneHeaderActions below="md" />
</IconButton>
<PhoneHeaderActions below="md" menuItems={phoneMenuItems} />
</div>
</div>
@@ -367,22 +462,15 @@ export function ContactList({
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
size="xs"
onClick={() => setFilters(EMPTY_FILTERS)}
className="h-7 px-2 text-xs"
>
<RotateCcw className="w-3 h-3 me-1" />
{t("filters.clear")}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setFiltersOpen(false)}
className="h-7 w-7"
aria-label={t("filters.close")}
>
<X className="w-4 h-4" />
</Button>
<IconButton label={t("filters.close")} onClick={() => setFiltersOpen(false)}>
<X />
</IconButton>
</div>
</div>
@@ -489,8 +577,14 @@ export function ContactList({
{/* Bulk action bar */}
{hasSelection && (
<div className="px-3 py-1.5 border-b border-border bg-accent/30 flex items-center gap-2 flex-wrap">
// One line at any width: below 28rem the buttons show only their icons.
<div className="@container px-3 py-1.5 border-b border-border bg-accent/30 flex items-center gap-1 flex-nowrap min-w-0">
<button
type="button"
role="checkbox"
aria-checked={allSelected ? true : "mixed"}
aria-label={allSelected ? t("bulk.clear") : t("bulk.select_all")}
title={allSelected ? t("bulk.clear") : t("bulk.select_all")}
onClick={() => {
if (allSelected) {
onClearSelection();
@@ -506,38 +600,53 @@ export function ContactList({
<Square className="w-4 h-4 text-muted-foreground" />
)}
</button>
<span className="text-xs font-medium text-foreground">
<span className="text-xs font-medium text-foreground whitespace-nowrap">
{t("bulk.selected", { count: selectedContactIds.size })}
</span>
<div className="flex-1" />
<Button variant="ghost" size="sm" onClick={onBulkAddToGroup} className="h-7 text-xs">
<Users className="w-3.5 h-3.5 me-1" />
{t("bulk.add_to_group")}
<Button variant="ghost" size="xs" onClick={onBulkAddToGroup} aria-label={t("bulk.add_to_group")} title={t("bulk.add_to_group")} className="shrink-0">
<Users className="w-3.5 h-3.5 @md:me-1" />
<span className="hidden @md:inline">{t("bulk.add_to_group")}</span>
</Button>
<Button variant="ghost" size="sm" onClick={onBulkExport} disabled={exporting} aria-busy={exporting} className="h-7 text-xs">
{onMoveContacts && (
<Button variant="ghost" size="xs" onClick={() => onMoveContacts([...selectedContactIds])} aria-label={t("address_books.move_to")} title={t("address_books.move_to")} className="shrink-0">
<FolderInput className="w-3.5 h-3.5 @md:me-1" />
<span className="hidden @md:inline">{t("address_books.move_to")}</span>
</Button>
)}
<Button variant="ghost" size="xs" onClick={onBulkExport} disabled={exporting} aria-busy={exporting} aria-label={t("bulk.export")} title={t("bulk.export")} className="shrink-0">
{exporting
? <Loader2 className="w-3.5 h-3.5 me-1 animate-spin" />
: <Download className="w-3.5 h-3.5 me-1" />}
{exporting ? t("export.exporting") : t("bulk.export")}
? <Loader2 className="w-3.5 h-3.5 @md:me-1 animate-spin" />
: <Download className="w-3.5 h-3.5 @md:me-1" />}
<span className="hidden @md:inline">{exporting ? t("export.exporting") : t("bulk.export")}</span>
</Button>
<Button
variant="ghost"
size="sm"
size="xs"
onClick={onBulkDelete}
className="h-7 text-xs text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
aria-label={t("bulk.delete")}
title={t("bulk.delete")}
className="shrink-0 text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="w-3.5 h-3.5 me-1" />
{t("bulk.delete")}
<Trash2 className="w-3.5 h-3.5 @md:me-1" />
<span className="hidden @md:inline">{t("bulk.delete")}</span>
</Button>
<PluginActions surface="contact-list-actions" context={pluginContext} />
<Button variant="ghost" size="icon" onClick={onClearSelection} className="h-7 w-7">
<X className="w-3.5 h-3.5" />
</Button>
<IconButton label={t("bulk.clear")} onClick={onClearSelection}>
<X />
</IconButton>
</div>
)}
{/* Contact list */}
<div className="flex-1 overflow-y-auto">
<div
ref={listRef}
role="listbox"
aria-label={t("title")}
aria-multiselectable
onKeyDown={handleListKeyDown}
className="flex-1 overflow-y-auto"
>
{sorted.length === 0 && isLoading && !searchQuery && activeFilters === 0 ? (
<div aria-hidden className="py-1" data-testid="contact-list-skeleton">
{Array.from({ length: 8 }, (_, i) => (
@@ -551,21 +660,14 @@ export function ContactList({
))}
</div>
) : sorted.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full px-6 text-center">
{searchQuery || activeFilters > 0 ? (
<>
{activeFilters > 0 && !searchQuery ? (
<Filter className="w-10 h-10 mb-3 text-muted-foreground/30" />
) : (
<Search className="w-10 h-10 mb-3 text-muted-foreground/30" />
)}
<p className="text-sm font-medium text-foreground">
{searchQuery ? t("empty_search") : t("empty_filtered")}
</p>
<p className="text-xs text-muted-foreground mt-1">
{searchQuery ? t("empty_search_hint") : t("empty_filtered_hint")}
</p>
<div className="flex gap-2 mt-3">
searchQuery || activeFilters > 0 ? (
<EmptyState
compact
icon={activeFilters > 0 && !searchQuery ? Filter : Search}
title={searchQuery ? t("empty_search") : t("empty_filtered")}
description={searchQuery ? t("empty_search_hint") : t("empty_filtered_hint")}
actions={
<>
{searchQuery && (
<Button variant="outline" size="sm" onClick={() => onSearchChange("")}>
{t("clear_search")}
@@ -577,20 +679,33 @@ export function ContactList({
{t("filters.clear")}
</Button>
)}
</div>
</>
) : (
<>
<BookUser className="w-10 h-10 mb-3 text-muted-foreground/30" />
<p className="text-sm font-medium text-foreground">{t("empty_state_title")}</p>
<p className="text-xs text-muted-foreground mt-1">{t("empty_state_subtitle")}</p>
<Button size="sm" className="mt-3" onClick={onCreateNew}>
<UserPlus className="w-4 h-4 me-1.5" />
{t("create_new")}
</Button>
</>
)}
</div>
</>
}
/>
) : (
// The whole account is empty, or only the address book, category
// or group on screen: each says so, and both offer New and Import.
<EmptyState
compact
icon={BookUser}
title={emptyView ? t("empty_view_title") : t("empty_state_title")}
description={emptyView ? t("empty_view_hint") : t("empty_state_subtitle")}
actions={
<>
<Button size="sm" onClick={onCreateNew}>
<UserPlus className="w-4 h-4 me-1.5" />
{t("create_new")}
</Button>
{onImport && (
<Button variant="outline" size="sm" onClick={onImport}>
<Upload className="w-4 h-4 me-1.5" />
{t("import.title")}
</Button>
)}
</>
}
/>
)
) : (
(() => {
const renderItem = (contact: ContactCard) => (
@@ -600,6 +715,8 @@ export function ContactList({
isSelected={contact.id === selectedContactId}
isChecked={selectedContactIds.has(contact.id)}
hasSelection={hasSelection}
isFocused={contact.id === cursorId}
onFocus={() => setFocusedId(contact.id)}
density={density}
selectedContactIds={selectedContactIds}
onClick={(e) => {
@@ -618,14 +735,15 @@ export function ContactList({
e.stopPropagation();
onToggleSelection(contact.id);
}}
onContextMenu={(e, c) => openContextMenu(e, c)}
onContextMenu={(e, c) => { setMenuByTouch(false); openContextMenu(e, c); }}
onLongPress={(row) => { setMenuByTouch(true); openContextMenuFrom(row, contact); }}
/>
);
return groupByLetter ? (
<div>
{groupedSections.map(({ letter, items }) => (
<section key={letter}>
<div className="sticky top-0 z-10 px-4 py-0.5 bg-background/90 backdrop-blur-sm text-[11px] font-medium text-muted-foreground/70 uppercase tracking-wide">
<section key={letter} role="group" aria-label={letter}>
<div aria-hidden className="sticky top-0 z-10 px-4 py-0.5 bg-background/90 backdrop-blur-sm text-[11px] font-medium text-muted-foreground/70 uppercase tracking-wide">
{letter}
</div>
{items.map(renderItem)}
@@ -649,9 +767,14 @@ export function ContactList({
isMultiSelect={selectedContactIds.has(contextMenu.data.id)}
selectedCount={selectedContactIds.size}
onOpen={() => onSelectContact(contextMenu.data!.id)}
onSelect={menuByTouch ? () => onToggleSelection(contextMenu.data!.id) : undefined}
onEdit={() => onEditContact(contextMenu.data!.id)}
onDelete={() => onDeleteContact(contextMenu.data!)}
onAddToGroup={() => onAddContactToGroup(contextMenu.data!.id)}
onCompose={onComposeContact ? () => onComposeContact(contextMenu.data!) : undefined}
onDuplicate={onDuplicateContact ? () => onDuplicateContact(contextMenu.data!) : undefined}
onMoveTo={onMoveContacts ? () => onMoveContacts([contextMenu.data!.id]) : undefined}
onBatchMoveTo={onMoveContacts ? () => onMoveContacts([...selectedContactIds]) : undefined}
onBatchExport={onBulkExport}
onBatchAddToGroup={onBulkAddToGroup}
onBatchDelete={onBulkDelete}
+64 -34
View File
@@ -1,4 +1,5 @@
import type { ContactCard, PartialDate } from "@/lib/jmap/types";
import type { ContactCard } from "@/lib/jmap/types";
import { formatContactDate } from "@/lib/contact-dates";
import { getContactDisplayName, getContactPhotoRef } from "@/stores/contact-store";
import { loadContactMediaBlob } from "@/lib/contact-media";
import { blobToDataUri } from "@/lib/data-uri";
@@ -37,7 +38,53 @@ async function printablePhotoUri(contact: ContactCard): Promise<string | undefin
return blob ? blobToDataUri(blob, photo.mediaType).catch(() => undefined) : undefined;
}
export async function printContact(contact: ContactCard, displayName?: string): Promise<void> {
/** The print sheet's words, in the UI language. */
export interface PrintLabels {
locale: string;
contact: string;
emails: string;
email: string;
phones: string;
phone: string;
organizations: string;
organization: string;
titles: string;
title: string;
role: string;
addresses: string;
address: string;
online: string;
onlineService: string;
anniversaries: string;
anniversary: (kind: string) => string;
notes: string;
}
/** The print labels from the contacts translations (`t` of the "contacts" namespace). */
export function printLabels(t: (key: string) => string, locale: string): PrintLabels {
return {
locale,
contact: t("detail.section_contact"),
emails: t("detail.emails"),
email: t("detail.email_default_label"),
phones: t("detail.phones"),
phone: t("detail.phone_default_label"),
organizations: t("detail.organizations"),
organization: t("detail.organization_label"),
titles: t("detail.titles"),
title: t("detail.title_label"),
role: t("detail.role_label"),
addresses: t("detail.addresses"),
address: t("detail.address_default_label"),
online: t("detail.online_services"),
onlineService: t("detail.online_service_default_label"),
anniversaries: t("detail.anniversaries"),
anniversary: (kind) => t(`detail.anniversary_${["birth", "death", "wedding"].includes(kind) ? kind : "other"}`),
notes: t("detail.notes"),
};
}
export async function printContact(contact: ContactCard, labels: PrintLabels, displayName?: string): Promise<void> {
// Open the window before anything is awaited: browsers allow the popup only
// while the click that asked for it is being handled.
const printWindow = window.open("", "_blank");
@@ -63,62 +110,45 @@ export async function printContact(contact: ContactCard, displayName?: string):
const row = (label: string, value: string) =>
`<dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd>`;
section("Email", emails.map(e => row(e.label || "Email", e.address || "")));
section("Phone", phones.map(p => row(p.label || "Phone", p.number || "")));
section(labels.emails, emails.map(e => row(e.label || labels.email, e.address || "")));
section(labels.phones, phones.map(p => row(p.label || labels.phone, p.number || "")));
section(
"Organization",
labels.organizations,
orgs.map(o => {
const units = o.units ? Object.values(o.units).map(u => u.name).filter(Boolean).join(", ") : "";
const value = [o.name, units].filter(Boolean).join(" \u2014 ");
return row("Organization", value);
return row(labels.organization, value);
}),
);
section("Title", titles.map(t => row(t.kind === "role" ? "Role" : "Title", t.name || "")));
section(labels.titles, titles.map(t => row(t.kind === "role" ? labels.role : labels.title, t.name || "")));
section(
"Address",
labels.addresses,
addresses.map(a => {
const parts = [a.street, a.locality, a.region, a.postcode, a.country]
.map(p => (typeof p === "string" ? p : ""))
.filter(Boolean);
return row(a.label || "Address", parts.join(", "));
return row(a.label || labels.address, parts.join(", "));
}),
);
section(
"Online",
onlineServices.map(s => row(s.label || s.service || "Online", s.uri || s.user || "")),
labels.online,
onlineServices.map(s => row(s.label || s.service || labels.onlineService, s.uri || s.user || "")),
);
section(
"Anniversary",
anniversaries.map(a => {
const date = a.date;
let formatted = "";
if (typeof date === "object" && date !== null) {
if ("utc" in date && typeof date.utc === "string") formatted = date.utc;
else {
const parts: string[] = [];
const pd = date as PartialDate;
if (pd.year) parts.push(String(pd.year));
if (pd.month) parts.push(String(pd.month).padStart(2, "0"));
if (pd.day) parts.push(String(pd.day).padStart(2, "0"));
formatted = parts.join("-");
}
} else if (date) {
formatted = String(date);
}
return row(a.kind || "Date", formatted);
}),
labels.anniversaries,
anniversaries.map(a => row(labels.anniversary(a.kind || "other"), a.date ? formatContactDate(a.date, labels.locale) : "")),
);
section("Notes", notes.map(n => row("", n.note || "")));
section(labels.notes, notes.map(n => row("", n.note || "")));
const photoTag = photoUri
? `<img class="photo" src="${escapeHtml(photoUri)}" alt="" />`
: `<div class="photo placeholder">${escapeHtml((name || "?").charAt(0).toUpperCase())}</div>`;
const html = `<!doctype html>
<html>
<html lang="${escapeHtml(labels.locale)}">
<head>
<meta charset="utf-8" />
<title>${escapeHtml(name || "Contact")}</title>
<title>${escapeHtml(name || labels.contact)}</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #111; margin: 32px; }
@@ -137,7 +167,7 @@ export async function printContact(contact: ContactCard, displayName?: string):
<body>
<header>
${photoTag}
<div><h1>${escapeHtml(name || "Contact")}</h1></div>
<div><h1>${escapeHtml(name || labels.contact)}</h1></div>
</header>
${rows.join("")}
</body>
+312 -102
View File
@@ -4,8 +4,9 @@ import { Suspense, useState, useEffect, useCallback, useRef, useMemo } from "rea
import { useTranslations } from "next-intl";
import { useSearchParams } from "next/navigation";
import { useRouter } from "@/i18n/navigation";
import { ArrowLeft, Users, AlertTriangle } from "@/components/icons";
import { ArrowLeft, Users, UsersRound, AlertTriangle, Plus, UserPlus, Upload, BookPlus } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { ContextMenuItem } from "@/components/ui/context-menu";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { ContactList } from "@/components/contacts/contact-list";
@@ -15,9 +16,10 @@ 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 { RenameDialog } from "@/components/files/rename-dialog";
import { PromptDialog } from "@/components/ui/prompt-dialog";
import { exportContacts } from "@/components/contacts/contact-export";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { useContactStore, getContactDisplayName, getContactPrimaryEmail, type DirectoryEntry } from "@/stores/contact-store";
@@ -48,6 +50,8 @@ import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
import { useDocumentTitle } from "@/hooks/use-document-title";
import { useRegisterCommands } from "@/lib/command-registry";
import { useCloseOnBack } from "@/hooks/use-close-on-back";
import { MvTopBar } from "@/components/layout/mv-top-bar";
import { useMountainViewShell } from "@/hooks/use-mountain-view-shell";
@@ -115,6 +119,8 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
shareAddressBook,
setDefaultAddressBook,
renameKeyword,
removeKeyword,
addKeyword,
importContacts,
} = useContactStore();
@@ -128,7 +134,11 @@ 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[]>([]);
const [directorySelection, setDirectorySelection] = useState<Set<string>>(() => new Set());
const [openDirectoryKey, setOpenDirectoryKey] = useState<string | null>(null);
const directoryPrincipals = useContactStore((s) => s.directoryPrincipals);
@@ -151,6 +161,68 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
const isNarrow = !isDesktop;
const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false);
useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]);
// A new view (a form, a group, a contact) replaces whatever the drawer was for.
useEffect(() => { setNarrowSidebarOpen(false); }, [view]);
// R5: an editor with unsaved changes asks before anything replaces it.
const [editorDirty, setEditorDirtyState] = useState(false);
const editorDirtyRef = useRef(false);
const setEditorDirty = useCallback((dirty: boolean) => {
editorDirtyRef.current = dirty;
setEditorDirtyState(dirty);
}, []);
useEffect(() => {
if (view !== "create" && view !== "edit" && view !== "group-create" && view !== "group-edit") setEditorDirty(false);
}, [view, setEditorDirty]);
const confirmDiscard = useCallback(async () => {
if (!editorDirtyRef.current) return true;
const discard = await confirmDialog({
title: t("form.discard_title"),
message: t("form.discard_message"),
confirmText: t("form.discard"),
cancelText: t("form.keep_editing"),
variant: "destructive",
});
if (discard) setEditorDirty(false);
return discard;
}, [confirmDialog, t, setEditorDirty]);
const guard = <A extends unknown[]>(action: (...args: A) => unknown) => (...args: A): void => {
if (!editorDirtyRef.current) {
action(...args);
return;
}
void confirmDiscard().then((discard) => { if (discard) action(...args); });
};
// Leaving the page or going Back asks too.
useEffect(() => {
if (!editorDirty) return;
const onBeforeUnload = (e: BeforeUnloadEvent) => e.preventDefault();
window.addEventListener("beforeunload", onBeforeUnload);
return () => window.removeEventListener("beforeunload", onBeforeUnload);
}, [editorDirty]);
const handleCancelRef = useRef<() => void>(() => {});
// Set when the app itself goes Back (to the email it came from): the
// guard's history entry is in the way then, and is stepped over.
const leavingRef = useRef(false);
const backGuard = useCloseOnBack(() => {
if (leavingRef.current) {
leavingRef.current = false;
window.history.back();
return;
}
void confirmDiscard().then((discard) => {
if (discard) handleCancelRef.current();
else backGuard.rearm();
});
}, editorDirty);
const routerBack = useCallback(() => {
if (backGuard.armed()) leavingRef.current = true;
router.back();
}, [backGuard, router]);
// Anything picked in the drawer closes it (R7).
const fromDrawer = <A extends unknown[], R>(action: (...args: A) => R) => (...args: A): R => {
setNarrowSidebarOpen(false);
return action(...args);
};
// Panel resize state - sidebar (categories)
const [sidebarWidth, setSidebarWidth] = useState(() => {
@@ -350,6 +422,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 {
@@ -398,7 +508,6 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
await createAddressBook(client, name);
await refreshAddressBooks();
toast.success(t("address_books.created"));
setCreatingAddressBook(false);
} catch (error) {
console.error('Failed to create address book:', error);
toast.error(t("address_books.create_failed"));
@@ -475,12 +584,25 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
clearSelection();
toggleContactSelection(id);
if (groups.length === 0) {
// No group to add to yet: a new one, with the contact already in it.
setNewGroupMemberIds([id]);
setView("group-create");
return;
}
setView("bulk-add-to-group");
}, [clearSelection, toggleContactSelection, groups.length]);
// After a save or a duplicate the new card is on screen, not "Select a contact".
const showNewestContact = useCallback(() => {
const newest = useContactStore.getState().contacts.at(-1);
if (!newest) {
setView("list");
return;
}
setSelectedContact(newest.id);
setView("detail");
}, [setSelectedContact]);
const handleDuplicateContact = useCallback(async (source: ContactCard) => {
const { id: _id, uid: _uid, created: _created, updated: _updated, ...rest } = source;
void _id; void _uid; void _created; void _updated;
@@ -494,15 +616,24 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
localAccountId: source.localAccountId,
}, "keep");
}
const created = supportsSync && client
? await createContact(client, data)
: await addLocalContact({
id: `local-${generateUUID()}`,
addressBookIds: data.addressBookIds || {},
...data,
});
if (created) toast.success(t("toast.created"));
}, [supportsSync, client, createContact, addLocalContact, t]);
let created: boolean;
try {
created = supportsSync && client
? await createContact(client, data)
: await addLocalContact({
id: `local-${generateUUID()}`,
addressBookIds: data.addressBookIds || {},
...data,
});
} catch (error) {
console.error("Failed to duplicate contact:", error);
toast.error(t("toast.error_create"));
return;
}
if (!created) return;
toast.success(t("toast.created"));
showNewestContact();
}, [supportsSync, client, createContact, addLocalContact, t, showNewestContact]);
const handleSaveNew = useCallback(async (data: Partial<ContactCard>, options?: ContactSaveOptions) => {
const created = supportsSync && client
@@ -515,15 +646,16 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
// A plugin stopped it: keep the form open with what was typed.
if (!created) return;
toast.success(t("toast.created"));
setEditorDirty(false);
setDefaultBookIdForCreate(undefined);
setCreatePrefill(undefined);
if (returnToEmail) {
setReturnToEmail(false);
router.back();
routerBack();
return;
}
setView("list");
}, [supportsSync, client, createContact, addLocalContact, t, returnToEmail, router]);
showNewestContact();
}, [supportsSync, client, createContact, addLocalContact, t, returnToEmail, routerBack, setEditorDirty, showNewestContact]);
const handleSaveEdit = useCallback(async (data: Partial<ContactCard>, options?: ContactSaveOptions) => {
if (!selectedContact) return;
@@ -537,12 +669,13 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
}, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]);
const handleCancel = () => {
setEditorDirty(false);
setDefaultBookIdForCreate(undefined);
// Came from email → cancel returns to the email instead of the contact list.
if (returnToEmail && view === "create") {
setCreatePrefill(undefined);
setReturnToEmail(false);
router.back();
routerBack();
return;
}
if (view === "create") setCreatePrefill(undefined);
@@ -555,6 +688,8 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
}
};
handleCancelRef.current = handleCancel;
const _handleSelectGroup = (id: string) => {
setSelectedGroupId(id);
setActiveCategory({ groupId: id });
@@ -563,6 +698,14 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
const handleCreateGroup = () => {
setSelectedGroupId(null);
setNewGroupMemberIds([]);
setView("group-create");
};
// "Add to group": a new group starting with the selected contacts.
const handleCreateGroupFromSelection = () => {
setSelectedGroupId(null);
setNewGroupMemberIds(Array.from(selectedContactIds));
setView("group-create");
};
@@ -591,6 +734,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
bcc: field === "bcc" ? recipients : [],
subject: "",
body: "",
inApp: true,
});
router.push("/");
}, [router]);
@@ -702,9 +846,10 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
} else {
await createGroup(jmapClient, name, memberIds);
toast.success(t("toast.created"));
clearSelection();
setView("list");
}
}, [view, selectedGroup, selectedGroupMembers, supportsSync, client, createGroup, updateGroup, addMembersToGroup, removeMembersFromGroup, t]);
}, [view, selectedGroup, selectedGroupMembers, supportsSync, client, createGroup, updateGroup, addMembersToGroup, removeMembersFromGroup, clearSelection, t]);
const handleRemoveGroupMember = async (memberId: string) => {
if (!selectedGroup) return;
@@ -748,7 +893,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
const handleBulkAddToGroup = () => {
if (selectedContactIds.size === 0) return;
if (groups.length === 0) {
setView("group-create");
handleCreateGroupFromSelection();
return;
}
setView("bulk-add-to-group");
@@ -762,6 +907,14 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
}
};
// The command palette's Contacts group (Ctrl/⌘+K).
useRegisterCommands(tSidebar("contacts"), [
{ id: "new-contact", label: t("create_new"), icon: UserPlus, run: () => guard(handleCreateNew)() },
{ id: "import", label: t("import.title"), icon: Upload, run: () => setShowImportDialog(true) },
{ id: "new-group", label: t("groups.create"), icon: UsersRound, run: () => guard(handleCreateGroup)() },
{ id: "new-address-book", label: t("address_books.create"), icon: BookPlus, run: () => setCreatingAddressBook(true), when: () => !!client },
], contactsEnabled);
const handleBulkAddToGroupConfirm = async (groupId: string) => {
try {
await bulkAddToGroup(
@@ -786,13 +939,13 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
<DirectoryEntryDetail
entry={directoryPrincipals.find((e) => directoryKey(e) === openDirectoryKey) ?? null}
onCompose={(entry) => handleComposeDirectory([entry])}
onAddToContacts={handleAddDirectoryEntryToContacts}
onAddToContacts={guard(handleAddDirectoryEntryToContacts)}
/>
);
}
switch (view) {
case "create":
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} defaultAddressBookId={defaultBookIdForCreate} prefill={createPrefill} onSave={handleSaveNew} onCancel={handleCancel} />;
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} defaultAddressBookId={defaultBookIdForCreate} prefill={createPrefill} onSave={handleSaveNew} onCancel={guard(handleCancel)} onDirtyChange={setEditorDirty} />;
case "edit":
if (!selectedContact) return null;
@@ -802,7 +955,8 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
addressBooks={addressBooks}
allKeywords={allKeywords}
onSave={handleSaveEdit}
onCancel={handleCancel}
onCancel={guard(handleCancel)}
onDirtyChange={setEditorDirty}
loadCurrentMedia={supportsSync && client ? () => fetchContactFromServer(client, selectedContact.id) : undefined}
/>
);
@@ -818,10 +972,10 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
onRemoveMember={handleRemoveGroupMember}
onComposeGroup={(field) => handleComposeGroupFromSidebar(selectedGroup.id, field)}
isMobile={isMobile}
onSelectMember={(id) => {
onSelectMember={guard((id: string) => {
setSelectedContact(id);
setView("detail");
}}
})}
/>
);
@@ -829,8 +983,10 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
return (
<ContactGroupForm
individuals={individuals}
currentMemberIds={newGroupMemberIds}
onSave={handleSaveGroup}
onCancel={handleCancel}
onCancel={guard(handleCancel)}
onDirtyChange={setEditorDirty}
/>
);
@@ -842,7 +998,8 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
individuals={individuals}
currentMemberIds={selectedGroupMembers.map(m => m.id)}
onSave={handleSaveGroup}
onCancel={handleCancel}
onCancel={guard(handleCancel)}
onDirtyChange={setEditorDirty}
/>
);
@@ -856,6 +1013,16 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
</p>
</div>
<div className="flex-1 overflow-y-auto divide-y divide-border">
<button
type="button"
onClick={handleCreateGroupFromSelection}
className="w-full flex items-center gap-3 px-6 py-3 text-start hover:bg-muted transition-colors"
>
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
<UsersRound className="w-4 h-4 text-primary" />
</div>
<div className="flex-1 min-w-0 text-sm font-medium">{t("bulk.new_group")}</div>
</button>
{groups.map((group) => {
const gName = getContactDisplayName(group);
const memberCount = group.members
@@ -909,6 +1076,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
? () => void handleDuplicateContact(selectedContact)
: undefined
}
onMoveTo={selectedContact && canMoveContacts ? () => handleMoveContacts([selectedContact.id]) : undefined}
isMobile={isMobile}
/>
);
@@ -919,9 +1087,9 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
return (
<div className="flex h-dvh items-center justify-center bg-background p-6">
<div className="max-w-lg text-center space-y-3">
<AlertTriangle className="w-10 h-10 text-yellow-500 mx-auto" />
<p className="text-sm font-medium">Contacts feature is disabled by your administrator</p>
<p className="text-xs text-muted-foreground">Please contact your administrator if you need access.</p>
<AlertTriangle className="w-10 h-10 text-amber-600 dark:text-amber-400 mx-auto" />
<p className="text-sm font-medium">{t("disabled_title")}</p>
<p className="text-xs text-muted-foreground">{t("disabled_description")}</p>
</div>
</div>
);
@@ -934,7 +1102,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
if (returnToEmail) {
setReturnToEmail(false);
setCreatePrefill(undefined);
router.back();
routerBack();
return;
}
setView("list");
@@ -975,7 +1143,8 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
{inlineApp && (
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} />
)}
<div className={cn("relative flex flex-1 min-h-0", inlineApp && "hidden")}>
{/* Clips the closed drawer, so it never sits over the nav rail. */}
<div className={cn("relative flex flex-1 min-h-0 overflow-hidden", inlineApp && "hidden")}>
{/* Narrow-pane backdrop for the overlay categories sidebar */}
{isNarrow && narrowSidebarOpen && (
<div
@@ -992,15 +1161,18 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
{(!isMobile || isNarrow) && (
<>
<div
data-testid={isNarrow ? "contacts-sidebar-drawer" : undefined}
className={cn(
"border-e border-border flex flex-col flex-shrink-0 bg-background",
!isSidebarResizing && "transition-[width] duration-300",
isNarrow && cn(
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
"transform transition-transform duration-300 ease-in-out",
!narrowSidebarOpen && "-translate-x-full"
"absolute inset-y-0 start-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
"transform transition-[transform,visibility] duration-300 ease-in-out",
!narrowSidebarOpen && "invisible -translate-x-full rtl:translate-x-full"
)
)}
// Closed, the drawer is out of reach: no clicks, no Tab stops.
inert={isNarrow && !narrowSidebarOpen}
style={isNarrow ? undefined : { width: `${sidebarWidth}px` }}
>
<ContactsSidebar
@@ -1008,32 +1180,32 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
individuals={individuals}
addressBooks={addressBooks}
activeCategory={activeCategory}
onSelectCategory={handleSelectCategory}
onCreateGroup={handleCreateGroup}
onCreateContact={handleCreateNew}
onCreateAddressBook={client ? () => setCreatingAddressBook(true) : undefined}
onImport={() => setShowImportDialog(true)}
onEditGroup={handleEditGroupFromSidebar}
onDeleteGroup={handleDeleteGroupFromSidebar}
onComposeGroup={handleComposeGroupFromSidebar}
onSelectCategory={guard(handleSelectCategory)}
onCreateGroup={fromDrawer(guard(handleCreateGroup))}
onCreateContact={fromDrawer(guard(handleCreateNew))}
onCreateAddressBook={client ? fromDrawer(() => setCreatingAddressBook(true)) : undefined}
onImport={fromDrawer(() => setShowImportDialog(true))}
onEditGroup={fromDrawer(guard(handleEditGroupFromSidebar))}
onDeleteGroup={fromDrawer(handleDeleteGroupFromSidebar)}
onComposeGroup={fromDrawer(handleComposeGroupFromSidebar)}
onDropContacts={handleDropContacts}
onDropContactsToCategory={handleDropContactsToCategory}
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
onShareAddressBook={client ? (book) => setSharingAddressBookId(book.id) : undefined}
onSetDefaultAddressBook={client ? async (book) => {
onRenameAddressBook={client ? fromDrawer((book: AddressBook) => setRenamingAddressBook(book)) : undefined}
onShareAddressBook={client ? fromDrawer((book: AddressBook) => setSharingAddressBookId(book.id)) : undefined}
onSetDefaultAddressBook={client ? fromDrawer(async (book: AddressBook) => {
try {
await setDefaultAddressBook(client, book);
toast.success(t("address_books.default_updated"));
} catch {
toast.error(t("address_books.set_default_failed"));
}
} : undefined}
onCreateContactInBook={(book) => {
}) : undefined}
onCreateContactInBook={fromDrawer(guard((book: AddressBook) => {
setDefaultBookIdForCreate(book.id);
setSelectedContact(null);
setView("create");
}}
onDeleteAddressBook={client ? async (book) => {
}))}
onDeleteAddressBook={client ? fromDrawer(async (book: AddressBook) => {
const ok = await confirmDialog({
title: t("address_books.delete"),
message: t("address_books.confirm_delete", { name: book.name }),
@@ -1047,8 +1219,9 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
} catch {
toast.error(t("address_books.delete_failed"));
}
} : undefined}
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
}) : undefined}
onRenameKeyword={fromDrawer((kw: string) => setRenamingKeyword(kw))}
onDeleteKeyword={fromDrawer((kw: string) => void handleDeleteKeyword(kw))}
directoryCount={showDirectory ? directoryPrincipals.length : undefined}
multiAccountMode={multiAccountEnabled && accountClients.length > 1}
/>
@@ -1071,7 +1244,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
<div
data-tour="contacts-list"
className={cn(
"border-e border-border bg-background flex flex-col flex-shrink-0",
"relative border-e border-border bg-background flex flex-col flex-shrink-0",
isMobile ? "w-full" : "",
!isListResizing && !isMobile && "transition-[width] duration-300"
)}
@@ -1111,8 +1284,8 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
selectedContactId={selectedContactId}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onSelectContact={handleSelectContact}
onCreateNew={handleCreateNew}
onSelectContact={guard(handleSelectContact)}
onCreateNew={guard(handleCreateNew)}
className="flex-1"
selectedContactIds={selectedContactIds}
onToggleSelection={toggleContactSelection}
@@ -1120,15 +1293,39 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
onSelectAll={selectAllContacts}
onClearSelection={clearSelection}
onBulkDelete={handleBulkDelete}
onBulkAddToGroup={handleBulkAddToGroup}
onBulkAddToGroup={guard(handleBulkAddToGroup)}
onBulkExport={handleBulkExport}
onEditContact={handleEditContact}
onEditContact={guard(handleEditContact)}
onDeleteContact={handleDeleteContact}
onAddContactToGroup={handleAddContactToGroup}
onAddContactToGroup={guard(handleAddContactToGroup)}
onComposeContact={handleComposeContact}
onDuplicateContact={(contact) => void handleDuplicateContact(contact)}
onMoveContacts={canMoveContacts ? handleMoveContacts : undefined}
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined}
emptyView={activeCategory !== "all"}
onImport={() => setShowImportDialog(true)}
phoneMenuItems={(close) => (
// On the phone these are otherwise only in the drawer's "+".
<>
<ContextMenuItem icon={UsersRound} label={t("groups.create")} onClick={() => { close(); guard(handleCreateGroup)(); }} />
<ContextMenuItem icon={Upload} label={t("import.title")} onClick={() => { close(); setShowImportDialog(true); }} />
</>
)}
isLoading={supportsSync && !contactsLoaded}
/>
)}
{/* The phone's create button, as in Mail and Files (R7). */}
{isMobile && activeCategory !== "directory" && selectedContactIds.size === 0 && (
<Button
size="icon-lg"
className="absolute bottom-4 end-4 z-20 rounded-full shadow-lg"
aria-label={t("create_new")}
title={t("create_new")}
onClick={guard(handleCreateNew)}
>
<Plus className="w-5 h-5" />
</Button>
)}
</div>
{!isMobile && (
@@ -1153,7 +1350,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
<Button
variant="ghost"
size="sm"
onClick={mobileBackToList}
onClick={guard(mobileBackToList)}
className="touch-manipulation"
>
<ArrowLeft className="w-4 h-4 me-2" />
@@ -1181,65 +1378,78 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
<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) => {
<PromptDialog
isOpen={renamingKeyword !== null}
onClose={() => setRenamingKeyword(null)}
title={t("rename_category")}
placeholder={t("category_name_label")}
defaultValue={renamingKeyword ?? ""}
confirmText={t("form.save")}
cancelText={t("form.cancel")}
onSubmit={(newName) => {
const keyword = renamingKeyword;
if (keyword === null || newName === keyword) return;
void (async () => {
try {
await renameKeyword(supportsSync && client ? client : null, renamingKeyword, newName);
await renameKeyword(supportsSync && client ? client : null, keyword, newName);
toast.success(t("category_renamed"));
if (typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === renamingKeyword) {
setActiveCategory({ keyword: newName.trim() });
if (typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === keyword) {
setActiveCategory({ keyword: newName });
}
setRenamingKeyword(null);
} catch (err) {
console.error("Failed to rename category:", err);
toast.error(t("category_rename_failed"));
}
}}
/>
)}
{creatingAddressBook && (
<RenameDialog
currentName=""
title={t("address_books.create")}
label={t("address_books.name_label")}
onCancel={() => setCreatingAddressBook(false)}
onConfirm={handleCreateAddressBook}
/>
)}
{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;
})();
}}
/>
<PromptDialog
isOpen={creatingAddressBook}
onClose={() => setCreatingAddressBook(false)}
title={t("address_books.create")}
placeholder={t("address_books.name_label")}
confirmText={t("address_books.create_confirm")}
cancelText={t("form.cancel")}
onSubmit={(name) => void handleCreateAddressBook(name)}
/>
<PromptDialog
isOpen={renamingAddressBook !== null}
onClose={() => setRenamingAddressBook(null)}
title={t("address_books.rename")}
placeholder={t("address_books.name_label")}
defaultValue={renamingAddressBook?.name ?? ""}
confirmText={t("form.save")}
cancelText={t("form.cancel")}
onSubmit={(newName) => {
const book = renamingAddressBook;
if (!client || !book || newName === book.name) return;
void (async () => {
try {
await renameAddressBook(client, renamingAddressBook, newName);
await renameAddressBook(client, book, 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"));
}
}}
/>
)}
})();
}}
/>
<AddressBookPickerDialog
isOpen={movingContactIds !== null}
onClose={() => setMovingContactIds(null)}
addressBooks={writableBooks}
currentId={movingFromBookId}
count={movingContactIds?.length ?? 0}
onPick={(book) => {
if (movingContactIds) void handleDropContacts(movingContactIds, book);
}}
/>
{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">
<ContactImportDialog
existingContacts={contacts}
onImport={handleImportContacts}
onClose={() => setShowImportDialog(false)}
/>
</div>
</div>
<ContactImportDialog
existingContacts={contacts}
onImport={handleImportContacts}
onClose={() => setShowImportDialog(false)}
/>
)}
{sharingAddressBookId && client && (() => {
const book = addressBooks.find((b) => b.id === sharingAddressBookId);
+216 -180
View File
@@ -1,12 +1,13 @@
"use client";
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
import { useMemo, useState, useCallback, 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 +36,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,21 +111,27 @@ 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);
const menuRef = useRef<HTMLDivElement>(null);
const menuBtnRef = useRef<HTMLButtonElement>(null);
const newMenu = useContextMenu<null>();
const toggleSection = useCallback((key: string) => {
setCollapsed(prev => {
@@ -132,21 +141,6 @@ export function ContactsSidebar({
});
}, []);
// Close dropdown on outside click
useEffect(() => {
if (!showMenu) return;
const handler = (e: MouseEvent) => {
if (
menuRef.current && !menuRef.current.contains(e.target as Node) &&
menuBtnRef.current && !menuBtnRef.current.contains(e.target as Node)
) {
setShowMenu(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [showMenu]);
const sortedGroups = useMemo(() => {
return [...groups].sort((a, b) =>
getContactDisplayName(a).localeCompare(getContactDisplayName(b))
@@ -279,54 +273,31 @@ export function ContactsSidebar({
<div className="px-3 h-14 box-content border-b border-border flex items-center justify-between flex-shrink-0">
<span className="text-sm font-semibold truncate">{t("title")}</span>
<div className="relative flex-shrink-0">
<Button
ref={menuBtnRef}
size="icon"
variant="ghost"
onClick={() => setShowMenu(v => !v)}
className="h-7 w-7"
<IconButton
label={t("new")}
aria-haspopup="menu"
aria-expanded={newMenu.contextMenu.isOpen}
onMouseDown={(e) => { if (newMenu.contextMenu.isOpen) e.stopPropagation(); }}
onClick={(e) => {
if (newMenu.contextMenu.isOpen) newMenu.closeContextMenu();
else newMenu.openContextMenuFrom(e.currentTarget, null, "end");
}}
>
<Plus className="w-4 h-4" />
</Button>
{showMenu && (
<div
ref={menuRef}
className="absolute end-0 top-full mt-1 w-44 rounded-md border border-border bg-background text-foreground shadow-md z-50 py-1"
>
<button
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start"
onClick={() => { setShowMenu(false); onCreateContact(); }}
>
<UserPlus className="w-4 h-4" />
{t("create_new")}
</button>
<button
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start"
onClick={() => { setShowMenu(false); onCreateGroup(); }}
>
<UsersRound className="w-4 h-4" />
{t("groups.create")}
</button>
{onCreateAddressBook && (
<button
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start"
onClick={() => { setShowMenu(false); onCreateAddressBook(); }}
>
<BookPlus className="w-4 h-4" />
{t("address_books.create")}
</button>
)}
{onImport && (
<button
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start"
onClick={() => { setShowMenu(false); onImport(); }}
>
<Upload className="w-4 h-4" />
{t("import.title")}
</button>
)}
</div>
)}
<Plus />
</IconButton>
<ContextMenu ref={newMenu.menuRef} isOpen={newMenu.contextMenu.isOpen} position={newMenu.contextMenu.position} onClose={newMenu.closeContextMenu}>
<ContextMenuItem icon={UserPlus} label={t("create_new")} onClick={() => { newMenu.closeContextMenu(); onCreateContact(); }} />
<ContextMenuItem icon={UsersRound} label={t("groups.create")} onClick={() => { newMenu.closeContextMenu(); onCreateGroup(); }} />
{onCreateAddressBook && (
<ContextMenuItem icon={BookPlus} label={t("address_books.create")} onClick={() => { newMenu.closeContextMenu(); onCreateAddressBook(); }} />
)}
{onImport && (
<>
<ContextMenuSeparator />
<ContextMenuItem icon={Upload} label={t("import.title")} onClick={() => { newMenu.closeContextMenu(); onImport(); }} />
</>
)}
</ContextMenu>
</div>
</div>
@@ -336,12 +307,12 @@ export function ContactsSidebar({
<button
onClick={() => onSelectCategory("all")}
className={cn(
"w-full flex items-center gap-2 px-3 text-sm transition-colors",
"w-full flex items-center gap-2 px-3 text-sm transition-colors min-h-8 pointer-coarse:min-h-11",
isAllActive
? "bg-accent text-accent-foreground font-medium"
: "text-foreground/80 hover:bg-muted"
)}
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }}
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)' }}
>
<BookUser className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{t("tabs.all")}</span>
@@ -355,12 +326,12 @@ export function ContactsSidebar({
<button
onClick={() => onSelectCategory("directory")}
className={cn(
"w-full flex items-center gap-2 px-3 text-sm transition-colors",
"w-full flex items-center gap-2 px-3 text-sm transition-colors min-h-8 pointer-coarse:min-h-11",
activeCategory === "directory"
? "bg-accent text-accent-foreground font-medium"
: "text-foreground/80 hover:bg-muted"
)}
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }}
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)' }}
data-testid="contacts-directory-entry"
>
<Building2 className="w-4 h-4 flex-shrink-0" />
@@ -413,7 +384,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 +403,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>
@@ -459,17 +430,16 @@ export function ContactsSidebar({
{t("address_books.title")}
</span>
</button>
<button
<IconButton
label={t("address_books.manage")}
onClick={(e) => {
e.stopPropagation();
try { localStorage.setItem('settings-active-tab', 'contacts'); } catch { /* ignore */ }
router.push('/settings');
router.push('/settings/contacts');
}}
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted"
title={t("address_books.manage")}
className="opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100 transition-opacity duration-150"
>
<Settings className="w-3 h-3 text-muted-foreground" />
</button>
<Settings className="text-muted-foreground" />
</IconButton>
</div>
{!collapsed.addressBooks && personalBooks.map((book) => (
<AddressBookItem
@@ -479,7 +449,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 +477,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>
@@ -553,12 +517,12 @@ export function ContactsSidebar({
<button
onClick={() => onSelectCategory("uncategorized")}
className={cn(
"w-full flex items-center gap-2 ps-5 pe-3 text-sm transition-colors",
"w-full flex items-center gap-2 ps-5 pe-3 text-sm transition-colors min-h-8 pointer-coarse:min-h-11",
activeCategory === "uncategorized"
? "bg-accent text-accent-foreground font-medium"
: "text-foreground/80 hover:bg-muted"
)}
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }}
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)' }}
>
<Tag className="w-3.5 h-3.5 flex-shrink-0 opacity-50" />
<span className="truncate italic">{t("no_category")}</span>
@@ -576,7 +540,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 })}
/>
);
})}
@@ -603,17 +569,16 @@ export function ContactsSidebar({
{t("address_books.shared_prefix", { name: group.accountName })}
</span>
</button>
<button
<IconButton
label={t("address_books.manage")}
onClick={(e) => {
e.stopPropagation();
try { localStorage.setItem('settings-active-tab', 'contacts'); } catch { /* ignore */ }
router.push('/settings');
router.push('/settings/contacts');
}}
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted"
title={t("address_books.manage")}
className="opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100 transition-opacity duration-150"
>
<Settings className="w-3 h-3 text-muted-foreground" />
</button>
<Settings className="text-muted-foreground" />
</IconButton>
</div>
{!collapsed[`shared-${group.accountId}`] && group.books.map((book) => (
<AddressBookItem
@@ -623,7 +588,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 +670,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 +751,84 @@ export function ContactsSidebar({
);
}
const ROW_STYLE = { paddingBlock: "var(--density-sidebar-py, 4px)" } 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(
// 32px rows, 44px on touch (R1).
"group w-full flex items-center ps-5 pe-1 text-sm transition-colors select-none min-h-8 pointer-coarse:min-h-11",
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 +836,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 +858,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 +874,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 +896,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 +918,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 +933,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 ?? "" }}
/>
);
}
+4 -4
View File
@@ -155,7 +155,7 @@ export function DirectoryBrowser({
{pageAllSelected ? <CheckSquare className="w-4 h-4" /> : <Square className="w-4 h-4" />}
</button>
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
ref={searchRef}
type="text"
@@ -169,7 +169,7 @@ export function DirectoryBrowser({
<button
type="button"
onClick={() => { setQuery(""); setPage(0); }}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
className="absolute end-2 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
aria-label={tContacts("clear_search")}
>
<X className="w-4 h-4" />
@@ -258,7 +258,7 @@ export function DirectoryBrowser({
className="p-1.5 rounded-md hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
aria-label={t("previous_page")}
>
<ChevronLeft className="w-4 h-4 rtl:rotate-180" />
<ChevronLeft className="w-4 h-4" />
</button>
<span className="tabular-nums">{t("page", { page: currentPage + 1, count: pageCount })}</span>
<button
@@ -268,7 +268,7 @@ export function DirectoryBrowser({
className="p-1.5 rounded-md hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
aria-label={t("next_page")}
>
<ChevronRight className="w-4 h-4 rtl:rotate-180" />
<ChevronRight className="w-4 h-4" />
</button>
</div>
)}
@@ -1,7 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { FileBrowser } from '../file-browser';
import { useFileStore, type FileResource } from '@/stores/file-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useRegisteredCommandGroups, type CommandGroup } from '@/lib/command-registry';
import { DEFAULT_FILES_SETTINGS } from '@/lib/files-settings';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { FileNode } from '@/lib/jmap/types';
@@ -132,7 +135,10 @@ function list() {
}
describe('FileBrowser', () => {
beforeEach(() => localStorage.clear());
beforeEach(() => {
localStorage.clear();
act(() => { useSettingsStore.setState({ filesSettings: DEFAULT_FILES_SETTINGS }); });
});
afterEach(() => {
act(() => { useFileStore.setState({ client: null, sharedRoots: [], allFolders: [] }); });
});
@@ -228,7 +234,7 @@ describe('FileBrowser', () => {
});
it('starts in the default view and sort from the settings', () => {
localStorage.setItem('files-settings', JSON.stringify({ defaultSortKey: 'name', defaultSortDir: 'desc' }));
act(() => { useSettingsStore.setState({ filesSettings: { ...DEFAULT_FILES_SETTINGS, defaultSortKey: 'name', defaultSortDir: 'desc' } }); });
const files = ['a.txt', 'c.txt', 'b.txt'].map((n, i) => resource(node(`f${i}`, n, null, { blobId: `b${i}`, type: 'text/plain', size: 1 })));
renderBrowser({ resources: files, allFolders: [] });
const names = within(list()).getAllByRole('row').slice(1).map((r) => r.getAttribute('data-resource'));
@@ -334,4 +340,134 @@ describe('FileBrowser', () => {
expect(within(bar).getByRole('button', { name: 'download' })).toBeInTheDocument();
expect(within(bar).getByRole('button', { name: 'delete' })).toBeInTheDocument();
});
it("takes the closed folder drawer out of reach, so it can't cover the nav rail or catch Tab", () => {
renderBrowser();
const drawer = screen.getByTestId('files-sidebar-drawer');
expect(drawer).toHaveAttribute('inert');
expect(drawer.className).toContain('invisible');
tree();
expect(drawer).not.toHaveAttribute('inert');
});
it("renames in a dialog that selects the name without its extension and closes on Escape", async () => {
renderBrowser({ props: { selectedResources: new Set(['notes.txt']) } });
fireEvent.keyDown(window, { key: 'F2' });
const dialog = screen.getByRole('dialog', { name: 'rename_title' });
const input = within(dialog).getByRole('textbox') as HTMLInputElement;
await waitFor(() => expect([input.selectionStart, input.selectionEnd]).toEqual([0, 5]));
fireEvent.keyDown(input, { key: 'Escape' });
expect(screen.queryByRole('dialog', { name: 'rename_title' })).toBeNull();
});
it("deletes the selection with Backspace, as Mail does, and goes up a folder only with Alt+Up", () => {
const onDelete = vi.fn(async () => {});
const onNavigate = vi.fn();
renderBrowser({ props: { currentPath: '/Documents', selectedResources: new Set(['notes.txt']), onDelete, onNavigate } });
fireEvent.keyDown(window, { key: 'Backspace' });
expect(onDelete).toHaveBeenCalledWith('notes.txt');
expect(onNavigate).not.toHaveBeenCalled();
fireEvent.keyDown(window, { key: 'ArrowUp', altKey: true });
expect(onNavigate).toHaveBeenCalled();
});
it("keeps the selection when Escape closes an item's menu", () => {
const onClearSelection = vi.fn();
renderBrowser({ props: { selectedResources: new Set(['notes.txt', 'Documents']), onClearSelection } });
fireEvent.contextMenu(within(list()).getByText('notes.txt'));
expect(screen.getByRole('menu')).toBeInTheDocument();
fireEvent.keyDown(document.activeElement ?? window, { key: 'Escape' });
expect(screen.queryByRole('menu')).toBeNull();
expect(onClearSelection).not.toHaveBeenCalled();
});
it("offers Restore and Delete permanently in the trash, and Empty trash in its toolbar", () => {
const onRestore = vi.fn(async () => {});
const onEmptyTrash = vi.fn();
const trashed = { ...resource(OWN[1]), trash: { deletedAt: '2026-10-20T10:00:00Z', fromParentId: null, batchId: 'b1' } };
act(() => { useFileStore.setState({ trashCount: 1 }); });
renderBrowser({ resources: [trashed], props: { currentPath: '/.Trash', onRestore, onEmptyTrash } });
act(() => { useFileStore.setState({ currentParentId: '*trash', trashCount: 1 }); });
fireEvent.click(screen.getByRole('button', { name: 'empty_trash' }));
expect(onEmptyTrash).toHaveBeenCalled();
fireEvent.contextMenu(within(list()).getByText('notes.txt'));
expect(screen.queryByText('rename')).toBeNull();
fireEvent.click(screen.getByText('restore'));
expect(onRestore).toHaveBeenCalledWith(['notes.txt']);
expect(screen.getByRole('columnheader', { name: 'deleted' })).toBeInTheDocument();
});
it("lists the trash in the sidebar", () => {
const { onNavigate } = renderBrowser();
fireEvent.click(within(tree()).getByRole('button', { name: /trash/ }));
expect(onNavigate).toHaveBeenCalledWith('/.Trash', '*trash');
});
it("moves an item to a folder picked in Move to…, without dragging", () => {
const onMoveTo = vi.fn(async () => {});
renderBrowser({ props: { onMoveTo } });
fireEvent.contextMenu(within(list()).getByText('notes.txt'));
fireEvent.click(screen.getByText('move_to'));
const dialog = screen.getByRole('dialog', { name: 'move_to_title' });
expect(within(dialog).getByRole('button', { name: 'move_here' })).toBeDisabled();
fireEvent.click(within(dialog).getByRole('treeitem', { name: /Documents/ }));
fireEvent.click(within(dialog).getByRole('button', { name: 'move_here' }));
expect(onMoveTo).toHaveBeenCalledWith(['notes.txt'], 'f-docs');
});
it("leaves a folder out of its own Move to… picker", () => {
renderBrowser();
fireEvent.contextMenu(within(list()).getByText('Documents'));
fireEvent.click(screen.getByText('move_to'));
const dialog = screen.getByRole('dialog', { name: 'move_to_title' });
expect(within(dialog).queryByRole('treeitem', { name: /Documents/ })).toBeNull();
});
it("downloads a folder as a zip named after it", () => {
const onDownloadZip = vi.fn(async () => {});
renderBrowser({ props: { onDownloadZip } });
fireEvent.contextMenu(within(list()).getByText('Documents'));
fireEvent.click(screen.getByText('download_zip'));
expect(onDownloadZip).toHaveBeenCalledWith(['f-docs'], 'Documents.zip');
});
it("adds Upload, New folder and Trash to the command palette, and focuses search with /", () => {
renderBrowser();
let groups: CommandGroup[] = [];
function Probe() {
groups = useRegisteredCommandGroups();
return null;
}
render(<Probe />);
const files = groups.find((g) => g.label === 'files');
expect(files?.commands.map((c) => c.label)).toEqual(expect.arrayContaining(['upload_files', 'new_folder', 'trash']));
fireEvent.keyDown(document.body, { key: '/' });
expect(document.activeElement).toHaveAttribute('role', 'searchbox');
});
it("lists a folder's actions in the same order in the list and in the tree", () => {
renderBrowser({ props: { onDownloadZip: vi.fn(async () => {}), onMoveNode: vi.fn(async () => {}) } });
const labels = () => within(screen.getByRole('menu')).getAllByRole('menuitem').map((i) => (i.textContent ?? '').replace(/(F2|Del|Ctrl\+[A-Z]|⌘[A-Z])$/, ''));
fireEvent.contextMenu(within(list()).getByText('Documents'));
const inList = labels();
fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' });
fireEvent.contextMenu(within(tree()).getByText('Documents'));
const inTree = labels();
const shared = inTree.filter((l) => inList.includes(l));
expect(shared).toEqual(inList.filter((l) => inTree.includes(l)));
expect(shared).toEqual(expect.arrayContaining(['open', 'star', 'rename', 'move_to', 'download_zip', 'delete']));
});
it("closes the details panel over a narrow list with Escape", async () => {
renderBrowser({ props: { selectedResources: new Set(['notes.txt']) } });
fireEvent.click(screen.getByRole('button', { name: 'details' }));
expect(screen.getByRole('button', { name: 'details' })).toHaveAttribute('aria-pressed', 'true');
fireEvent.keyDown(window, { key: 'Escape' });
expect(screen.getByRole('button', { name: 'details' })).toHaveAttribute('aria-pressed', 'false');
});
});
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
@@ -46,7 +46,12 @@ describe('FilePreviewModal HTML attachments', () => {
getFileContent={async () => ({ blob: new Blob([HTML], { type: 'text/html' }), contentType: 'text/html' })}
/>,
);
const frame = (await screen.findByTitle('invoice.html')) as HTMLIFrameElement;
// The header shows the name as its title too; the frame is the iframe.
const frame = await waitFor(() => {
const found = screen.getAllByTitle('invoice.html').find((el) => el.tagName === 'IFRAME');
if (!found) throw new Error('no frame yet');
return found as HTMLIFrameElement;
});
expect(created).toEqual([]);
expect(frame.getAttribute('src')).toBeNull();
expect(frame.getAttribute('sandbox')).toBe('');
@@ -0,0 +1,39 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useSettingsStore } from '@/stores/settings-store';
import { DEFAULT_FILES_SETTINGS } from '@/lib/files-settings';
import { getFilesSettings, migrateLegacyFilesSettings } from '../files-settings';
describe('Files settings in the synced settings', () => {
beforeEach(() => {
localStorage.clear();
useSettingsStore.getState().resetToDefaults();
useSettingsStore.setState({ explicitSettings: [] });
});
it('are part of the settings export and import', () => {
useSettingsStore.getState().updateSetting('filesSettings', { ...DEFAULT_FILES_SETTINGS, defaultViewMode: 'grid' });
const exported = JSON.parse(useSettingsStore.getState().exportSettings());
expect(exported.filesSettings.defaultViewMode).toBe('grid');
useSettingsStore.getState().resetToDefaults();
expect(useSettingsStore.getState().filesSettings).toEqual(DEFAULT_FILES_SETTINGS);
useSettingsStore.getState().importSettings(JSON.stringify({ filesSettings: { defaultViewMode: 'grid', showIcons: 'yes' } }));
expect(useSettingsStore.getState().filesSettings).toEqual({ ...DEFAULT_FILES_SETTINGS, defaultViewMode: 'grid' });
});
it('take over what an older build kept on this device, once', () => {
localStorage.setItem('files-settings', JSON.stringify({ ...DEFAULT_FILES_SETTINGS, showHiddenFiles: true }));
expect(getFilesSettings().showHiddenFiles).toBe(true);
expect(localStorage.getItem('files-settings')).toBeNull();
expect(useSettingsStore.getState().explicitSettings).toContain('filesSettings');
});
it('keep a choice already synced over an old device copy', () => {
useSettingsStore.getState().updateSetting('filesSettings', { ...DEFAULT_FILES_SETTINGS, defaultSortKey: 'size' });
localStorage.setItem('files-settings', JSON.stringify({ ...DEFAULT_FILES_SETTINGS, defaultSortKey: 'modified' }));
migrateLegacyFilesSettings();
expect(useSettingsStore.getState().filesSettings.defaultSortKey).toBe('size');
expect(localStorage.getItem('files-settings')).toBeNull();
});
});
@@ -55,4 +55,16 @@ describe('ImagePreviewModal', () => {
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).toHaveBeenCalled();
});
it('closes on Back instead of leaving Files, like the other previews', async () => {
const push = vi.spyOn(window.history, 'pushState');
const { onClose } = await renderViewer();
// The history entry is added a tick after opening.
await act(async () => { await new Promise((r) => setTimeout(r, 5)); });
expect(push).toHaveBeenCalledTimes(1);
act(() => { window.dispatchEvent(new PopStateEvent('popstate', { state: null })); });
expect(onClose).toHaveBeenCalledTimes(1);
push.mockRestore();
});
});
@@ -0,0 +1,26 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { FilePreviewModal } from '../file-preview-modal';
import { ImagePreviewModal } from '../image-preview-modal';
vi.mock('@/lib/standalone-display', () => ({ canOpenUrlInNewWindow: () => false }));
describe('one preview frame for images and other files', () => {
it('has the same header buttons in both, and ←/→ go to the next and previous file', async () => {
const onGo = vi.fn();
const neighbours = { previous: 'a.txt', next: 'c.png', onGo };
const { unmount } = render(
<FilePreviewModal name="b.txt" onClose={vi.fn()} onDownload={vi.fn()} getFileContent={() => new Promise(() => {})} neighbours={neighbours} />,
);
const textButtons = screen.getAllByRole('button').map((b) => b.getAttribute('aria-label'));
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true');
fireEvent.keyDown(window, { key: 'ArrowRight' });
expect(onGo).toHaveBeenLastCalledWith('c.png');
fireEvent.keyDown(window, { key: 'ArrowLeft' });
expect(onGo).toHaveBeenLastCalledWith('a.txt');
unmount();
render(<ImagePreviewModal name="c.png" onClose={vi.fn()} onDownload={async () => {}} getImageUrl={() => new Promise(() => {})} neighbours={neighbours} />);
expect(screen.getAllByRole('button').map((b) => b.getAttribute('aria-label'))).toEqual(textButtons);
});
});
+279 -115
View File
@@ -5,20 +5,23 @@ import { useTranslations } from "next-intl";
import {
Upload, FolderPlus, Download, Trash2, Pencil, Copy, Clipboard, Scissors, Info,
FilePlus, CopyPlus, AlertCircle, FolderUp, Share2, SquarePen, Eye, FolderOpen,
RefreshCw, Star, StarOff, Plus, CheckSquare, File,
RefreshCw, Star, StarOff, Plus, CheckSquare, File, RotateCcw, FolderInput,
} from "@/components/icons";
import { useIsDesktop, useIsMobile, useMediaQuery } from "@/hooks/use-media-query";
import { useContextMenu } from "@/hooks/use-context-menu";
import { useRegisterCommands } from "@/lib/command-registry";
import { formatShortcutCombo } from "@/lib/shortcut-keys";
import { Button } from "@/components/ui/button";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { Avatar } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
import { NewFolderDialog } from "@/components/files/new-folder-dialog";
import { RenameDialog } from "@/components/files/rename-dialog";
import { PromptDialog } from "@/components/ui/prompt-dialog";
import { FolderPickerDialog } from "@/components/files/folder-picker-dialog";
import { fileNameProblem } from "@/lib/file-name-rules";
import { EmptyFolder, NoSearchResults } from "@/components/files/file-empty-state";
import { EmptyState } from "@/components/ui/empty-state";
import { isAudioFile, isImageFile, isPdfFile, isTextFile, isVideoFile } from "@/components/files/file-icons";
import { loadFilesSettings, saveFilesSettings, type FilesSettings } from "@/components/files/files-settings";
import { useFilesSettings } from "@/components/files/files-settings";
import { FilesSidebar, type CreateKind } from "@/components/files/files-sidebar";
import { FilesHeader, type Crumb, type SortDir, type SortKey, type ViewMode } from "@/components/files/files-header";
import { FileGridView, FileListView, type FileItemHandlers } from "@/components/files/file-views";
@@ -27,6 +30,7 @@ import { INTERNAL_DRAG_TYPE, anchorEvent, isInternalDrag, readDraggedNames } fro
import { ResizeHandle } from "@/components/layout/resize-handle";
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
import { resourceToView, useFileStore, isSharedFilesPage, sharedFilePages, type FileResource } from "@/stores/file-store";
import { TRASH_LOCATION, TRASH_PATH, isTrashLocation } from "@/lib/files-trash";
import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions";
import { toast } from "@/stores/toast-store";
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
@@ -61,11 +65,18 @@ interface FileBrowserProps {
onUploadFiles: (files: File[]) => Promise<void>;
onUploadFolder: (files: File[]) => Promise<void>;
onCancelUpload: () => void;
/** Delete: into the trash, or for good when in the trash or a shared drive. */
onDelete: (name: string) => Promise<void>;
onBatchDelete: (names: string[]) => Promise<void>;
/** In the trash: put items back where they came from. */
onRestore?: (names: string[]) => Promise<void>;
/** In the trash: delete everything in it for good. */
onEmptyTrash?: () => void;
onRename: (oldName: string, newName: string) => Promise<void>;
onDownload: (name: string) => Promise<void>;
onBatchDownload: (names: string[]) => Promise<void>;
/** Download items by id as one zip, e.g. a folder. */
onDownloadZip?: (ids: string[], zipName: string) => Promise<void>;
onRefresh: () => Promise<void>;
onToggleSelect: (name: string) => void;
onClearSelection: () => void;
@@ -75,6 +86,8 @@ interface FileBrowserProps {
onPaste: () => Promise<void>;
/** Move listed items into a folder by id (null = the root). */
onMoveTo: (names: string[], targetId: string | null) => Promise<void>;
/** Move a folder of the tree, which need not be listed, into a folder. */
onMoveNode?: (id: string, targetId: string | null) => Promise<void>;
onPreviewImage: (name: string) => void;
onPreviewFile: (name: string) => void;
/** WOPI document editing (#425): whether a file can open in the document editor. */
@@ -119,6 +132,16 @@ type MenuData = { kind: "item"; resource: FileResource } | { kind: "empty" } | {
const SIDEBAR_DEFAULT_WIDTH = 256;
/** A folder's path ("/Documents/Taxes") from the folder list. */
function pathOfFolder(folders: FileResource[], folder: FileResource): string {
const names = [folder.name];
const byId = new Map(folders.map((f) => [f.id, f]));
for (let cur = folder.parentId ? byId.get(folder.parentId) : undefined; cur && names.length < 64; cur = cur.parentId ? byId.get(cur.parentId) : undefined) {
names.unshift(cur.name);
}
return "/" + names.join("/");
}
function isTypingTarget(target: EventTarget | null): boolean {
const el = target as HTMLElement | null;
if (!el) return false;
@@ -140,9 +163,12 @@ export function FileBrowser({
onCancelUpload,
onDelete,
onBatchDelete,
onRestore,
onEmptyTrash,
onRename,
onDownload,
onBatchDownload,
onDownloadZip,
onRefresh,
onToggleSelect,
onClearSelection,
@@ -151,6 +177,7 @@ export function FileBrowser({
onCopy,
onPaste,
onMoveTo,
onMoveNode,
onPreviewImage,
onPreviewFile,
isOfficeEditable,
@@ -197,37 +224,26 @@ export function FileBrowser({
const hasListed = useFileStore((s) => s.hasListed);
const currentParentId = useFileStore((s) => s.currentParentId);
const pathStack = useFileStore((s) => s.pathStack);
const trashCount = useFileStore((s) => s.trashCount);
// The trash lists deleted items: they can be restored, previewed or
// deleted for good, nothing else.
const inTrash = isTrashLocation(currentParentId);
useEffect(() => {
if (storeClient) void loadSharedRoots();
}, [storeClient, loadSharedRoots]);
// ---- Settings --------------------------------------------------------------
const [settings, setSettings] = useState<FilesSettings>(loadFilesSettings);
const [settings, updateSettings] = useFilesSettings();
const [sortKey, setSortKey] = useState<SortKey>(() => settings.defaultSortKey);
const [sortDir, setSortDir] = useState<SortDir>(() => settings.defaultSortDir);
// A new default order (Settings > Files, another device) applies at once.
useEffect(() => {
const reload = () => {
const next = loadFilesSettings();
setSettings(next);
setSortKey(next.defaultSortKey);
setSortDir(next.defaultSortDir);
};
const handleStorage = (e: StorageEvent) => { if (e.key === "files-settings") reload(); };
// StorageEvent fires cross-tab, the custom event same-tab
window.addEventListener("storage", handleStorage);
window.addEventListener("files-settings-changed", reload);
return () => {
window.removeEventListener("storage", handleStorage);
window.removeEventListener("files-settings-changed", reload);
};
}, []);
setSortKey(settings.defaultSortKey);
setSortDir(settings.defaultSortDir);
}, [settings.defaultSortKey, settings.defaultSortDir]);
const viewMode: ViewMode = settings.defaultViewMode;
const handleViewModeChange = useCallback((mode: ViewMode) => {
// The toggle and Settings > Files edit the same preference.
const next = { ...loadFilesSettings(), defaultViewMode: mode };
setSettings(next);
saveFilesSettings(next);
}, []);
// The toggle and Settings > Files edit the same preference.
const handleViewModeChange = useCallback((mode: ViewMode) => updateSettings({ defaultViewMode: mode }), [updateSettings]);
const iconOptions = useMemo(() => ({
showIcons: settings.showIcons,
coloredIcons: settings.coloredIcons,
@@ -242,6 +258,11 @@ export function FileBrowser({
const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false);
useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]);
useEffect(() => { setNarrowSidebarOpen(false); }, [currentPath]);
// Anything picked in the drawer closes it (R7).
const fromDrawer = <A extends unknown[], R>(action: (...args: A) => R) => (...args: A): R => {
setNarrowSidebarOpen(false);
return action(...args);
};
const [sidebarWidth, setSidebarWidth] = useState(() => {
if (typeof window !== "undefined") {
const saved = Number(localStorage.getItem("files-sidebar-width"));
@@ -268,6 +289,8 @@ export function FileBrowser({
const [renameTarget, setRenameTarget] = useState<string | null>(null);
const [renameFolder, setRenameFolder] = useState<FileResource | null>(null);
const [shareTargetId, setShareTargetId] = useState<string | null>(null);
// "Move to…": listed items by name, or a folder of the tree by id.
const [moveTarget, setMoveTarget] = useState<{ names: string[] } | { folder: FileResource } | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [focusedName, setFocusedName] = useState<string | null>(null);
// The cursor outline follows a click or the arrow keys, but goes away once
@@ -342,12 +365,15 @@ export function FileBrowser({
// Whether the user may add items to the folder on screen. Nothing can be
// put on a page listing files shared on their own.
const currentFolder = currentParentId ? allFolders.find((f) => f.id === currentParentId) : null;
const canCreate = !accountPickerMode && !isSharedFilesPage(currentParentId)
const canCreate = !accountPickerMode && !isSharedFilesPage(currentParentId) && !inTrash
&& (currentFolder?.myRights?.mayAddChildren ?? true);
// ---- Listing ---------------------------------------------------------------
const displayResources = useMemo(() => {
let list = resources;
// In the trash the date column is when an item was deleted.
let list = inTrash
? resources.map((r) => (r.trash?.deletedAt ? { ...r, lastModified: r.trash.deletedAt } : r))
: resources;
if (!settings.showHiddenFiles) list = list.filter((r) => !r.name.startsWith("."));
if (searchQuery) {
const q = searchQuery.toLowerCase();
@@ -371,7 +397,7 @@ export function FileBrowser({
if (cmp === 0) cmp = a.name.localeCompare(b.name);
return sortDir === "asc" ? cmp : -cmp;
});
}, [resources, settings.showHiddenFiles, searchQuery, sortKey, sortDir]);
}, [resources, inTrash, settings.showHiddenFiles, searchQuery, sortKey, sortDir]);
// Folders other principals shared with the user, listed at the root next to
// the user's own (#1181).
@@ -403,12 +429,15 @@ export function FileBrowser({
// Inside a folder someone shared with the user, the trail starts there.
const insideShared = !!pathStack[1]?.id?.includes(":");
const list: Crumb[] = [{ id: null, name: insideShared ? t("shared_with_me") : t("my_files"), path: "/" }];
if (inTrash) return [...list, { id: TRASH_LOCATION, name: t("trash"), path: TRASH_PATH }];
segments.forEach((name, i) => {
list.push({ id: pathStack[i + 1]?.id ?? null, name, path: "/" + segments.slice(0, i + 1).join("/") });
});
return list;
}, [currentPath, pathStack, t]);
}, [currentPath, pathStack, inTrash, t]);
const folderName = crumbs[crumbs.length - 1].name;
const folderNameRef = useRef(folderName);
folderNameRef.current = folderName;
const navigateCrumb = useCallback((crumb: Crumb) => {
if (crumb.path === "/") {
@@ -434,6 +463,8 @@ export function FileBrowser({
const openResource = useCallback((resource: FileResource) => {
// A symlink has nothing to open (Stalwart 1.0).
if (resource.isSymlink) return;
// A deleted folder is restored before it is opened; a deleted file can be looked at.
if (resource.trash && (resource.isDirectory || !isPreviewable(resource.name))) return;
if (resource.isDirectory) {
onNavigate(pathOf(resource), resource.id);
} else if (isOfficeEditable?.(resource.name) && onEditFile) {
@@ -452,6 +483,15 @@ export function FileBrowser({
else onPreviewFile(resource.name);
}, [onPreviewImage, onPreviewFile]);
// Where a deleted item came from, as its row's tooltip in the trash.
const trashNote = useCallback((r: FileResource): string | undefined => {
if (!r.trash) return undefined;
const from = r.trash.fromParentId;
const folder = from ? allFolders.find((f) => f.id === from) : null;
const path = from === null ? t("my_files") : folder ? pathOfFolder(allFolders, folder) : null;
return path ? t("deleted_from", { folder: path }) : undefined;
}, [allFolders, t]);
// ---- Selection -------------------------------------------------------------
const selectRange = useCallback((from: string, to: string, additive: boolean) => {
const names = displayResources.map((r) => r.name);
@@ -499,10 +539,17 @@ export function FileBrowser({
}, [onDelete, onBatchDelete]);
const downloadNames = useCallback((names: string[]) => {
const items = names.map((n) => resources.find((r) => r.name === n)).filter((r): r is FileResource => !!r && !r.isSymlink);
// Folders come as one zip, with any files picked next to them.
if (onDownloadZip && items.some((r) => r.isDirectory)) {
const zipName = items.length === 1 ? items[0].name : folderNameRef.current;
void onDownloadZip(items.map((r) => r.id), `${zipName}.zip`);
return;
}
const files = names.filter(hasContent);
if (files.length === 1) void onDownload(files[0]);
else if (files.length > 1) void onBatchDownload(files);
}, [hasContent, onDownload, onBatchDownload]);
}, [resources, hasContent, onDownload, onBatchDownload, onDownloadZip]);
// ---- Drag and drop ---------------------------------------------------------
const moveNames = useCallback((names: string[], targetId: string | null) => {
@@ -510,7 +557,7 @@ export function FileBrowser({
}, [onMoveTo]);
const itemDragProps = useCallback((resource: FileResource) => ({
draggable: !touch,
draggable: !touch && !inTrash,
onDragStart: (e: React.DragEvent) => {
const names = selectedResources.has(resource.name) ? [...selectedResources] : [resource.name];
e.dataTransfer.setData(INTERNAL_DRAG_TYPE, JSON.stringify(names));
@@ -532,7 +579,7 @@ export function FileBrowser({
const names = readDraggedNames(e).filter((n) => n !== resource.name);
if (names.length > 0) moveNames(names, resource.id);
},
}), [touch, selectedResources, moveNames]);
}), [touch, inTrash, selectedResources, moveNames]);
const runUpload = useCallback(async (files: File[], asFolder: boolean) => {
if (files.length === 0) return;
@@ -662,14 +709,15 @@ export function FileBrowser({
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (isTypingTarget(e.target)) return;
if (showNewFolder || showNewTextFile || renameTarget || renameFolder || shareTargetId) return;
if (showNewFolder || showNewTextFile || renameTarget || renameFolder || shareTargetId || moveTarget) return;
// A preview, editor or confirmation is open on top of the list.
if (document.querySelector('[role="dialog"], [role="alertdialog"]')) return;
if (accountPickerMode) return;
const mod = e.ctrlKey || e.metaKey;
const selected = selectedList.map((r) => r.name);
if (mod && e.key.toLowerCase() === "f") {
// "/" as in Mail and Contacts; Ctrl/⌘+F as in a file manager.
if ((mod && e.key.toLowerCase() === "f") || (e.key === "/" && !mod && !e.altKey)) {
e.preventDefault();
searchInputRef.current?.focus();
return;
@@ -679,6 +727,7 @@ export function FileBrowser({
selectAllVisible();
return;
}
if (inTrash && (mod || e.key === "F2") && ["c", "x", "v", "f2"].includes(e.key.toLowerCase())) return;
if (mod && e.key.toLowerCase() === "c" && selected.length > 0) {
if (window.getSelection()?.toString()) return;
e.preventDefault();
@@ -696,11 +745,14 @@ export function FileBrowser({
return;
}
if (e.key === "Escape") {
if (searchQuery) setSearchQuery("");
// The details panel over the list closes first (innermost layer, R8).
if (showDetails && isNarrow) toggleDetails(false);
else if (searchQuery) setSearchQuery("");
else if (selected.length > 0) onClearSelection();
return;
}
if (e.key === "Delete" && selected.length > 0) {
// Backspace deletes too, as in Mail (⌘⌫ on a Mac); Alt+↑ goes up.
if ((e.key === "Delete" || e.key === "Backspace") && selected.length > 0) {
e.preventDefault();
deleteNames(selected);
return;
@@ -720,7 +772,7 @@ export function FileBrowser({
onToggleSelect(focusedName);
return;
}
if ((e.key === "Backspace" || (e.altKey && e.key === "ArrowUp")) && currentPath !== "/") {
if (e.altKey && e.key === "ArrowUp" && currentPath !== "/") {
e.preventDefault();
navigateUp();
return;
@@ -745,7 +797,8 @@ export function FileBrowser({
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [
showNewFolder, showNewTextFile, renameTarget, renameFolder, shareTargetId, accountPickerMode, selectedList, singleSelected,
showNewFolder, showNewTextFile, renameTarget, renameFolder, shareTargetId, moveTarget, accountPickerMode, selectedList, singleSelected, inTrash,
showDetails, isNarrow, toggleDetails,
selectAllVisible, onCopy, onCut, onPaste, clipboard, searchQuery, onClearSelection, deleteNames, openResource,
focusedName, onToggleSelect, currentPath, navigateUp, viewMode, gridColumns, moveFocus,
]);
@@ -773,18 +826,48 @@ export function FileBrowser({
? selectedList
: menuData?.kind === "item" ? [menuData.resource] : [];
// Items move within the own drive; a shared drive's folders are not in the picker.
const canMove = !inTrash && !accountPickerMode && !currentParentId?.includes(":");
const renderItemMenu = (resource: FileResource) => {
if (inTrash) {
const names = menuSelection.map((r) => r.name);
const one = menuSelection.length === 1 ? menuSelection[0] : null;
return (
<>
{onRestore && (
<ContextMenuItem icon={RotateCcw} label={t("restore")} onClick={() => { closeMenu(); void onRestore(names); }} />
)}
{one && !one.isDirectory && !one.isSymlink && isPreviewable(one.name) && (
<ContextMenuItem icon={Eye} label={t("preview")} onClick={() => { closeMenu(); previewResource(one); }} />
)}
{one && !one.isDirectory && !one.isSymlink && (
<ContextMenuItem icon={Download} label={t("download")} onClick={() => { closeMenu(); void onDownload(one.name); }} />
)}
<ContextMenuSeparator />
<ContextMenuItem icon={Trash2} label={t("delete_forever")} destructive onClick={() => { closeMenu(); deleteNames(names); }} />
</>
);
}
if (menuSelection.length > 1) {
const names = menuSelection.map((r) => r.name);
const fileCount = menuSelection.filter((r) => !r.isDirectory && !r.isSymlink).length;
const withFolders = !!onDownloadZip && menuSelection.some((r) => r.isDirectory);
return (
<>
{fileCount > 0 && (
<ContextMenuItem icon={Download} label={t("download_count", { count: fileCount })} onClick={() => { closeMenu(); downloadNames(names); }} />
{(fileCount > 0 || withFolders) && (
<ContextMenuItem
icon={Download}
label={withFolders ? t("download_zip") : t("download_count", { count: fileCount })}
onClick={() => { closeMenu(); downloadNames(names); }}
/>
)}
<ContextMenuItem icon={Scissors} label={t("cut")} shortcut="Ctrl+X" onClick={() => { closeMenu(); onCut(names); }} />
{canMove && (
<ContextMenuItem icon={FolderInput} label={t("move_to")} onClick={() => { closeMenu(); setMoveTarget({ names }); }} />
)}
<ContextMenuItem icon={Scissors} label={t("cut")} shortcut={formatShortcutCombo("Mod + X")} onClick={() => { closeMenu(); onCut(names); }} />
{menuSelection.some((r) => !r.isSymlink) && (
<ContextMenuItem icon={Copy} label={t("copy")} shortcut="Ctrl+C" onClick={() => { closeMenu(); onCopy(names); }} />
<ContextMenuItem icon={Copy} label={t("copy")} shortcut={formatShortcutCombo("Mod + C")} onClick={() => { closeMenu(); onCopy(names); }} />
)}
<ContextMenuSeparator />
<ContextMenuItem icon={Trash2} label={t("delete_count", { count: names.length })} destructive onClick={() => { closeMenu(); deleteNames(names); }} />
@@ -796,17 +879,18 @@ export function FileBrowser({
const starred = starredPaths.has(path);
return (
<>
{/* R6 order, the same as the folder tree's menu: Open, Edit, Share,
Star, Rename, Duplicate, Move to…, Cut, Copy, Download, Details, Delete. */}
{resource.isDirectory ? (
<ContextMenuItem icon={FolderOpen} label={t("open")} onClick={() => { closeMenu(); openResource(resource); }} />
) : resource.isSymlink ? null : (
<>
{isOfficeEditable?.(resource.name) && onEditFile && (
<ContextMenuItem icon={SquarePen} label={t("office_edit")} onClick={() => { closeMenu(); onEditFile(resource.name); }} />
)}
{isPreviewable(resource.name) && (
<ContextMenuItem icon={Eye} label={t("preview")} onClick={() => { closeMenu(); previewResource(resource); }} />
)}
<ContextMenuItem icon={Download} label={t("download")} onClick={() => { closeMenu(); void onDownload(resource.name); }} />
{isOfficeEditable?.(resource.name) && onEditFile && (
<ContextMenuItem icon={SquarePen} label={t("office_edit")} onClick={() => { closeMenu(); onEditFile(resource.name); }} />
)}
</>
)}
{(canShare(resource) || (resource.isDirectory && !resource.isShared)) && <ContextMenuSeparator />}
@@ -825,9 +909,18 @@ export function FileBrowser({
{!resource.isDirectory && !resource.isSymlink && (
<ContextMenuItem icon={CopyPlus} label={t("duplicate")} onClick={() => { closeMenu(); void onDuplicate(resource.name); }} />
)}
<ContextMenuItem icon={Scissors} label={t("cut")} shortcut="Ctrl+X" onClick={() => { closeMenu(); onCut([resource.name]); }} />
{canMove && (
<ContextMenuItem icon={FolderInput} label={t("move_to")} onClick={() => { closeMenu(); setMoveTarget({ names: [resource.name] }); }} />
)}
<ContextMenuItem icon={Scissors} label={t("cut")} shortcut={formatShortcutCombo("Mod + X")} onClick={() => { closeMenu(); onCut([resource.name]); }} />
{!resource.isSymlink && (
<ContextMenuItem icon={Copy} label={t("copy")} shortcut="Ctrl+C" onClick={() => { closeMenu(); onCopy([resource.name]); }} />
<ContextMenuItem icon={Copy} label={t("copy")} shortcut={formatShortcutCombo("Mod + C")} onClick={() => { closeMenu(); onCopy([resource.name]); }} />
)}
{resource.isDirectory && onDownloadZip && (
<ContextMenuItem icon={Download} label={t("download_zip")} onClick={() => { closeMenu(); downloadNames([resource.name]); }} />
)}
{!resource.isDirectory && !resource.isSymlink && (
<ContextMenuItem icon={Download} label={t("download")} onClick={() => { closeMenu(); void onDownload(resource.name); }} />
)}
<ContextMenuItem icon={Info} label={t("details")} onClick={() => { closeMenu(); toggleDetails(true); }} />
<ContextMenuSeparator />
@@ -879,6 +972,18 @@ export function FileBrowser({
else folderInputRef.current?.click();
}, [currentParentId, onNavigate]);
// ---- Command palette ---------------------------------------------------------
const tSidebar = useTranslations("sidebar");
useRegisterCommands(tSidebar("files"), [
{ id: "upload-files", label: t("upload_files"), icon: Upload, run: () => fileInputRef.current?.click(), when: () => canCreate },
{ id: "upload-folder", label: t("upload_folder"), icon: FolderUp, run: () => folderInputRef.current?.click(), when: () => canCreate },
{ id: "new-folder", label: t("new_folder"), icon: FolderPlus, run: () => setShowNewFolder(true), when: () => canCreate },
{ id: "new-text-file", label: t("new_text_file"), icon: FilePlus, run: () => setShowNewTextFile(true), when: () => canCreate },
{ id: "trash", label: t("trash"), icon: Trash2, run: () => onNavigate(TRASH_PATH, TRASH_LOCATION), when: () => !inTrash && !accountPickerMode },
{ id: "empty-trash", label: t("empty_trash"), icon: Trash2, run: () => onEmptyTrash?.(), when: () => inTrash && trashCount > 0 && !!onEmptyTrash },
{ id: "refresh", label: t("refresh"), icon: RefreshCw, run: () => void onRefresh() },
]);
// ---- Rendering -------------------------------------------------------------
const sidebar = !accountPickerMode && (
<FilesSidebar
@@ -891,20 +996,25 @@ export function FileBrowser({
canCreate={canCreate}
onNavigate={onNavigate}
onOpenRecent={onOpenRecent}
onUploadFiles={() => fileInputRef.current?.click()}
onUploadFolder={() => folderInputRef.current?.click()}
onNewFolder={() => setShowNewFolder(true)}
onNewTextFile={() => setShowNewTextFile(true)}
onUploadFiles={fromDrawer(() => fileInputRef.current?.click())}
onUploadFolder={fromDrawer(() => folderInputRef.current?.click())}
onNewFolder={fromDrawer(() => setShowNewFolder(true))}
onNewTextFile={fromDrawer(() => setShowNewTextFile(true))}
onToggleFavorite={onToggleFavorite}
onDropItems={moveNames}
canShare={canShare}
onShare={(r) => setShareTargetId(r.id)}
onCreateIn={createIn}
onRenameFolder={setRenameFolder}
onDeleteFolder={(folder) => void onDeleteNode(folder.id, folder.name)}
onRevealRecent={(id) => void onRevealNode(id)}
onDownloadRecent={(id) => void onDownloadNode(id)}
onShare={fromDrawer((r: FileResource) => setShareTargetId(r.id))}
onCreateIn={fromDrawer(createIn)}
onRenameFolder={fromDrawer(setRenameFolder)}
onDownloadFolder={onDownloadZip ? (folder: FileResource) => void onDownloadZip([folder.id], `${folder.name}.zip`) : undefined}
onMoveFolder={onMoveNode ? fromDrawer((folder: FileResource) => setMoveTarget({ folder })) : undefined}
onDeleteFolder={fromDrawer((folder: FileResource) => void onDeleteNode(folder.id, folder.name))}
onRevealRecent={fromDrawer((id: string) => void onRevealNode(id))}
onDownloadRecent={fromDrawer((id: string) => void onDownloadNode(id))}
onRemoveRecent={onRemoveRecent}
trashCount={trashCount}
onOpenTrash={fromDrawer(() => onNavigate(TRASH_PATH, TRASH_LOCATION))}
onDropToTrash={(names) => deleteNames(names)}
/>
);
@@ -943,6 +1053,9 @@ export function FileBrowser({
// Until the first listing is in, an empty list means "not loaded yet".
if ((isLoading || !hasListed) && resources.length === 0) return <ListSkeleton />;
if (showEmpty && searchQuery) return <NoSearchResults query={searchQuery} onClear={() => setSearchQuery("")} />;
if (showEmpty && inTrash) {
return <EmptyState icon={Trash2} title={t("trash_empty_title")} description={t("trash_empty_hint")} />;
}
if (showEmpty) {
return (
<EmptyFolder
@@ -965,6 +1078,7 @@ export function FileBrowser({
iconOptions,
getImageUrl,
handlers,
itemNote: inTrash ? trashNote : undefined,
};
return (
<>
@@ -979,6 +1093,7 @@ export function FileBrowser({
allSelected={allSelected}
someSelected={someSelected}
onToggleAll={() => (allSelected ? onClearSelection() : selectAllVisible())}
dateLabel={inTrash ? t("deleted") : undefined}
/>
))}
{(sharedAtRoot.length > 0 || sharedPagesAtRoot.length > 0) && (
@@ -1020,9 +1135,27 @@ export function FileBrowser({
/>
);
const selectionActions = selectedList.length > 0 ? {
const selectionActions = selectedList.length > 0 && inTrash ? {
count: selectedList.length,
fileCount: selectedList.filter((r) => !r.isDirectory && !r.isSymlink).length,
fileCount: 0,
canOpen: false,
canRename: false,
canShare: false,
trash: true,
onRestore: onRestore ? () => void onRestore(selectedList.map((r) => r.name)) : undefined,
onOpen: () => {},
onDownload: () => {},
onShare: () => {},
onRename: () => {},
onCut: () => {},
onCopy: () => {},
onDelete: () => deleteNames(selectedList.map((r) => r.name)),
onClear: onClearSelection,
pluginFiles: [],
} : selectedList.length > 0 ? {
count: selectedList.length,
// Folders download as a zip.
fileCount: selectedList.filter((r) => !r.isSymlink && (!r.isDirectory || !!onDownloadZip)).length,
canOpen: !!singleSelected && !singleSelected.isSymlink
&& (singleSelected.isDirectory || isPreviewable(singleSelected.name) || !!isOfficeEditable?.(singleSelected.name)),
canRename: !!singleSelected,
@@ -1031,6 +1164,7 @@ export function FileBrowser({
onDownload: () => downloadNames(selectedList.map((r) => r.name)),
onShare: () => singleSelected && setShareTargetId(singleSelected.id),
onRename: () => singleSelected && setRenameTarget(singleSelected.name),
onMoveTo: canMove ? () => setMoveTarget({ names: selectedList.map((r) => r.name) }) : undefined,
onCut: () => onCut(selectedList.map((r) => r.name)),
onCopy: () => onCopy(selectedList.map((r) => r.name)),
onDelete: () => deleteNames(selectedList.map((r) => r.name)),
@@ -1040,19 +1174,22 @@ export function FileBrowser({
} : null;
return (
<div className="relative flex flex-1 min-h-0">
// Clips the closed drawer, so it never sits over the nav rail.
<div className="relative flex flex-1 min-h-0 overflow-hidden">
{/* Folder sidebar: in flow on desktop, a drawer on narrow panes */}
{sidebar && isNarrow && narrowSidebarOpen && (
<div className="absolute inset-0 bg-black/50 z-40" onClick={() => setNarrowSidebarOpen(false)} />
)}
{sidebar && (isNarrow ? (
<div
data-testid="files-sidebar-drawer"
className={cn(
"absolute inset-y-0 start-0 z-50 w-72 border-e border-border",
"transform transition-transform duration-300 ease-in-out",
!narrowSidebarOpen && "-translate-x-full rtl:translate-x-full",
"transform transition-[transform,visibility] duration-300 ease-in-out",
!narrowSidebarOpen && "invisible -translate-x-full rtl:translate-x-full",
)}
aria-hidden={!narrowSidebarOpen}
// Closed, the drawer is out of reach: no clicks, no Tab stops.
inert={!narrowSidebarOpen}
>
{sidebar}
</div>
@@ -1102,13 +1239,14 @@ export function FileBrowser({
compact={isMobile}
toolsHidden={accountPickerMode}
selection={selectionActions}
emptyTrash={inTrash && onEmptyTrash ? { onEmpty: onEmptyTrash, disabled: trashCount === 0 } : undefined}
/>
{error && (
<div role="alert" className="mx-4 mt-3 px-3 py-2 rounded-md bg-destructive/10 text-destructive text-sm flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
<span className="flex-1">{error}</span>
<Button variant="ghost" size="sm" className="h-7 text-destructive hover:text-destructive shrink-0" onClick={() => void onRefresh()}>
<Button variant="ghost" size="xs" className="text-destructive hover:text-destructive shrink-0" onClick={() => void onRefresh()}>
<RefreshCw className="w-3.5 h-3.5 me-1" />
{t("retry")}
</Button>
@@ -1159,12 +1297,16 @@ export function FileBrowser({
</div>
)}
{/* Over the list on a narrow pane: a backdrop that closes it, as for the drawer. */}
{detailsPanel && isNarrow && (
<div className="absolute inset-0 z-20 bg-black/30" aria-hidden onClick={() => toggleDetails(false)} />
)}
{detailsPanel}
{isNarrow && canCreate && !selectionActions && (
<Button
size="icon"
className="absolute bottom-4 end-4 z-20 h-12 w-12 rounded-full shadow-lg"
size="icon-lg"
className="absolute bottom-4 end-4 z-20 rounded-full shadow-lg"
aria-label={t("new")}
onClick={(e) => menu.openContextMenu(anchorEvent(e.currentTarget, "end"), { kind: "new" })}
>
@@ -1193,66 +1335,88 @@ export function FileBrowser({
{clipboard && canCreate && (
<>
<ContextMenuSeparator />
<ContextMenuItem icon={Clipboard} label={t("paste")} shortcut="Ctrl+V" onClick={() => { closeMenu(); void onPaste(); }} />
<ContextMenuItem icon={Clipboard} label={t("paste")} shortcut={formatShortcutCombo("Mod + V")} onClick={() => { closeMenu(); void onPaste(); }} />
</>
)}
<ContextMenuSeparator />
{displayResources.length > 0 && (
<ContextMenuItem icon={CheckSquare} label={t("select_all")} shortcut="Ctrl+A" onClick={() => { closeMenu(); selectAllVisible(); }} />
<ContextMenuItem icon={CheckSquare} label={t("select_all")} shortcut={formatShortcutCombo("Mod + A")} onClick={() => { closeMenu(); selectAllVisible(); }} />
)}
<ContextMenuItem icon={RefreshCw} label={t("refresh")} onClick={() => { closeMenu(); void onRefresh(); }} />
</>
)}
</ContextMenu>
{showNewFolder && (
<NewFolderDialog
onConfirm={async (name: string) => {
await onCreateFolder(name);
setShowNewFolder(false);
}}
onCancel={() => setShowNewFolder(false)}
validate={validateFileName}
/>
)}
<PromptDialog
isOpen={showNewFolder}
onClose={() => setShowNewFolder(false)}
title={t("new_folder")}
placeholder={t("new_folder_name")}
confirmText={t("create")}
cancelText={t("cancel")}
validate={validateFileName}
onSubmit={(name) => void onCreateFolder(name)}
/>
{showNewTextFile && (
<RenameDialog
currentName="new-file.txt"
title={t("new_text_file")}
label={t("file_name")}
onConfirm={async (name: string) => {
await onCreateTextFile(name);
setShowNewTextFile(false);
}}
onCancel={() => setShowNewTextFile(false)}
validate={validateFileName}
/>
)}
<PromptDialog
isOpen={showNewTextFile}
onClose={() => setShowNewTextFile(false)}
title={t("new_text_file")}
placeholder={t("file_name")}
defaultValue="new-file.txt"
selectBaseName
confirmText={t("create")}
cancelText={t("cancel")}
validate={validateFileName}
onSubmit={(name) => void onCreateTextFile(name)}
/>
{renameTarget && (
<RenameDialog
currentName={renameTarget}
onConfirm={async (newName: string) => {
await onRename(renameTarget, newName);
setRenameTarget(null);
}}
onCancel={() => setRenameTarget(null)}
validate={validateFileName}
/>
)}
<PromptDialog
isOpen={renameTarget !== null}
onClose={() => setRenameTarget(null)}
title={t("rename_title")}
placeholder={t("new_name")}
defaultValue={renameTarget ?? ""}
// A file keeps its extension unless the user means to change it.
selectBaseName={!resources.find((r) => r.name === renameTarget)?.isDirectory}
confirmText={t("save")}
cancelText={t("cancel")}
validate={validateFileName}
onSubmit={(newName) => {
if (renameTarget !== null && newName !== renameTarget) void onRename(renameTarget, newName);
}}
/>
{renameFolder && (
<RenameDialog
currentName={renameFolder.name}
onConfirm={async (newName: string) => {
await onRenameNode(renameFolder.id, newName);
setRenameFolder(null);
}}
onCancel={() => setRenameFolder(null)}
validate={validateFileName}
/>
)}
<PromptDialog
isOpen={renameFolder !== null}
onClose={() => setRenameFolder(null)}
title={t("rename_title")}
placeholder={t("new_name")}
defaultValue={renameFolder?.name ?? ""}
confirmText={t("save")}
cancelText={t("cancel")}
validate={validateFileName}
onSubmit={(newName) => {
if (renameFolder && newName !== renameFolder.name) void onRenameNode(renameFolder.id, newName);
}}
/>
<FolderPickerDialog
isOpen={moveTarget !== null}
onClose={() => setMoveTarget(null)}
title={t("move_to_title")}
confirmText={t("move_here")}
folders={allFolders.filter((f) => !f.isShared && !f.id.includes(":"))}
excludeIds={new Set(moveTarget && "folder" in moveTarget
? [moveTarget.folder.id]
: (moveTarget?.names ?? []).map((n) => resources.find((r) => r.name === n)).filter((r) => r?.isDirectory).map((r) => r!.id))}
currentId={moveTarget && "folder" in moveTarget ? moveTarget.folder.parentId ?? null : currentParentId}
onPick={(targetId) => {
if (!moveTarget) return;
if ("folder" in moveTarget) void onMoveNode?.(moveTarget.folder.id, targetId);
else void onMoveTo(moveTarget.names, targetId);
}}
/>
{shareTarget && client && onShare && (
<ShareCollectionDialog
+1 -1
View File
@@ -82,7 +82,7 @@ export function FileDetailsPanel({
<div className="aspect-[4/3] rounded-lg bg-muted/50 flex items-center justify-center">
{selection.length > 1
? <Files className="w-14 h-14 text-muted-foreground" />
: <FolderOpen className="w-14 h-14 text-blue-500" />}
: <FolderOpen className="w-14 h-14 text-primary" />}
</div>
<div>
<p className="font-medium break-words">
+25 -28
View File
@@ -3,6 +3,7 @@
import { useTranslations } from "next-intl";
import { Upload, FolderPlus, FolderOpen, SearchX } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
interface EmptyFolderProps {
isRoot: boolean;
@@ -15,39 +16,35 @@ interface EmptyFolderProps {
export function EmptyFolder({ isRoot, canCreate, onUpload, onCreateFolder }: EmptyFolderProps) {
const t = useTranslations("files");
return (
<div className="flex items-center justify-center h-full min-h-[18rem] p-8">
<div className="flex flex-col items-center text-center max-w-sm">
<div className="w-14 h-14 rounded-full bg-muted flex items-center justify-center mb-4">
{isRoot ? <Upload className="w-7 h-7 text-muted-foreground" /> : <FolderOpen className="w-7 h-7 text-muted-foreground" />}
</div>
<h3 className="text-base font-medium">{isRoot ? t("empty_state_title") : t("empty_folder_title")}</h3>
<p className="text-sm text-muted-foreground mt-1">
{canCreate ? t("empty_folder_description") : t("empty_folder_read_only")}
</p>
{canCreate && (
<div className="flex gap-2 flex-wrap justify-center mt-5">
<Button size="sm" onClick={onUpload} className="gap-1.5">
<Upload className="w-4 h-4" />
{t("upload_files")}
</Button>
<Button variant="outline" size="sm" onClick={onCreateFolder} className="gap-1.5">
<FolderPlus className="w-4 h-4" />
{t("new_folder")}
</Button>
</div>
)}
</div>
</div>
<EmptyState
className="h-full"
icon={isRoot ? Upload : FolderOpen}
title={isRoot ? t("empty_state_title") : t("empty_folder_title")}
description={canCreate ? t("empty_folder_description") : t("empty_folder_read_only")}
actions={canCreate ? (
<>
<Button size="sm" onClick={onUpload} className="gap-1.5">
<Upload className="w-4 h-4" />
{t("upload_files")}
</Button>
<Button variant="outline" size="sm" onClick={onCreateFolder} className="gap-1.5">
<FolderPlus className="w-4 h-4" />
{t("new_folder")}
</Button>
</>
) : undefined}
/>
);
}
export function NoSearchResults({ query, onClear }: { query: string; onClear: () => void }) {
const t = useTranslations("files");
return (
<div className="flex flex-col items-center justify-center text-center h-full min-h-[14rem] p-8">
<SearchX className="w-10 h-10 text-muted-foreground/50 mb-3" />
<p className="text-sm font-medium">{t("no_results_for", { query })}</p>
<Button variant="ghost" size="sm" className="mt-2" onClick={onClear}>{t("clear_search")}</Button>
</div>
<EmptyState
className="h-full"
icon={SearchX}
title={t("no_results_for", { query })}
actions={<Button variant="outline" size="sm" onClick={onClear}>{t("clear_search")}</Button>}
/>
);
}
+1 -1
View File
@@ -156,5 +156,5 @@ export function getSymlinkIcon(size: FileIconSize, label: string) {
}
export function getFolderIcon(size: FileIconSize, colored = true) {
return <Folder className={`${ICON_SIZE_CLASSES[size]} shrink-0 ${colored ? "text-blue-500" : "text-muted-foreground"}`} />;
return <Folder className={`${ICON_SIZE_CLASSES[size]} shrink-0 ${colored ? "text-primary" : "text-muted-foreground"}`} />;
}
+7 -7
View File
@@ -9,7 +9,7 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
import { debug } from "@/lib/debug";
import { cn, formatFileSize } from "@/lib/utils";
import { getFileIconByName } from "@/components/files/file-icons";
import { loadFilesSettings } from "@/components/files/files-settings";
import { getFilesSettings } from "@/components/files/files-settings";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type { FileNode } from "@/lib/jmap/types";
@@ -51,7 +51,7 @@ export function FilePickerDialog({ client, onClose, onPick }: FilePickerDialogPr
const [trail, setTrail] = useState<{ id: string; name: string }[]>([]);
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<Map<string, FileNode>>(() => new Map());
const [showHidden] = useState(() => loadFilesSettings().showHiddenFiles);
const [showHidden] = useState(() => getFilesSettings().showHiddenFiles);
const modalRef = useFocusTrap({ isActive: true, onEscape: onClose, restoreFocus: true });
@@ -144,7 +144,7 @@ export function FilePickerDialog({ client, onClose, onPick }: FilePickerDialogPr
<span className="block truncate">{name}</span>
{detail && <span className="block truncate text-xs text-muted-foreground">{detail}</span>}
</span>
<ChevronRight className="w-4 h-4 shrink-0 text-muted-foreground rtl:rotate-180" />
<ChevronRight className="w-4 h-4 shrink-0 text-muted-foreground" />
</button>
);
@@ -159,7 +159,7 @@ export function FilePickerDialog({ client, onClose, onPick }: FilePickerDialogPr
onClick={() => toggle(node)}
className={cn(
"w-full flex items-center gap-3 px-3 py-2 rounded-md text-start text-sm transition-colors",
checked ? "bg-primary/10" : "hover:bg-muted",
checked ? "bg-selection" : "hover:bg-muted",
)}
>
<span
@@ -210,7 +210,7 @@ export function FilePickerDialog({ client, onClose, onPick }: FilePickerDialogPr
{here === null && !needle && sharedRoots.length > 0 && folderRow(
SHARED_WITH_ME,
t("shared_with_me"),
<Users className="w-5 h-5 shrink-0 text-blue-500" />,
<Users className="w-5 h-5 shrink-0 text-primary" />,
() => openFolder(SHARED_WITH_ME, t("shared_with_me")),
)}
{entries.map((node) =>
@@ -218,7 +218,7 @@ export function FilePickerDialog({ client, onClose, onPick }: FilePickerDialogPr
? folderRow(
node.id,
node.name,
<Folder className="w-5 h-5 shrink-0 text-blue-500" />,
<Folder className="w-5 h-5 shrink-0 text-primary" />,
() => openFolder(node.id, node.name),
sharedBy(node),
)
@@ -276,7 +276,7 @@ export function FilePickerDialog({ client, onClose, onPick }: FilePickerDialogPr
</button>
{trail.map((crumb, i) => (
<span key={crumb.id} className="flex items-center gap-1 min-w-0">
<ChevronRight className="w-3.5 h-3.5 shrink-0 text-muted-foreground rtl:rotate-180" />
<ChevronRight className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
<button
type="button"
onClick={() => setTrail(trail.slice(0, i + 1))}
+26 -96
View File
@@ -1,10 +1,11 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { generateUUID } from "@/lib/utils";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { X, Download, Loader2, ExternalLink } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { ExternalLink } from "@/components/icons";
import { Spinner } from "@/components/ui/spinner";
import { PreviewShell, type PreviewNeighbours } from "@/components/files/preview-shell";
import { IconButton } from "@/components/ui/icon-button";
import { ZoomableImage } from "@/components/files/zoomable-image";
import { getFilePreviewKind, imageBlobUrl, isMimeTypeSafeForInlinePreview, previewBlobType } from "@/lib/file-preview";
import dynamic from "next/dynamic";
@@ -13,6 +14,7 @@ import { FilePreviewPluginSection } from "@/components/files/file-preview-plugin
import { canOpenUrlInNewWindow } from "@/lib/standalone-display";
import { emailIframeCsp, sanitizeEmailBodyForIframe } from "@/lib/email-sanitization";
import { useSettingsStore } from "@/stores/settings-store";
import { useCloseOnBack } from "@/hooks/use-close-on-back";
import type { FileResourceView } from "@/lib/plugin-types";
/**
@@ -45,6 +47,8 @@ interface FilePreviewModalProps {
getFileContent: () => Promise<{ blob: Blob; contentType: string }>;
/** The previewed file as plugins see it (file-preview-section). */
file?: FileResourceView | null;
/** The previous and next file of the folder, for ←/→. */
neighbours?: PreviewNeighbours;
}
function SimpleMarkdown({ content }: { content: string }) {
@@ -138,11 +142,7 @@ function processInline(text: string): React.ReactNode {
return parts.length === 1 ? parts[0] : <>{parts}</>;
}
// Closing one preview can leave an asynchronous Back traversal in flight.
// A replacement must not install its popstate listener until that completes.
let pendingPreviewHistoryBack: Promise<void> | null = null;
export function FilePreviewModal({ name, onClose, onDownload, getFileContent, file }: FilePreviewModalProps) {
export function FilePreviewModal({ name, onClose, onDownload, getFileContent, file, neighbours }: FilePreviewModalProps) {
const t = useTranslations("files");
const [content, setContent] = useState<string | null>(null);
const [objectUrl, setObjectUrl] = useState<string | null>(null);
@@ -182,43 +182,8 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
}
}, []);
// Keep the latest onClose for the back-button handler without re-subscribing.
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
// Defer history setup so Strict Mode's setup/cleanup/setup replay can
// cancel the first setup without scheduling a browser Back traversal.
useEffect(() => {
const entryId = generateUUID();
let cancelled = false;
let started = false;
let poppedByBack = false;
const onPop = () => {
poppedByBack = true;
onCloseRef.current();
};
const timer = window.setTimeout(async () => {
if (pendingPreviewHistoryBack) await pendingPreviewHistoryBack;
if (cancelled) return;
window.history.pushState({ ...window.history.state, __filePreview: entryId }, "");
started = true;
window.addEventListener("popstate", onPop);
}, 0);
return () => {
cancelled = true;
window.clearTimeout(timer);
window.removeEventListener("popstate", onPop);
if (started && !poppedByBack && window.history.state?.__filePreview === entryId) {
pendingPreviewHistoryBack = new Promise<void>(resolve => {
window.addEventListener("popstate", () => {
pendingPreviewHistoryBack = null;
resolve();
}, { once: true });
});
window.history.back();
}
};
}, []);
// Back closes the preview instead of leaving Files.
useCloseOnBack(onClose);
const fileType = resolvedFileType;
@@ -298,57 +263,22 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
};
}, [getFileContent, name, blockRemote]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
onClose();
}
};
window.addEventListener("keydown", handleKeyDown, { capture: true });
return () => window.removeEventListener("keydown", handleKeyDown, { capture: true });
}, [onClose]);
return (
<div role="dialog" aria-label={name} className="fixed inset-0 z-50 flex flex-col bg-black/80" onClick={onClose}>
{/* The dialog is `fixed inset-0` under `viewport-fit=cover`, so in an
installed iOS PWA (no browser chrome above it) the header would sit
underneath the status bar and its buttons - including the close
button - became unreachable (#936). Pad the bar itself so its
background still bleeds behind the status bar / notch. The insets are
physical, so pl/pr (not ps/pe) is correct in RTL too. */}
<div className="flex items-center justify-between gap-2 pl-[calc(1rem+env(safe-area-inset-left))] pr-[calc(1rem+env(safe-area-inset-right))] pt-[calc(0.75rem+env(safe-area-inset-top))] pb-3 bg-background/90 backdrop-blur border-b border-border" onClick={(e) => e.stopPropagation()}>
<h3 className="text-sm font-medium truncate">{name}</h3>
<div className="flex items-center gap-2">
{/* An installed app hands the new window to the system browser,
which cannot load our blob: URL (#865): no dead button there. */}
{objectUrl && canOpenInNewTab && canOpenUrlInNewWindow(objectUrl) && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title={t("open_in_new_tab")}
aria-label={t("open_in_new_tab")}
onClick={() => window.open(objectUrl, "_blank", "noopener,noreferrer")}
>
<ExternalLink className="w-4 h-4" />
</Button>
)}
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => void onDownload()}>
<Download className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onClose}>
<X className="w-4 h-4" />
</Button>
</div>
</div>
<PreviewShell
name={name}
onClose={onClose}
onDownload={() => void onDownload()}
neighbours={neighbours}
// An installed app hands the new window to the system browser, which
// cannot load our blob: URL (#865): no dead button there.
actions={objectUrl && canOpenInNewTab && canOpenUrlInNewWindow(objectUrl) ? (
<IconButton label={t("open_in_new_tab")} onClick={() => window.open(objectUrl, "_blank", "noopener,noreferrer")}>
<ExternalLink />
</IconButton>
) : undefined}
>
<div className="relative flex-1 min-h-0 flex items-center justify-center overflow-auto p-4 pb-[calc(1rem+env(safe-area-inset-bottom))] pl-[calc(1rem+env(safe-area-inset-left))] pr-[calc(1rem+env(safe-area-inset-right))]">
{loading && (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Loader2 className="w-8 h-8 animate-spin" />
</div>
)}
{loading && <Spinner label={t("loading")} className="w-8 h-8 text-white/80" />}
{error && (
<p className="text-sm text-destructive">{t("preview_error")}</p>
@@ -434,6 +364,6 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
)}
</div>
<FilePreviewPluginSection file={file} />
</div>
</PreviewShell>
);
}
+40 -35
View File
@@ -2,8 +2,9 @@
import { useRef } from "react";
import { useTranslations } from "next-intl";
import { ArrowUp, ArrowDown, MoreHorizontal, Download, Share2, Star, Check } from "@/components/icons";
import { ArrowUp, ArrowDown, MoreVertical, Download, Share2, Star, Check } from "@/components/icons";
import { cn, formatFileSize } from "@/lib/utils";
import { IconButton } from "@/components/ui/icon-button";
import type { FileResource } from "@/stores/file-store";
import {
ResourceIcon, ShareBadge, Thumbnail, canShowThumbnail, formatFullDate, formatShortDate,
@@ -36,6 +37,8 @@ export interface FileViewProps {
iconOptions: IconOptions;
getImageUrl: (name: string) => Promise<string>;
handlers: FileItemHandlers;
/** A line about an item, as its tooltip (where a deleted item came from). */
itemNote?: (resource: FileResource) => string | undefined;
}
const LONG_PRESS_MS = 450;
@@ -102,6 +105,8 @@ function SelectBox({ checked, visible, onToggle, label }: {
onDoubleClick={(e) => e.stopPropagation()}
className={cn(
"w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-opacity",
// A 44px hit area on touch, as every Button has (R1).
"relative pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2",
checked ? "bg-primary border-primary text-primary-foreground" : "border-muted-foreground/50 bg-background",
visible || checked ? "opacity-100" : "opacity-0 group-hover:opacity-100 focus-visible:opacity-100",
)}
@@ -138,7 +143,7 @@ const LIST_COLUMNS = "grid grid-cols-[auto_minmax(0,1fr)_auto] sm:grid-cols-[aut
export function FileListView({
resources, selected, focusedName, dropTarget, cutNames, starredPaths, pathOf, touch, iconOptions, getImageUrl, handlers,
sortKey, sortDir, onSort, allSelected, someSelected, onToggleAll,
sortKey, sortDir, onSort, allSelected, someSelected, onToggleAll, itemNote, dateLabel,
}: FileViewProps & {
sortKey: SortKey;
sortDir: SortDir;
@@ -146,6 +151,8 @@ export function FileListView({
allSelected: boolean;
someSelected: boolean;
onToggleAll: () => void;
/** The date column's heading, "Modified" unless told otherwise. */
dateLabel?: string;
}) {
const t = useTranslations("files");
const selecting = selected.size > 0;
@@ -165,7 +172,7 @@ export function FileListView({
{someSelected && !allSelected && <span className="sr-only">{t("selected_count", { count: selected.size })}</span>}
</div>
<SortHeader label={t("name")} column="name" sortKey={sortKey} sortDir={sortDir} onSort={onSort} />
<SortHeader label={t("modified")} column="modified" sortKey={sortKey} sortDir={sortDir} onSort={onSort} className="hidden md:block" />
<SortHeader label={dateLabel ?? t("modified")} column="modified" sortKey={sortKey} sortDir={sortDir} onSort={onSort} className="hidden md:block" />
<SortHeader label={t("size")} column="size" sortKey={sortKey} sortDir={sortDir} onSort={onSort} className="hidden sm:block text-end" />
<div role="columnheader" className="w-[4.75rem]"><span className="sr-only">{t("context_menu")}</span></div>
</div>
@@ -183,13 +190,14 @@ export function FileListView({
iconOptions={iconOptions}
getImageUrl={getImageUrl}
handlers={handlers}
note={itemNote?.(resource)}
/>
))}
</div>
);
}
function FileListRow({ resource, isSelected, isFocused, isDropTarget, isCut, isStarred, selecting, touch, iconOptions, getImageUrl, handlers }: {
function FileListRow({ resource, isSelected, isFocused, isDropTarget, isCut, isStarred, selecting, touch, iconOptions, getImageUrl, handlers, note }: {
resource: FileResource;
isSelected: boolean;
isFocused: boolean;
@@ -201,6 +209,7 @@ function FileListRow({ resource, isSelected, isFocused, isDropTarget, isCut, isS
iconOptions: IconOptions;
getImageUrl: (name: string) => Promise<string>;
handlers: FileItemHandlers;
note?: string;
}) {
const t = useTranslations("files");
const touchProps = useTouchPress(touch, selecting, resource, handlers);
@@ -220,7 +229,7 @@ function FileListRow({ resource, isSelected, isFocused, isDropTarget, isCut, isS
LIST_COLUMNS,
"group px-4 border-b border-border/60 cursor-default select-none transition-colors outline-none",
touch ? "min-h-[52px] py-1.5" : "h-10",
isSelected ? "bg-primary/10" : isDropTarget ? "bg-primary/10 ring-1 ring-inset ring-primary/50" : "hover:bg-muted/60",
isSelected ? "bg-selection" : isDropTarget ? "bg-primary/10 ring-1 ring-inset ring-primary/50" : "hover:bg-muted/60",
isFocused && "ring-1 ring-inset ring-ring/60",
isCut && "opacity-50",
)}
@@ -251,15 +260,16 @@ function FileListRow({ resource, isSelected, isFocused, isDropTarget, isCut, isS
}}
onDoubleClick={(e) => e.stopPropagation()}
className={cn("truncate text-start hover:underline underline-offset-2 decoration-muted-foreground/50", resource.isDirectory && "font-medium")}
title={resource.name}
title={note ? `${resource.name}\n${note}` : resource.name}
>
{resource.name}
</button>
{isStarred && <Star className="w-3.5 h-3.5 shrink-0 text-amber-500 fill-current" aria-label={t("favorites")} />}
{isStarred && <Star className="w-3.5 h-3.5 shrink-0 text-amber-400 fill-amber-400" aria-label={t("favorites")} />}
<ShareBadge resource={resource} t={t} />
</div>
{touch && (
<div className="text-xs text-muted-foreground truncate">
{note && <>{note} · </>}
{formatShortDate(resource.lastModified)}
{!resource.isDirectory && !resource.isSymlink && <> · {formatFileSize(resource.contentLength)}</>}
</div>
@@ -279,17 +289,16 @@ function FileListRow({ resource, isSelected, isFocused, isDropTarget, isCut, isS
{!touch && handlers.canShare(resource) && (
<RowAction icon={Share2} label={t("share")} onClick={() => handlers.onShare(resource)} />
)}
<button
type="button"
<IconButton
data-item-action
aria-label={t("more_actions", { name: resource.name })}
label={t("more_actions", { name: resource.name })}
title={t("context_menu")}
onClick={(e) => { e.stopPropagation(); handlers.onContextMenu(e, resource); }}
onDoubleClick={(e) => e.stopPropagation()}
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-foreground/10 transition-colors"
className="text-muted-foreground hover:text-foreground"
>
<MoreHorizontal className="w-4 h-4" />
</button>
<MoreVertical />
</IconButton>
</div>
</div>
);
@@ -297,17 +306,15 @@ function FileListRow({ resource, isSelected, isFocused, isDropTarget, isCut, isS
function RowAction({ icon: Icon, label, onClick }: { icon: React.ComponentType<{ className?: string }>; label: string; onClick: () => void }) {
return (
<button
type="button"
<IconButton
data-item-action
aria-label={label}
title={label}
label={label}
onClick={(e) => { e.stopPropagation(); onClick(); }}
onDoubleClick={(e) => e.stopPropagation()}
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-foreground/10 transition-all opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
className="text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100"
>
<Icon className="w-4 h-4" />
</button>
<Icon />
</IconButton>
);
}
@@ -388,7 +395,7 @@ function FolderTile({ resource, isSelected, isFocused, isDropTarget, isCut, isSt
{...touchProps}
className={cn(
"group relative flex items-center gap-2.5 h-11 ps-3 pe-1 rounded-lg border cursor-default select-none transition-colors",
isSelected ? "border-primary/60 bg-primary/10" : isDropTarget ? "border-primary bg-primary/10" : "border-border bg-background hover:bg-muted/60",
isSelected ? "border-primary/60 bg-selection" : isDropTarget ? "border-primary bg-primary/10" : "border-border bg-background hover:bg-muted/60",
isFocused && "ring-2 ring-ring/50",
isCut && "opacity-50",
)}
@@ -409,18 +416,17 @@ function FolderTile({ resource, isSelected, isFocused, isDropTarget, isCut, isSt
>
{resource.name}
</button>
{isStarred && <Star className="w-3.5 h-3.5 shrink-0 text-amber-500 fill-current" aria-label={t("favorites")} />}
{isStarred && <Star className="w-3.5 h-3.5 shrink-0 text-amber-400 fill-amber-400" aria-label={t("favorites")} />}
<ShareBadge resource={resource} t={t} />
<button
type="button"
<IconButton
data-item-action
aria-label={t("more_actions", { name: resource.name })}
label={t("more_actions", { name: resource.name })}
onClick={(e) => { e.stopPropagation(); handlers.onContextMenu(e, resource); }}
onDoubleClick={(e) => e.stopPropagation()}
className={cn("p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-foreground/10", !touch && "opacity-0 group-hover:opacity-100 focus-visible:opacity-100")}
className={cn("text-muted-foreground hover:text-foreground", !touch && "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100")}
>
<MoreHorizontal className="w-4 h-4" />
</button>
<MoreVertical />
</IconButton>
</div>
);
}
@@ -441,7 +447,7 @@ function FileTile({ resource, isSelected, isFocused, isCut, selecting, touch, ic
{...touchProps}
className={cn(
"group relative flex flex-col rounded-lg border overflow-hidden cursor-default select-none transition-colors",
isSelected ? "border-primary/60 bg-primary/10" : "border-border bg-background hover:bg-muted/60",
isSelected ? "border-primary/60 bg-selection" : "border-border bg-background hover:bg-muted/60",
isFocused && "ring-2 ring-ring/50",
isCut && "opacity-50",
)}
@@ -481,16 +487,15 @@ function FileTile({ resource, isSelected, isFocused, isCut, selecting, touch, ic
<ShareBadge resource={resource} t={t} className="w-3 h-3" />
</div>
</div>
<button
type="button"
<IconButton
data-item-action
aria-label={t("more_actions", { name: resource.name })}
label={t("more_actions", { name: resource.name })}
onClick={(e) => { e.stopPropagation(); handlers.onContextMenu(e, resource); }}
onDoubleClick={(e) => e.stopPropagation()}
className={cn("p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-foreground/10 shrink-0", !touch && "opacity-0 group-hover:opacity-100 focus-visible:opacity-100")}
className={cn("text-muted-foreground hover:text-foreground shrink-0", !touch && "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100")}
>
<MoreHorizontal className="w-4 h-4" />
</button>
<MoreVertical />
</IconButton>
</div>
</div>
);
+185 -46
View File
@@ -7,7 +7,8 @@ import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store';
import { useAccountStore } from "@/stores/account-store";
import { useEmailStore } from "@/stores/email-store";
import { useFileStore, isSharedLocation, linkedPreviewResource, resourceServerRef, resourceToView, type UploadReport } from "@/stores/file-store";
import { useFileStore, deletesToTrash, isSharedLocation, linkedPreviewResource, resourceServerRef, resourceToView, type UploadReport } from "@/stores/file-store";
import { isTrashLocation } from "@/lib/files-trash";
import { fileErrorKind, isPartialCopy } from "@/lib/file-errors";
import { toast } from "@/stores/toast-store";
import { cn, formatFileSize } from "@/lib/utils";
@@ -88,7 +89,9 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
createTextFile,
duplicateResource,
downloadResources,
downloadZip,
moveToFolderId,
moveNode,
cutResources,
copyResources,
pasteResources,
@@ -109,6 +112,11 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
lastAction,
shareResource,
openSharedNode,
trashResources,
trashNode,
restoreResources,
purgeResources,
emptyTrash,
} = useFileStore();
const isMobile = useIsMobile();
@@ -397,42 +405,6 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
}
}, [uploadFolder, t, maxSizeUpload, showUploadReport, failureReason]);
const handleDelete = useCallback(async (name: string) => {
const confirmed = await confirmDialog({
title: t("delete_confirm_title"),
message: t("delete_confirm_message", { name }),
confirmText: t("delete"),
variant: "destructive",
});
if (!confirmed) return;
try {
if (!await deleteResource(name)) return;
toast.success(t("delete_success"));
} catch (err) {
console.error("Failed to delete:", err);
toast.error(t("delete_error"), failureReason(err));
}
}, [deleteResource, confirmDialog, t, failureReason]);
const handleBatchDelete = useCallback(async (names: string[]) => {
const confirmed = await confirmDialog({
title: t("delete_confirm_title"),
message: t("batch_delete_confirm_message", { count: names.length }),
confirmText: t("delete"),
variant: "destructive",
});
if (!confirmed) return;
try {
if (!await deleteResources(names)) return;
toast.success(t("batch_delete_success", { count: names.length }));
} catch (err) {
console.error("Failed to batch delete:", err);
toast.error(t("delete_error"), failureReason(err));
}
}, [deleteResources, confirmDialog, t, failureReason]);
const handleUndo = useCallback(async () => {
try {
await undoLastAction();
@@ -443,6 +415,93 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
}
}, [undoLastAction, t, failureReason]);
// Delete (R3): in the own drive items go to the trash, with Undo and no
// question; in the trash and in a drive someone shared they are gone for
// good, so that asks first.
const currentParentIdForDelete = useFileStore((s) => s.currentParentId);
const handleDeleteNames = useCallback(async (names: string[]) => {
if (names.length === 0) return;
if (isTrashLocation(currentParentIdForDelete)) {
const confirmed = await confirmDialog({
title: t("delete_forever_title", { count: names.length }),
message: names.length === 1
? t("delete_forever_message", { name: names[0] })
: t("delete_forever_message_count", { count: names.length }),
confirmText: t("delete_forever"),
variant: "destructive",
});
if (!confirmed) return;
try {
const count = await purgeResources(names);
if (count > 0) toast.success(t("deleted_forever", { count }));
} catch (err) {
console.error("Failed to delete:", err);
toast.error(t("delete_error"), failureReason(err));
}
return;
}
if (deletesToTrash(currentParentIdForDelete)) {
try {
const count = await trashResources(names);
if (count > 0) {
toast.success(t("moved_to_trash", { count }), {
action: { label: t("undo"), onClick: handleUndo },
});
}
} catch (err) {
console.error("Failed to delete:", err);
toast.error(t("delete_error"), failureReason(err));
}
return;
}
const confirmed = await confirmDialog({
title: t("delete_forever_title", { count: names.length }),
message: names.length === 1
? t("delete_forever_message", { name: names[0] })
: t("delete_forever_message_count", { count: names.length }),
confirmText: t("delete_forever"),
variant: "destructive",
});
if (!confirmed) return;
try {
if (names.length === 1 ? !await deleteResource(names[0]) : !await deleteResources(names)) return;
toast.success(t("deleted_forever", { count: names.length }));
} catch (err) {
console.error("Failed to delete:", err);
toast.error(t("delete_error"), failureReason(err));
}
}, [currentParentIdForDelete, confirmDialog, t, purgeResources, trashResources, deleteResource, deleteResources, handleUndo, failureReason]);
const handleDelete = useCallback((name: string) => handleDeleteNames([name]), [handleDeleteNames]);
const handleBatchDelete = handleDeleteNames;
const handleRestore = useCallback(async (names: string[]) => {
try {
const count = await restoreResources(names);
if (count > 0) toast.success(t("restored", { count }));
} catch (err) {
console.error("Failed to restore:", err);
toast.error(t("restore_error"), failureReason(err));
}
}, [restoreResources, t, failureReason]);
const trashCount = useFileStore((s) => s.trashCount);
const handleEmptyTrash = useCallback(async () => {
const confirmed = await confirmDialog({
title: t("empty_trash_title"),
message: t("empty_trash_message", { count: trashCount }),
confirmText: t("empty_trash"),
variant: "destructive",
});
if (!confirmed) return;
try {
await emptyTrash();
toast.success(t("trash_emptied"));
} catch (err) {
console.error("Failed to empty the trash:", err);
toast.error(t("empty_trash_error"), failureReason(err));
}
}, [confirmDialog, t, trashCount, emptyTrash, failureReason]);
const handleRename = useCallback(async (oldName: string, newName: string) => {
try {
if (!await renameResource(oldName, newName)) return;
@@ -479,6 +538,31 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
}
}, [downloadResources, t]);
// A folder (or a selection with folders) comes as a zip built here; a
// progress toast shows how far it is and can cancel it.
const handleDownloadZip = useCallback(async (ids: string[], zipName: string) => {
const controller = new AbortController();
const toastId = toast.progress(t("zipping", { name: zipName }), {
progress: 0,
action: { label: t("cancel"), onClick: () => controller.abort() },
});
try {
await downloadZip(ids, zipName, {
signal: controller.signal,
onProgress: ({ progress, current }) => toast.update(toastId, { progress, message: current }),
});
toast.dismiss(toastId);
} catch (err) {
toast.dismiss(toastId);
if (err instanceof DOMException && err.name === "AbortError") {
toast.info(t("download_canceled"));
return;
}
console.error("Failed to download the zip:", err);
toast.error(t("zip_error", { name: zipName }), failureReason(err));
}
}, [downloadZip, t, failureReason]);
const handleCreateTextFile = useCallback(async (name: string) => {
try {
await createTextFile(name);
@@ -511,6 +595,18 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
}
}, [moveToFolderId, t, handleUndo, failureReason]);
const handleMoveNode = useCallback(async (id: string, targetId: string | null) => {
try {
if (!await moveNode(id, targetId)) return;
toast.success(t("move_success", { count: 1 }), {
action: { label: t("undo"), onClick: handleUndo },
});
} catch (err) {
console.error("Failed to move:", err);
toast.error(t("move_error"), failureReason(err));
}
}, [moveNode, t, handleUndo, failureReason]);
const handlePaste = useCallback(async () => {
try {
if (!await pasteResources()) return;
@@ -541,26 +637,39 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
});
} catch (err) {
console.error("Failed to rename:", err);
toast.error(t("rename_error"));
toast.error(t("rename_error"), failureReason(err));
}
}, [renameNode, t, handleUndo]);
}, [renameNode, t, handleUndo, failureReason]);
const handleDeleteNode = useCallback(async (id: string, name: string) => {
// A folder of the own drive goes to the trash; one of a shared drive is gone for good.
if (deletesToTrash(id)) {
try {
if (!await trashNode(id)) return;
toast.success(t("moved_to_trash", { count: 1 }), {
action: { label: t("undo"), onClick: handleUndo },
});
} catch (err) {
console.error("Failed to delete:", err);
toast.error(t("delete_error"), failureReason(err));
}
return;
}
const confirmed = await confirmDialog({
title: t("delete_confirm_title"),
message: t("delete_confirm_message", { name }),
confirmText: t("delete"),
title: t("delete_forever_title", { count: 1 }),
message: t("delete_forever_message", { name }),
confirmText: t("delete_forever"),
variant: "destructive",
});
if (!confirmed) return;
try {
if (!await deleteNode(id)) return;
toast.success(t("delete_success"));
toast.success(t("deleted_forever", { count: 1 }));
} catch (err) {
console.error("Failed to delete:", err);
toast.error(t("delete_error"));
toast.error(t("delete_error"), failureReason(err));
}
}, [deleteNode, confirmDialog, t]);
}, [deleteNode, trashNode, confirmDialog, handleUndo, t, failureReason]);
const handleDownloadNode = useCallback(async (id: string) => {
try {
@@ -600,6 +709,29 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
// The previewed file as plugins see it (file-preview-section).
const previewedName = previewFile ?? previewImage;
// ←/→ in a preview go through the folder's other previewable files.
const previewNeighbours = useMemo(() => {
if (!previewedName) return undefined;
const names = resources
.filter((r) => !r.isDirectory && !r.isSymlink && (isImageFile(r.name) || isFilePreviewable(r.name)))
.map((r) => r.name);
const i = names.indexOf(previewedName);
if (i === -1) return undefined;
return {
previous: names[i - 1] ?? null,
next: names[i + 1] ?? null,
onGo: (name: string) => {
addRecentFile(name, resources.find((r) => r.name === name)?.id ?? name);
if (isImageFile(name)) {
setPreviewFile(null);
setPreviewImage(name);
} else {
setPreviewImage(null);
setPreviewFile(name);
}
},
};
}, [previewedName, resources, addRecentFile]);
const previewedView = useMemo(() => {
const resource = previewedName ? resources.find((r) => r.name === previewedName) : undefined;
return resource ? resourceToView(resource, currentPath) : null;
@@ -691,7 +823,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
{!filesEnabled ? (
<div className="flex items-center justify-center h-full">
<div className="max-w-lg text-center space-y-3 px-4">
<AlertTriangle className="w-10 h-10 text-yellow-500 mx-auto" />
<AlertTriangle className="w-10 h-10 text-amber-600 dark:text-amber-400 mx-auto" />
<p className="text-sm font-medium">{t("disabled_title")}</p>
<p className="text-xs text-muted-foreground">{t("disabled_description")}</p>
</div>
@@ -716,9 +848,12 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
onCancelUpload={cancelUpload}
onDelete={handleDelete}
onBatchDelete={handleBatchDelete}
onRestore={handleRestore}
onEmptyTrash={() => void handleEmptyTrash()}
onRename={handleRename}
onDownload={handleDownload}
onBatchDownload={handleBatchDownload}
onDownloadZip={handleDownloadZip}
onRefresh={refresh}
onToggleSelect={toggleSelect}
onClearSelection={clearSelection}
@@ -727,6 +862,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
onCopy={copyResources}
onPaste={handlePaste}
onMoveTo={handleMoveTo}
onMoveNode={handleMoveNode}
onPreviewImage={handlePreviewImage}
onPreviewFile={handlePreviewFile}
isOfficeEditable={isOfficeEditable}
@@ -776,6 +912,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
onDownload={handleDownload}
getImageUrl={getImageUrl}
file={previewedView}
neighbours={previewNeighbours}
/>
)}
@@ -787,6 +924,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
onDownload={() => handleDownload(previewFile)}
getFileContent={() => getFileContent(previewFile)}
file={previewedView}
neighbours={previewNeighbours}
/>
)}
@@ -806,6 +944,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
target={{ kind: "file", id: node.id, name: editResource.name }}
accountId={node.accountId ?? filesAccountId}
slot={slot}
closeOnBack
onClose={() => {
setEditFile(null);
// The editor may have saved new content - pick up size/mtime.
+81 -44
View File
@@ -5,9 +5,10 @@ import { useTranslations } from "next-intl";
import {
Menu, ChevronRight, Search, X, ArrowUpDown, Check, LayoutGrid, LayoutList,
PanelRight, Download, Trash2, Scissors, Copy, Pencil, Share2, Clipboard,
MoreHorizontal, FolderOpen, Folder, ArrowUp, ArrowDown, RefreshCw,
MoreHorizontal, FolderOpen, Folder, ArrowUp, ArrowDown, RefreshCw, RotateCcw, FolderInput,
} from "@/components/icons";
import { Button } from "@/components/ui/button";
import { IconButton } from "@/components/ui/icon-button";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import { PhoneHeaderActions } from "@/components/layout/phone-header-actions";
@@ -38,6 +39,8 @@ interface SelectionActions {
onDownload: () => void;
onShare: () => void;
onRename: () => void;
/** Move to… a folder picked from the tree; undefined where items can't move. */
onMoveTo?: () => void;
onCut: () => void;
onCopy: () => void;
onDelete: () => void;
@@ -46,6 +49,9 @@ interface SelectionActions {
pluginFiles: FileResourceView[];
/** The one selected item's type, so plugins can offer type-specific actions; undefined for several. */
pluginMimeType?: string;
/** Items in the trash: Restore and Delete permanently, nothing else. */
trash?: boolean;
onRestore?: () => void;
}
interface FilesHeaderProps {
@@ -75,6 +81,8 @@ interface FilesHeaderProps {
/** Hide the tools that only apply to a folder listing (Pro account picker). */
toolsHidden?: boolean;
selection: SelectionActions | null;
/** In the trash: the Empty trash button. */
emptyTrash?: { onEmpty: () => void; disabled: boolean };
}
const MAX_VISIBLE_CRUMBS = 3;
@@ -104,6 +112,7 @@ export function FilesHeader({
compact,
toolsHidden,
selection,
emptyTrash,
}: FilesHeaderProps) {
const t = useTranslations("files");
const sortMenu = useContextMenu<null>();
@@ -116,32 +125,47 @@ export function FilesHeader({
if (selection) {
return (
<div role="toolbar" aria-label={t("selection_actions")} className={cn(headerClass, "bg-accent/40")}>
<Button variant="ghost" size="icon" className="h-8 w-8 -ms-1" onClick={selection.onClear} aria-label={t("clear_selection")} title={t("clear_selection")}>
<X className="w-4 h-4" />
</Button>
<IconButton label={t("clear_selection")} className="-ms-1" onClick={selection.onClear}>
<X />
</IconButton>
<span className="text-sm font-medium me-2 whitespace-nowrap" aria-live="polite">
{t("selected_count", { count: selection.count })}
</span>
<div className="flex items-center gap-0.5 min-w-0 overflow-x-auto">
{selection.trash ? (
<>
{selection.onRestore && (
<ToolButton icon={RotateCcw} label={t("restore")} onClick={selection.onRestore} compact />
)}
<ToolButton icon={Trash2} label={t("delete_forever")} onClick={selection.onDelete} compact destructive />
</>
) : (
<>
{/* All icons with a tooltip, as in Mail's selection bar: one kind of button. */}
{selection.canOpen && (
<ToolButton icon={FolderOpen} label={t("open")} onClick={selection.onOpen} compact={compact} />
<ToolButton icon={FolderOpen} label={t("open")} onClick={selection.onOpen} compact />
)}
{selection.fileCount > 0 && (
<ToolButton icon={Download} label={t("download")} onClick={selection.onDownload} compact={compact} />
<ToolButton icon={Download} label={t("download")} onClick={selection.onDownload} compact />
)}
{selection.canShare && (
<ToolButton icon={Share2} label={t("share")} onClick={selection.onShare} compact={compact} />
<ToolButton icon={Share2} label={t("share")} onClick={selection.onShare} compact />
)}
{selection.canRename && (
<ToolButton icon={Pencil} label={t("rename")} onClick={selection.onRename} compact />
)}
{selection.onMoveTo && (
<ToolButton icon={FolderInput} label={t("move_to")} onClick={selection.onMoveTo} compact />
)}
<ToolButton icon={Scissors} label={t("cut")} onClick={selection.onCut} compact />
<ToolButton icon={Copy} label={t("copy")} onClick={selection.onCopy} compact />
<ToolButton icon={Trash2} label={t("delete")} onClick={selection.onDelete} compact destructive />
{!compact && <SelectionPluginActions files={selection.pluginFiles} mimeType={selection.pluginMimeType} />}
</>
)}
</div>
{/* Outside the scrolling row, so the "more" button never scrolls out of sight. */}
{compact && <SelectionPluginActions files={selection.pluginFiles} mimeType={selection.pluginMimeType} overflow />}
{compact && !selection.trash && <SelectionPluginActions files={selection.pluginFiles} mimeType={selection.pluginMimeType} overflow />}
<div className="flex-1" />
<DetailsToggle showDetails={showDetails} onToggle={onToggleDetails} label={t("details")} />
</div>
@@ -172,7 +196,7 @@ export function FilesHeader({
const isLast = crumb === crumbs[crumbs.length - 1];
return (
<li key={`${crumb.id ?? "root"}:${crumb.path}`} className="flex items-center min-w-0 shrink-0 last:shrink">
{index > 0 && <ChevronRight className="w-3.5 h-3.5 text-muted-foreground shrink-0 rtl:rotate-180" aria-hidden />}
{index > 0 && <ChevronRight className="w-3.5 h-3.5 text-muted-foreground shrink-0" aria-hidden />}
<button
type="button"
onClick={() => onNavigateCrumb(crumb)}
@@ -235,16 +259,14 @@ export function FilesHeader({
return (
<div role="toolbar" aria-label={t("toolbar")} className={headerClass}>
{onMenuClick && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 -ms-1 shrink-0"
<IconButton
label={t("open_folder_tree")}
className="-ms-1 shrink-0"
onClick={onMenuClick}
aria-label={t("open_folder_tree")}
aria-expanded={menuOpen}
>
<Menu className="w-4 h-4" />
</Button>
<Menu />
</IconButton>
)}
<nav aria-label={t("breadcrumbs")} className="flex-1 min-w-0">
@@ -252,7 +274,7 @@ export function FilesHeader({
{visible.slice(0, 1).map((c) => renderCrumb(c, 0))}
{accountLabel && (
<li className="flex items-center min-w-0 shrink-0">
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground shrink-0 rtl:rotate-180" aria-hidden />
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground shrink-0" aria-hidden />
<button
type="button"
onClick={onNavigateAccountRoot}
@@ -264,7 +286,7 @@ export function FilesHeader({
)}
{hidden.length > 0 && (
<li className="flex items-center shrink-0">
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground shrink-0 rtl:rotate-180" aria-hidden />
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground shrink-0" aria-hidden />
<button
type="button"
className="px-1.5 py-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted"
@@ -282,23 +304,33 @@ export function FilesHeader({
{!toolsHidden && (
<div className="flex items-center gap-1 shrink-0">
{emptyTrash && (
<Button
variant="outline"
size="xs"
className="gap-1.5 text-destructive hover:text-destructive"
onClick={emptyTrash.onEmpty}
disabled={emptyTrash.disabled}
title={t("empty_trash")}
>
<Trash2 className="w-4 h-4" />
<span className={cn(compact && "sr-only")}>{t("empty_trash")}</span>
</Button>
)}
{clipboardCount > 0 && (
<Button variant="outline" size="sm" className="h-8 gap-1.5" onClick={onPaste} title={t("paste_here")}>
<Button variant="outline" size="xs" className="gap-1.5" onClick={onPaste} title={t("paste_here")}>
<Clipboard className="w-4 h-4" />
<span className={cn(compact && "sr-only")}>{t("paste_count", { count: clipboardCount })}</span>
</Button>
)}
{compact ? (
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setSearchOpen(true)} aria-label={t("search_placeholder")}>
<Search className="w-4 h-4" />
</Button>
<IconButton label={t("search_placeholder")} onClick={() => setSearchOpen(true)}>
<Search />
</IconButton>
) : searchField}
<Button
variant="ghost"
size="icon"
className={cn("h-8 w-8", sortMenu.contextMenu.isOpen && "bg-accent")}
aria-label={t("sort_by")}
title={t("sort_by")}
<IconButton
label={t("sort_by")}
className={cn(sortMenu.contextMenu.isOpen && "bg-accent")}
aria-haspopup="menu"
aria-expanded={sortMenu.contextMenu.isOpen}
onMouseDown={(e) => { if (sortMenu.contextMenu.isOpen) e.stopPropagation(); }}
@@ -307,8 +339,8 @@ export function FilesHeader({
else sortMenu.openContextMenu(anchorEvent(e.currentTarget, "end"), null);
}}
>
<ArrowUpDown className="w-4 h-4" />
</Button>
<ArrowUpDown />
</IconButton>
<div className="flex items-center rounded-md border border-border p-0.5" role="radiogroup" aria-label={t("view_mode")}>
{([["list", LayoutList, t("list_view")], ["grid", LayoutGrid, t("grid_view")]] as const).map(([mode, Icon, label]) => (
<button
@@ -321,6 +353,8 @@ export function FilesHeader({
onClick={() => onViewModeChange(mode)}
className={cn(
"h-6 w-7 flex items-center justify-center rounded transition-colors",
// A 44px hit area on touch, as every Button has (R1).
"relative pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2",
viewMode === mode ? "bg-accent text-accent-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
@@ -329,12 +363,18 @@ export function FilesHeader({
))}
</div>
{!compact && (
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onRefresh} disabled={isLoading} aria-label={t("refresh")} title={t("refresh")}>
<RefreshCw className={cn("w-4 h-4", isLoading && "animate-spin")} />
</Button>
<IconButton label={t("refresh")} onClick={onRefresh} disabled={isLoading}>
<RefreshCw className={cn(isLoading && "animate-spin")} />
</IconButton>
)}
<DetailsToggle showDetails={showDetails} onToggle={onToggleDetails} label={t("details")} />
<PhoneHeaderActions below="md" />
<PhoneHeaderActions
below="md"
menuItems={(close) => (
// The phone header has no room for Refresh as a button.
<ContextMenuItem icon={RefreshCw} label={t("refresh")} onClick={() => { close(); onRefresh(); }} disabled={isLoading} />
)}
/>
</div>
)}
@@ -386,8 +426,8 @@ function ToolButton({ icon: Icon, label, onClick, compact, destructive }: {
return (
<Button
variant="ghost"
size={compact ? "icon" : "sm"}
className={cn("h-8 shrink-0", compact ? "w-8" : "gap-1.5 px-2.5", destructive && "text-destructive hover:text-destructive hover:bg-destructive/10")}
size={compact ? "icon-sm" : "xs"}
className={cn("shrink-0", !compact && "gap-1.5", destructive && "text-destructive hover:text-destructive hover:bg-destructive/10")}
onClick={onClick}
aria-label={label}
title={label}
@@ -400,17 +440,14 @@ function ToolButton({ icon: Icon, label, onClick, compact, destructive }: {
function DetailsToggle({ showDetails, onToggle, label }: { showDetails: boolean; onToggle: () => void; label: string }) {
return (
<Button
variant="ghost"
size="icon"
className={cn("h-8 w-8 shrink-0", showDetails && "bg-accent text-accent-foreground")}
<IconButton
label={label}
className={cn("shrink-0", showDetails && "bg-accent text-accent-foreground")}
onClick={onToggle}
aria-label={label}
aria-pressed={showDetails}
title={label}
>
<PanelRight className="w-4 h-4" />
</Button>
<PanelRight />
</IconButton>
);
}
+39 -29
View File
@@ -1,36 +1,46 @@
// Files app preferences, kept in localStorage and edited in Settings > Files.
// Files app preferences: in the synced settings store, edited in Settings > Files
// and by the Files toolbar.
export interface FilesSettings {
defaultViewMode: "list" | "grid";
showIcons: boolean;
coloredIcons: boolean;
defaultSortKey: "name" | "size" | "modified";
defaultSortDir: "asc" | "desc";
showHiddenFiles: boolean;
showThumbnails: boolean;
}
import { useCallback, useEffect } from "react";
import { useSettingsStore } from "@/stores/settings-store";
import { DEFAULT_FILES_SETTINGS, normalizeFilesSettings, type FilesSettings } from "@/lib/files-settings";
export const DEFAULT_FILES_SETTINGS: FilesSettings = {
defaultViewMode: "list",
showIcons: true,
coloredIcons: true,
defaultSortKey: "name",
defaultSortDir: "asc",
showHiddenFiles: false,
showThumbnails: true,
};
export { DEFAULT_FILES_SETTINGS, type FilesSettings };
export function loadFilesSettings(): FilesSettings {
if (typeof window === "undefined") return DEFAULT_FILES_SETTINGS;
/** Where older builds kept the Files settings, on this device only. */
export const LEGACY_FILES_SETTINGS_KEY = "files-settings";
/**
* Moves Files settings an older build left in localStorage into the synced
* store, once. A choice already made in the synced settings (on this or
* another device) wins; the old copy is removed either way.
*/
export function migrateLegacyFilesSettings(): void {
if (typeof window === "undefined") return;
try {
const raw = localStorage.getItem("files-settings");
if (raw) return { ...DEFAULT_FILES_SETTINGS, ...JSON.parse(raw) };
} catch { /* ignore */ }
return DEFAULT_FILES_SETTINGS;
const raw = localStorage.getItem(LEGACY_FILES_SETTINGS_KEY);
if (raw === null) return;
const store = useSettingsStore.getState();
if (!store.explicitSettings.includes("filesSettings")) {
store.updateSetting("filesSettings", normalizeFilesSettings(JSON.parse(raw)));
}
localStorage.removeItem(LEGACY_FILES_SETTINGS_KEY);
} catch { /* unreadable: the defaults stand */ }
}
export function saveFilesSettings(settings: FilesSettings) {
localStorage.setItem("files-settings", JSON.stringify(settings));
// Dispatch custom event for same-tab listeners (StorageEvent only fires cross-tab)
window.dispatchEvent(new CustomEvent("files-settings-changed"));
/** The Files settings as they are now, outside React. */
export function getFilesSettings(): FilesSettings {
migrateLegacyFilesSettings();
return useSettingsStore.getState().filesSettings;
}
/** The Files settings, and a setter that changes some of them. */
export function useFilesSettings(): [FilesSettings, (patch: Partial<FilesSettings>) => void] {
useEffect(() => { migrateLegacyFilesSettings(); }, []);
const settings = useSettingsStore((s) => s.filesSettings);
const update = useCallback((patch: Partial<FilesSettings>) => {
const { filesSettings, updateSetting } = useSettingsStore.getState();
updateSetting("filesSettings", { ...filesSettings, ...patch });
}, []);
return [settings, update];
}
+73 -20
View File
@@ -4,9 +4,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import {
Plus, HardDrive, File, Folder, FolderOpen, ChevronRight, ChevronDown, Star, StarOff,
Clock, Share2, Upload, FolderUp, FolderPlus, FilePlus, Pencil, Trash2, Download, Eye, X, FolderSearch,
Clock, Share2, Upload, FolderUp, FolderPlus, FilePlus, Pencil, Trash2, Download, Eye, X, FolderSearch, FolderInput,
} from "@/components/icons";
import { Button } from "@/components/ui/button";
import { isTrashLocation } from "@/lib/files-trash";
import { IconButton } from "@/components/ui/icon-button";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import { cn } from "@/lib/utils";
@@ -26,11 +27,12 @@ function loadCollapsed(): Record<string, boolean> {
}
}
const ROW_STYLE = { paddingBlock: "var(--density-sidebar-py, 4px)", minHeight: "32px" } as const;
const ROW_STYLE = { paddingBlock: "var(--density-sidebar-py, 4px)" } as const;
function rowClass(active: boolean, dropTarget: boolean) {
return cn(
"group w-full flex items-center gap-2 pe-3 text-sm transition-colors text-start",
// 32px rows, 44px on touch (R1).
"group w-full flex items-center gap-2 pe-3 text-sm transition-colors text-start min-h-8 pointer-coarse:min-h-11",
active
? "bg-accent text-accent-foreground font-medium"
: dropTarget
@@ -72,11 +74,20 @@ export interface FilesSidebarProps {
/** Open a folder, then start creating or uploading there. */
onCreateIn: (path: string, id: string | null, kind: CreateKind) => void;
onRenameFolder: (folder: FileResource) => void;
/** Move to… for a folder of the own drive. */
onMoveFolder?: (folder: FileResource) => void;
/** Download a folder as a zip. */
onDownloadFolder?: (folder: FileResource) => void;
onDeleteFolder: (folder: FileResource) => void;
/** Open the folder holding a recent file, with the file selected. */
onRevealRecent: (id: string) => void;
onDownloadRecent: (id: string) => void;
onRemoveRecent: (id: string) => void;
/** Items in the trash; the entry shows their number. */
trashCount?: number;
onOpenTrash?: () => void;
/** Delete items dragged onto the trash entry. */
onDropToTrash?: (names: string[]) => void;
}
export function FilesSidebar({
@@ -99,10 +110,15 @@ export function FilesSidebar({
onShare,
onCreateIn,
onRenameFolder,
onMoveFolder,
onDownloadFolder,
onDeleteFolder,
onRevealRecent,
onDownloadRecent,
onRemoveRecent,
trashCount = 0,
onOpenTrash,
onDropToTrash,
}: FilesSidebarProps) {
const t = useTranslations("files");
const newMenu = useContextMenu<null>();
@@ -199,7 +215,7 @@ export function FilesSidebar({
className="p-0.5 -ms-1 rounded hover:bg-foreground/10 text-muted-foreground shrink-0"
aria-label={isOpen ? t("collapse_folder", { name: folder.name }) : t("expand_folder", { name: folder.name })}
>
{isOpen ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5 rtl:rotate-180" />}
{isOpen ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
</button>
) : (
<span className="w-3.5 -ms-1 shrink-0" />
@@ -229,7 +245,7 @@ export function FilesSidebar({
aria-expanded={!collapsed[key]}
>
{collapsed[key]
? <ChevronRight className="w-3 h-3 text-muted-foreground rtl:rotate-180" />
? <ChevronRight className="w-3 h-3 text-muted-foreground" />
: <ChevronDown className="w-3 h-3 text-muted-foreground" />}
{icon}
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider truncate">{label}</span>
@@ -265,13 +281,10 @@ export function FilesSidebar({
<div className="px-3 h-14 box-content border-b border-border flex items-center justify-between flex-shrink-0">
<span className="text-sm font-semibold truncate">{t("title")}</span>
{canCreate && (
<Button
<IconButton
ref={newButtonRef}
size="icon"
variant="ghost"
className="h-8 w-8 flex-shrink-0"
aria-label={t("new")}
title={t("new")}
label={t("new")}
className="flex-shrink-0"
aria-haspopup="menu"
aria-expanded={newMenu.contextMenu.isOpen}
onMouseDown={(e) => { if (newMenu.contextMenu.isOpen) e.stopPropagation(); }}
@@ -280,8 +293,8 @@ export function FilesSidebar({
else newMenu.openContextMenu(anchorEvent(e.currentTarget, "end"), null);
}}
>
<Plus className="w-4 h-4" />
</Button>
<Plus />
</IconButton>
)}
</div>
@@ -300,7 +313,7 @@ export function FilesSidebar({
className="p-0.5 -ms-1 rounded hover:bg-foreground/10 text-muted-foreground shrink-0"
aria-label={collapsed.tree ? t("expand_folder", { name: t("my_files") }) : t("collapse_folder", { name: t("my_files") })}
>
{collapsed.tree ? <ChevronRight className="w-3.5 h-3.5 rtl:rotate-180" /> : <ChevronDown className="w-3.5 h-3.5" />}
{collapsed.tree ? <ChevronRight className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
</button>
) : (
<span className="w-3.5 -ms-1 shrink-0" />
@@ -330,7 +343,7 @@ export function FilesSidebar({
style={{ ...ROW_STYLE, paddingInlineStart: "26px" }}
title={path}
>
<Star className="w-4 h-4 shrink-0 text-amber-500 fill-current" />
<Star className="w-4 h-4 shrink-0 text-amber-400 fill-amber-400" />
<span className="truncate">{path === "/" ? t("my_files") : path.split("/").pop()}</span>
</button>
))}
@@ -386,6 +399,37 @@ export function FilesSidebar({
</div>
)}
{onOpenTrash && (
<div className="mt-3">
<button
type="button"
onClick={onOpenTrash}
aria-current={isTrashLocation(currentParentId) ? "page" : undefined}
className={rowClass(isTrashLocation(currentParentId), dropTarget === "__trash__")}
style={{ ...ROW_STYLE, paddingInlineStart: "12px" }}
onDragOver={(e) => {
if (!isInternalDrag(e) || !onDropToTrash || isTrashLocation(currentParentId)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDropTarget("__trash__");
}}
onDragLeave={() => setDropTarget((cur) => (cur === "__trash__" ? null : cur))}
onDrop={(e) => {
if (!isInternalDrag(e) || !onDropToTrash) return;
e.preventDefault();
setDropTarget(null);
const names = readDraggedNames(e);
if (names.length > 0) onDropToTrash(names);
}}
>
<span className="w-3.5 -ms-1 shrink-0" />
<Trash2 className="w-4 h-4 shrink-0" />
<span className="truncate flex-1">{t("trash")}</span>
{trashCount > 0 && <span className="text-xs text-muted-foreground tabular-nums">{trashCount}</span>}
</button>
</div>
)}
{recent.length === 0 && favorites.length === 0 && (
<p className="px-4 pt-4 text-xs text-muted-foreground flex items-start gap-1.5">
<Clock className="w-3.5 h-3.5 shrink-0 mt-px" />
@@ -420,7 +464,12 @@ export function FilesSidebar({
<>
<ContextMenuItem icon={FolderOpen} label={t("open")} onClick={() => run(() => onNavigate(path, folder.id))} />
{writable && createItems(path, folder.id)}
{/* The list's folder menu in the same order; Cut, Copy and
Details act on the list and are left out here (R6). */}
{(!folder.isShared || canShare(folder)) && <ContextMenuSeparator />}
{canShare(folder) && (
<ContextMenuItem icon={Share2} label={t("share")} onClick={() => run(() => onShare(folder))} />
)}
{!folder.isShared && (
<ContextMenuItem
icon={starred ? StarOff : Star}
@@ -428,13 +477,17 @@ export function FilesSidebar({
onClick={() => run(() => onToggleFavorite(path))}
/>
)}
{canShare(folder) && (
<ContextMenuItem icon={Share2} label={t("share")} onClick={() => run(() => onShare(folder))} />
)}
{(mayRename || mayDelete) && <ContextMenuSeparator />}
<ContextMenuSeparator />
{mayRename && (
<ContextMenuItem icon={Pencil} label={t("rename")} onClick={() => run(() => onRenameFolder(folder))} />
)}
{onMoveFolder && !folder.isShared && mayRename && (
<ContextMenuItem icon={FolderInput} label={t("move_to")} onClick={() => run(() => onMoveFolder(folder))} />
)}
{onDownloadFolder && (
<ContextMenuItem icon={Download} label={t("download_zip")} onClick={() => run(() => onDownloadFolder(folder))} />
)}
{mayDelete && <ContextMenuSeparator />}
{mayDelete && (
<ContextMenuItem icon={Trash2} label={t("delete")} destructive onClick={() => run(() => onDeleteFolder(folder))} />
)}
+178
View File
@@ -0,0 +1,178 @@
"use client";
import { useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { ChevronDown, ChevronRight, Folder, FolderOpen, HardDrive } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { Dialog } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
export interface PickerFolder {
id: string;
name: string;
parentId: string | null;
}
interface FolderPickerDialogProps {
isOpen: boolean;
onClose: () => void;
/** Called with the chosen folder; null is the root ("My files"). */
onPick: (folderId: string | null) => void;
title: string;
confirmText: string;
/** Every folder of the drive; the tree is built from their parent links. */
folders: PickerFolder[];
/** Folders that can't be picked, with what is below them (what is being moved). */
excludeIds?: Set<string>;
/** The folder that is pre-selected and can't be picked (where the items already are). */
currentId?: string | null;
}
const ROOT = "";
/**
* Picks a folder of the user's drive, e.g. to move items there: the folder
* tree with My files at the top, browsed with the mouse or the arrow keys.
*/
export function FolderPickerDialog({
isOpen, onClose, onPick, title, confirmText, folders, excludeIds, currentId = null,
}: FolderPickerDialogProps) {
const t = useTranslations("files");
const [selected, setSelected] = useState<string | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(() => new Set([ROOT]));
const listRef = useRef<HTMLDivElement>(null);
const childrenOf = useMemo(() => {
const map = new Map<string, PickerFolder[]>();
for (const folder of folders) {
if (excludeIds?.has(folder.id)) continue;
const key = folder.parentId ?? ROOT;
const list = map.get(key) ?? [];
list.push(folder);
map.set(key, list);
}
for (const list of map.values()) list.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
return map;
}, [folders, excludeIds]);
// The rows on screen: the root, then every folder whose parents are open.
const rows = useMemo(() => {
const out: { id: string; name: string; depth: number; hasChildren: boolean }[] = [
{ id: ROOT, name: t("my_files"), depth: 0, hasChildren: (childrenOf.get(ROOT)?.length ?? 0) > 0 },
];
const walk = (parent: string, depth: number) => {
if (!expanded.has(parent)) return;
for (const folder of childrenOf.get(parent) ?? []) {
out.push({ id: folder.id, name: folder.name, depth, hasChildren: (childrenOf.get(folder.id)?.length ?? 0) > 0 });
walk(folder.id, depth + 1);
}
};
walk(ROOT, 1);
return out;
}, [childrenOf, expanded, t]);
const current = currentId ?? ROOT;
const canPick = selected !== null && selected !== current;
const toggle = (id: string, open?: boolean) => setExpanded((prev) => {
const next = new Set(prev);
if (open ?? !next.has(id)) next.add(id);
else next.delete(id);
return next;
});
const focusRow = (id: string) => {
setSelected(id);
listRef.current?.querySelector<HTMLElement>(`[data-folder-id="${CSS.escape(id)}"]`)?.focus();
};
const onKeyDown = (e: React.KeyboardEvent) => {
const index = rows.findIndex((r) => r.id === (selected ?? current));
const row = rows[index];
const rtl = getComputedStyle(e.currentTarget).direction === "rtl";
const openKey = rtl ? "ArrowLeft" : "ArrowRight";
const closeKey = rtl ? "ArrowRight" : "ArrowLeft";
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
const next = rows[Math.max(0, Math.min(rows.length - 1, index + (e.key === "ArrowDown" ? 1 : -1)))];
if (next) focusRow(next.id);
} else if (e.key === openKey && row?.hasChildren) {
e.preventDefault();
toggle(row.id, true);
} else if (e.key === closeKey && row) {
e.preventDefault();
if (row.hasChildren && expanded.has(row.id)) toggle(row.id, false);
}
};
const pick = () => {
if (!canPick) return;
onPick(selected === ROOT ? null : selected);
onClose();
};
return (
<Dialog
isOpen={isOpen}
onClose={onClose}
title={title}
onSubmit={(e) => { e.preventDefault(); pick(); }}
footer={
<>
<Button type="button" variant="outline" onClick={onClose}>{t("cancel")}</Button>
<Button type="submit" disabled={!canPick}>{confirmText}</Button>
</>
}
>
<div
ref={listRef}
role="tree"
aria-label={t("folders")}
onKeyDown={onKeyDown}
className="mt-4 max-h-80 overflow-y-auto rounded-md border border-border py-1"
>
{rows.map((row) => {
const isOpen = expanded.has(row.id);
const isSelected = (selected ?? current) === row.id;
const isCurrent = row.id === current;
const Icon = row.id === ROOT ? HardDrive : isOpen && row.hasChildren ? FolderOpen : Folder;
return (
<div
key={row.id || "root"}
role="treeitem"
aria-selected={isSelected}
aria-expanded={row.hasChildren ? isOpen : undefined}
aria-disabled={isCurrent || undefined}
data-folder-id={row.id}
tabIndex={isSelected ? 0 : -1}
onClick={() => setSelected(row.id)}
onDoubleClick={() => { if (row.id !== current) { onPick(row.id === ROOT ? null : row.id); onClose(); } }}
className={cn(
"flex items-center gap-1.5 pe-3 h-9 text-sm cursor-default select-none outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
isSelected ? "bg-selection" : "hover:bg-muted",
isCurrent && "text-muted-foreground",
)}
style={{ paddingInlineStart: `${8 + row.depth * 16}px` }}
>
{row.hasChildren ? (
<button
type="button"
tabIndex={-1}
aria-label={isOpen ? t("collapse_folder", { name: row.name }) : t("expand_folder", { name: row.name })}
onClick={(e) => { e.stopPropagation(); toggle(row.id); }}
className="p-0.5 rounded hover:bg-foreground/10 text-muted-foreground shrink-0"
>
{isOpen ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
</button>
) : (
<span className="w-[18px] shrink-0" />
)}
<Icon className="w-4 h-4 shrink-0 text-primary" />
<span className="truncate" title={row.name}>{row.name}</span>
</div>
);
})}
</div>
</Dialog>
);
}
+12 -55
View File
@@ -1,12 +1,13 @@
"use client";
import { useState, useEffect } from "react";
import { X, Download } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { PreviewShell, type PreviewNeighbours } from "@/components/files/preview-shell";
import { useTranslations } from "next-intl";
import { ZoomableImage } from "@/components/files/zoomable-image";
import { FilePreviewPluginSection } from "@/components/files/file-preview-plugin-section";
import type { FileResourceView } from "@/lib/plugin-types";
import { useCloseOnBack } from "@/hooks/use-close-on-back";
interface ImagePreviewModalProps {
name: string;
@@ -15,14 +16,19 @@ interface ImagePreviewModalProps {
getImageUrl: (name: string) => Promise<string>;
/** The previewed file as plugins see it (file-preview-section). */
file?: FileResourceView | null;
/** The previous and next file of the folder, for ←/→. */
neighbours?: PreviewNeighbours;
}
export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl, file }: ImagePreviewModalProps) {
export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl, file, neighbours }: ImagePreviewModalProps) {
const t = useTranslations("files");
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
// Back closes the preview instead of leaving Files, as for every other preview.
useCloseOnBack(onClose);
useEffect(() => {
let revoke: string | null = null;
let cancelled = false;
@@ -49,61 +55,12 @@ export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl, file
};
}, [name, getImageUrl]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
onClose();
}
};
window.addEventListener("keydown", onKey, { capture: true });
return () => window.removeEventListener("keydown", onKey, { capture: true });
}, [onClose]);
return (
<div
role="dialog"
aria-modal="true"
aria-label={name}
className="fixed inset-0 z-50 flex flex-col bg-black/90"
onClick={onClose}
>
{/* Same safe-area handling as FilePreviewModal: full-screen overlay +
`viewport-fit=cover` puts this bar under the iOS status bar in an
installed PWA (#936). */}
<div
className="flex items-center justify-between gap-3 pl-[calc(1rem+env(safe-area-inset-left))] pr-[calc(0.5rem+env(safe-area-inset-right))] pt-[calc(0.5rem+env(safe-area-inset-top))] pb-2 text-white"
onClick={(e) => e.stopPropagation()}
>
<span className="text-sm font-medium truncate min-w-0">{name}</span>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-9 w-9 text-white hover:bg-white/15 hover:text-white"
onClick={() => void onDownload(name)}
aria-label={t("download")}
title={t("download")}
>
<Download className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 text-white hover:bg-white/15 hover:text-white"
onClick={onClose}
aria-label={t("close_preview")}
title={t("close_preview")}
>
<X className="w-5 h-5" />
</Button>
</div>
</div>
<PreviewShell name={name} onClose={onClose} onDownload={() => void onDownload(name)} neighbours={neighbours}>
<div className="relative flex-1 min-h-0">
{loading && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="w-10 h-10 border-2 border-white/30 border-t-white rounded-full animate-spin" />
<Spinner label={t("loading")} className="w-8 h-8 text-white/80" />
</div>
)}
{error && (
@@ -116,6 +73,6 @@ export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl, file
)}
</div>
<FilePreviewPluginSection file={file} />
</div>
</PreviewShell>
);
}
-63
View File
@@ -1,63 +0,0 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface NewFolderDialogProps {
onConfirm: (name: string) => Promise<void>;
onCancel: () => void;
/** Returns an error message for a name the server would refuse. */
validate?: (name: string) => string | null;
}
export function NewFolderDialog({ onConfirm, onCancel, validate }: NewFolderDialogProps) {
const t = useTranslations("files");
const [name, setName] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const nameError = name.trim() ? validate?.(name.trim()) ?? null : null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = name.trim();
if (!trimmed || validate?.(trimmed)) return;
setIsSubmitting(true);
try {
await onConfirm(trimmed);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
<div
className="bg-background border border-border rounded-lg shadow-lg p-6 w-full max-w-sm mx-4"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-lg font-semibold mb-4">{t("new_folder")}</h2>
<form onSubmit={handleSubmit}>
<Input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("new_folder_name")}
className={nameError ? "mb-1" : "mb-4"}
aria-invalid={!!nameError}
/>
{nameError && <p className="text-xs text-destructive mb-3">{nameError}</p>}
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
{t("cancel")}
</Button>
<Button type="submit" disabled={!name.trim() || !!nameError || isSubmitting}>
{t("create")}
</Button>
</div>
</form>
</div>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { useEffect, type ReactNode } from "react";
import { useTranslations } from "next-intl";
import { ChevronLeft, ChevronRight, Download, X } from "@/components/icons";
import { IconButton } from "@/components/ui/icon-button";
export interface PreviewNeighbours {
/** The previous and next previewable file of the folder, if any. */
previous?: string | null;
next?: string | null;
onGo: (name: string) => void;
}
interface PreviewShellProps {
name: string;
onClose: () => void;
onDownload: () => void;
/** Extra header buttons before Download (e.g. "Open in new tab"). */
actions?: ReactNode;
neighbours?: PreviewNeighbours;
/** Click on the dark area around the content closes the preview. */
closeOnBackdrop?: boolean;
children: ReactNode;
}
/**
* The one frame both file previews use (images and everything else): the
* same dim backdrop, a header with the name, Download and Close, ←/→ to the
* previous and next file of the folder, Escape to close.
*/
export function PreviewShell({ name, onClose, onDownload, actions, neighbours, closeOnBackdrop = true, children }: PreviewShellProps) {
const t = useTranslations("files");
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
onClose();
return;
}
};
// The arrows after the content's own keys: a zoomed image pans with them
// and takes the key (preventDefault), the frame goes to the next file.
const onArrow = (e: KeyboardEvent) => {
if (e.defaultPrevented || !neighbours || e.ctrlKey || e.metaKey || e.altKey) return;
const target = e.target instanceof HTMLElement ? e.target : null;
if (target?.closest("input, textarea, [contenteditable=true]")) return;
const rtl = document.dir === "rtl";
const forward = rtl ? "ArrowLeft" : "ArrowRight";
const back = rtl ? "ArrowRight" : "ArrowLeft";
if (e.key === forward && neighbours.next) {
e.preventDefault();
neighbours.onGo(neighbours.next);
} else if (e.key === back && neighbours.previous) {
e.preventDefault();
neighbours.onGo(neighbours.previous);
}
};
window.addEventListener("keydown", onKey, { capture: true });
window.addEventListener("keydown", onArrow);
return () => {
window.removeEventListener("keydown", onKey, { capture: true });
window.removeEventListener("keydown", onArrow);
};
}, [onClose, neighbours]);
return (
<div
role="dialog"
aria-modal="true"
aria-label={name}
className="fixed inset-0 z-50 flex flex-col bg-black/80"
onClick={closeOnBackdrop ? onClose : undefined}
>
{/* Full-screen under `viewport-fit=cover`: the bar pads itself out of
the iOS status bar (#936). The insets are physical, so pl/pr. */}
<div
className="flex items-center justify-between gap-2 pl-[calc(1rem+env(safe-area-inset-left))] pr-[calc(1rem+env(safe-area-inset-right))] pt-[calc(0.75rem+env(safe-area-inset-top))] pb-3 bg-background/90 backdrop-blur border-b border-border"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-sm font-medium truncate" title={name}>{name}</h3>
<div className="flex items-center gap-1 shrink-0">
{neighbours && (
<>
<IconButton label={t("previous_file")} disabled={!neighbours.previous} onClick={() => neighbours.previous && neighbours.onGo(neighbours.previous)}>
<ChevronLeft />
</IconButton>
<IconButton label={t("next_file")} disabled={!neighbours.next} onClick={() => neighbours.next && neighbours.onGo(neighbours.next)}>
<ChevronRight />
</IconButton>
</>
)}
{actions}
<IconButton label={t("download")} onClick={onDownload}>
<Download />
</IconButton>
<IconButton label={t("close_preview")} onClick={onClose}>
<X />
</IconButton>
</div>
</div>
{children}
</div>
);
}
-66
View File
@@ -1,66 +0,0 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface RenameDialogProps {
currentName: string;
title?: string;
label?: string;
onConfirm: (newName: string) => Promise<void>;
onCancel: () => void;
/** Returns an error message for a name the server would refuse. */
validate?: (name: string) => string | null;
}
export function RenameDialog({ currentName, title, label, onConfirm, onCancel, validate }: RenameDialogProps) {
const t = useTranslations("files");
const [name, setName] = useState(currentName);
const [isSubmitting, setIsSubmitting] = useState(false);
const nameError = name.trim() ? validate?.(name.trim()) ?? null : null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = name.trim();
if (!trimmed || validate?.(trimmed)) return;
setIsSubmitting(true);
try {
await onConfirm(trimmed);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
<div
className="bg-background border border-border rounded-lg shadow-lg p-6 w-full max-w-sm mx-4"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-lg font-semibold mb-4">{title || t("rename_title")}</h2>
<form onSubmit={handleSubmit}>
<Input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={label || t("new_name")}
className={nameError ? "mb-1" : "mb-4"}
aria-invalid={!!nameError}
/>
{nameError && <p className="text-xs text-destructive mb-3">{nameError}</p>}
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
{t("cancel")}
</Button>
<Button type="submit" disabled={!name.trim() || !!nameError || isSubmitting}>
{t("save")}
</Button>
</div>
</form>
</div>
</div>
);
}
+5 -1
View File
@@ -5,6 +5,7 @@ import { useLocale, useTranslations } from "next-intl";
import { Loader2, X } from "@/components/icons";
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
import { apiFetch } from "@/lib/browser-navigation";
import { useCloseOnBack } from "@/hooks/use-close-on-back";
/** What the overlay opens: a Files node, or a mail attachment (read-only, #1047). */
export type WopiDocument =
@@ -21,6 +22,8 @@ interface WopiEditorProps {
*/
slot?: number | null;
onClose: () => void;
/** Back closes the editor (Files); Mail keeps its own history. */
closeOnBack?: boolean;
}
interface LaunchData {
@@ -36,8 +39,9 @@ interface LaunchData {
* a form POST of the access token to the editor URL, targeted at an iframe.
* Mail attachments open in the same overlay as a read-only viewer (#1047).
*/
export function WopiEditor({ target, accountId, slot, onClose }: WopiEditorProps) {
export function WopiEditor({ target, accountId, slot, onClose, closeOnBack = false }: WopiEditorProps) {
const t = useTranslations("files");
useCloseOnBack(onClose, closeOnBack);
const locale = useLocale();
const [launch, setLaunch] = useState<LaunchData | null>(null);
const [error, setError] = useState<string | null>(null);
+5
View File
@@ -1425,6 +1425,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const handleMailtoProtocolRequest = useCallback(async (pending: ParsedMailto) => {
if (!(await mailtoAllowed(pending))) return;
// From another app of this webmail: the account in use, no question.
if (pending.inApp) {
openMailtoDraft(pending);
return;
}
const protocolAccounts = getMailtoProtocolAccounts();
if (protocolAccounts.length > 1) {
setPendingMailtoAccountChoice(pending);
@@ -160,3 +160,12 @@ describe('ShareCollectionDialog directory', () => {
expect(screen.queryByText('no_principals')).toBeNull();
});
});
describe('ShareCollectionDialog focus', () => {
it('takes the focus when it opens and has a named close button', async () => {
renderDialog(fullDirectory());
const dialog = screen.getByRole('dialog');
await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true));
expect(screen.getAllByRole('button', { name: 'close' }).length).toBeGreaterThan(0);
});
});
@@ -11,7 +11,7 @@ import { SettingsSection } from "./settings-section";
import { cn } from "@/lib/utils";
import type { AddressBook, AddressBookRights } from "@/lib/jmap/types";
import { ShareCollectionDialog } from "./share-collection-dialog";
import { RenameDialog } from "@/components/files/rename-dialog";
import { PromptDialog } from "@/components/ui/prompt-dialog";
function AddressBookEditRow({
initial,
@@ -93,7 +93,6 @@ export function AddressBookManagementSettings() {
try {
await createAddressBook(client, name);
await fetchAddressBooks(client);
setCreating(false);
toast.success(t("created"));
} catch {
toast.error(t("create_failed"));
@@ -335,15 +334,14 @@ export function AddressBookManagementSettings() {
</div>
)}
{creating && (
<RenameDialog
currentName=""
title={t("create")}
label={t("name_label")}
onCancel={() => setCreating(false)}
onConfirm={handleCreate}
/>
)}
<PromptDialog
isOpen={creating}
onClose={() => setCreating(false)}
title={t("create")}
placeholder={t("name_label")}
confirmText={t("create_confirm")}
onSubmit={(name) => void handleCreate(name)}
/>
{sharingId && client && (() => {
const book = addressBooks.find((b) => b.id === sharingId);
+3 -22
View File
@@ -1,10 +1,10 @@
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { Folder, FileText, FileCode, ImageIcon, FileAudio, File } from "@/components/icons";
import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup } from "./settings-section";
import { loadFilesSettings, saveFilesSettings, type FilesSettings } from "@/components/files/files-settings";
import { useFilesSettings, type FilesSettings } from "@/components/files/files-settings";
import { cn } from "@/lib/utils";
import { withBasePath } from "@/lib/browser-navigation";
import { useConfig } from "@/hooks/use-config";
@@ -156,26 +156,7 @@ function FilesSettingsPreview({ settings }: { settings: FilesSettings }) {
export function FilesSettingsComponent() {
const t = useTranslations("settings.files");
const [settings, setSettings] = useState<FilesSettings>(loadFilesSettings);
// Listen for external changes (e.g. if file-browser updates settings)
useEffect(() => {
const handleStorage = (e: StorageEvent) => {
if (e.key === "files-settings") {
setSettings(loadFilesSettings());
}
};
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, []);
const update = useCallback((patch: Partial<FilesSettings>) => {
setSettings(prev => {
const next = { ...prev, ...patch };
saveFilesSettings(next);
return next;
});
}, []);
const [settings, update] = useFilesSettings();
return (
<div>
+15 -41
View File
@@ -1,10 +1,11 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import { X, Loader2, UserPlus, Trash2, Users, ChevronDown } from "@/components/icons";
import { Loader2, UserPlus, Trash2, ChevronDown, Share2 } from "@/components/icons";
import { Dialog } from "@/components/ui/dialog";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type { Principal, PrincipalDirectoryStatus, CalendarRights, AddressBookRights, FileNodeRights, MailboxRights } from "@/lib/jmap/types";
import { loadPrincipalDirectory } from "@/lib/jmap/principal-directory";
@@ -151,7 +152,6 @@ export function ShareCollectionDialog({
}: ShareCollectionDialogProps) {
const t = useTranslations("sharing");
const tCommon = useTranslations("common");
const modalRef = useRef<HTMLDivElement>(null);
const [allPrincipals, setAllPrincipals] = useState<Principal[]>([]);
const [directoryStatus, setDirectoryStatus] = useState<PrincipalDirectoryStatus>("ok");
const [directoryQueriesOff, setDirectoryQueriesOff] = useState(false);
@@ -189,15 +189,6 @@ export function ShareCollectionDialog({
return allPrincipals.filter((p) => p.id !== ownAccountId && !existing.has(p.id));
}, [allPrincipals, ownAccountId, shareWith]);
// Close on Escape, focus trap, click outside
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
const handleSetRights = async (principalId: string, preset: RolePreset) => {
if (preset === "custom") return; // custom is read-only here
const rights = presetRights(kind, preset);
@@ -260,30 +251,17 @@ export function ShareCollectionDialog({
: ["read", "readWrite", "manager"] as const;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={onClose} aria-hidden="true" />
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-label={t("title", { name: collectionName })}
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-lg mx-4 animate-in zoom-in-95 duration-200 max-h-[85vh] flex flex-col"
>
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-2">
<Users className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold">{t("title", { name: collectionName })}</h2>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
aria-label={tCommon("close")}
>
<X className="w-5 h-5" />
</button>
</div>
<div className="px-6 py-4 space-y-4 overflow-y-auto">
// The shared dialog shell (R4): focus moves in and comes back, Escape
// and the close X work from anywhere in it.
<Dialog
isOpen
onClose={onClose}
title={t("title", { name: collectionName })}
icon={<Share2 className="w-5 h-5 text-primary shrink-0 mt-1" />}
size="lg"
footer={<Button onClick={onClose}>{tCommon("close")}</Button>}
>
<div className="pt-4 space-y-4">
<p className="text-sm text-muted-foreground">{t("description")}</p>
{sharedEntries.length === 0 && !showAdd && (
@@ -440,10 +418,6 @@ export function ShareCollectionDialog({
)}
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
<Button onClick={onClose}>{tCommon("close")}</Button>
</div>
</div>
</div>
</Dialog>
);
}
+81
View File
@@ -0,0 +1,81 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { generateUUID } from "@/lib/utils";
// Closing one layer can leave an asynchronous Back traversal in flight. The
// next layer must not install its popstate listener until that completes, or
// the traversal would close it at once.
let pendingHistoryBack: Promise<void> | null = null;
/**
* Lets the browser's Back (and the phone's back gesture) close an overlay
* instead of leaving the page: while `active`, the overlay holds a history
* entry of its own, and Back calls `onBack`. Closed any other way, the entry
* is taken back off, so the history ends where it was.
*
* `onBack` runs after the entry is gone. A layer that decides to stay open
* (an editor that asked "Discard changes?" and heard "Keep editing") calls
* the returned `rearm` to hold an entry again. `armed` tells whether the
* entry is there now, e.g. before a `router.back()` that must leave for real.
*/
export function useCloseOnBack(onBack: () => void, active = true): { rearm: () => void; armed: () => boolean } {
const onBackRef = useRef(onBack);
onBackRef.current = onBack;
const rearmRef = useRef<() => void>(() => {});
const armedRef = useRef(false);
useEffect(() => {
if (!active) return;
let cancelled = false;
let entryId: string | null = null;
let poppedByBack = false;
const onPop = () => {
if (!entryId) return;
entryId = null;
armedRef.current = false;
poppedByBack = true;
window.removeEventListener("popstate", onPop);
onBackRef.current();
};
const push = () => {
if (cancelled || entryId) return;
entryId = generateUUID();
armedRef.current = true;
poppedByBack = false;
window.history.pushState({ ...window.history.state, __filePreview: entryId }, "");
window.addEventListener("popstate", onPop);
};
rearmRef.current = push;
// Deferred, so Strict Mode's setup/cleanup/setup replay can cancel the
// first setup without scheduling a browser Back traversal.
const timer = window.setTimeout(async () => {
if (pendingHistoryBack) await pendingHistoryBack;
push();
}, 0);
return () => {
cancelled = true;
rearmRef.current = () => {};
armedRef.current = false;
window.clearTimeout(timer);
window.removeEventListener("popstate", onPop);
const ours = entryId;
if (ours && !poppedByBack && window.history.state?.__filePreview === ours) {
pendingHistoryBack = new Promise<void>((resolve) => {
window.addEventListener("popstate", () => {
pendingHistoryBack = null;
resolve();
}, { once: true });
});
window.history.back();
}
};
}, [active]);
// The same object every render, so callers can list it as a dependency.
const [api] = useState(() => ({ rearm: () => rearmRef.current(), armed: () => armedRef.current }));
return api;
}
+15
View File
@@ -0,0 +1,15 @@
import { describe, it, expect } from 'vitest';
import { formatContactDate } from '../contact-dates';
describe('contact dates in the UI language', () => {
it('writes a birthday the German way in German, not "Mar 15 1988"', () => {
expect(formatContactDate({ year: 1988, month: 3, day: 15 }, 'de')).toBe('15. März 1988');
expect(formatContactDate('1988-03-15', 'de')).toBe('15. März 1988');
});
it('leaves out what the date does not name', () => {
expect(formatContactDate('--03-15', 'en')).toBe('March 15');
expect(formatContactDate({ year: 1988 }, 'en')).toBe('1988');
expect(formatContactDate('--02-29', 'fr')).toBe('29 février');
});
});
+56
View File
@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest';
import JSZip from 'jszip';
import type { FileNode } from '@/lib/jmap/types';
import { buildZip, planZip } from '../files-zip';
const dir = (id: string, name: string, parentId: string | null): FileNode => ({
id, parentId, name, type: '', blobId: null, size: 0, created: '', modified: '',
});
const file = (id: string, name: string, parentId: string | null, size = 4): FileNode => ({
id, parentId, name, type: 'text/plain', blobId: `b-${id}`, size, created: '', modified: '2026-10-01T10:00:00Z',
});
const DRIVE = [
dir('taxes', 'Taxes', null),
dir('y25', '2025', 'taxes'),
dir('empty', 'Drafts', 'taxes'),
file('ret', 'return.pdf', 'y25'),
file('note', 'notes.txt', 'taxes'),
file('other', 'other.txt', null),
];
const fetchFile = async (node: FileNode) => new Blob([`body of ${node.id}`]);
describe('folder zip', () => {
it('packs a folder with everything in it under its own name, empty folders included', () => {
const plan = planZip(DRIVE, ['taxes']);
expect(plan.files.map(f => f.path).sort()).toEqual(['Taxes/2025/return.pdf', 'Taxes/notes.txt']);
expect(plan.emptyFolders).toEqual(['Taxes/Drafts']);
expect(plan.totalBytes).toBe(8);
});
it('numbers two picked items of the same name instead of overwriting one', () => {
const plan = planZip([file('a', 'x.txt', null), file('b', 'x.txt', 'taxes'), dir('taxes', 'Taxes', null)], ['a', 'b']);
expect(plan.files.map(f => f.path)).toEqual(['x.txt', 'x (1).txt']);
});
it('builds a zip that holds the files', async () => {
const progress: number[] = [];
const blob = await buildZip(planZip(DRIVE, ['taxes', 'other']), fetchFile, { onProgress: (p) => progress.push(p.progress) });
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
expect(await zip.file('Taxes/2025/return.pdf')!.async('string')).toBe('body of ret');
expect(await zip.file('other.txt')!.async('string')).toBe('body of other');
expect(zip.folder('Taxes/Drafts')).not.toBeNull();
expect(progress[progress.length - 1]).toBeCloseTo(1);
});
it('stops when canceled', async () => {
const controller = new AbortController();
const plan = planZip(DRIVE, ['taxes']);
const slowFetch = async (node: FileNode) => {
controller.abort();
return fetchFile(node);
};
await expect(buildZip(plan, slowFetch, { signal: controller.signal })).rejects.toMatchObject({ name: 'AbortError' });
});
});
+45
View File
@@ -0,0 +1,45 @@
import type { AnniversaryDate, PartialDate } from "@/lib/jmap/types";
/**
* A contact's date (birthday, anniversary, created) in the UI language:
* "15. März 1988", "March 15", "1988". A date without a year (vCard
* "--03-15") or without a day shows only what it has.
*/
export function formatContactDate(dateInput: AnniversaryDate | string, locale: string): string {
const parts = contactDateParts(dateInput);
const { year, month, day } = parts;
try {
if (month && day) {
// A leap year, so 29 February survives without a year.
const d = new Date(Date.UTC(year ?? 2000, month - 1, day));
return new Intl.DateTimeFormat(locale, { timeZone: "UTC", month: "long", day: "numeric", ...(year ? { year: "numeric" } : {}) }).format(d);
}
if (month) {
const d = new Date(Date.UTC(year ?? 2000, month - 1, 1));
return new Intl.DateTimeFormat(locale, { timeZone: "UTC", month: "long", ...(year ? { year: "numeric" } : {}) }).format(d);
}
if (year) return String(year);
} catch { /* an unknown locale: below */ }
return typeof dateInput === "string" ? dateInput : JSON.stringify(dateInput);
}
/** The year, month and day a contact's date names, whichever it has. */
export function contactDateParts(dateInput: AnniversaryDate | string): { year?: number; month?: number; day?: number } {
if (typeof dateInput === "object" && dateInput !== null) {
if (dateInput["@type"] === "Timestamp" && typeof (dateInput as { utc?: unknown }).utc === "string") {
const d = new Date((dateInput as { utc: string }).utc);
return isNaN(d.getTime()) ? {} : { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };
}
const pd = dateInput as PartialDate;
return { year: pd.year, month: pd.month, day: pd.day };
}
const s = String(dateInput);
if (s.startsWith("--")) {
const [m, d] = s.substring(2).split("-");
return { month: parseInt(m, 10) || undefined, day: d ? parseInt(d, 10) || undefined : undefined };
}
const iso = /^(\d{4})-(\d{2})(?:-(\d{2}))?/.exec(s);
if (iso) return { year: Number(iso[1]), month: Number(iso[2]), day: iso[3] ? Number(iso[3]) : undefined };
const d = new Date(s);
return isNaN(d.getTime()) ? {} : { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };
}
+11
View File
@@ -1408,7 +1408,18 @@ export class DemoJMAPClient implements IJMAPClient {
}
async destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> {
// Like the server: a folder goes with everything in it (emptying the trash).
const idSet = new Set(ids);
let grew = true;
while (grew) {
grew = false;
for (const n of this.data.fileNodes) {
if (n.parentId && idSet.has(n.parentId) && !idSet.has(n.id)) {
idSet.add(n.id);
grew = true;
}
}
}
this.data.fileNodes = this.data.fileNodes.filter(n => !idSet.has(n.id));
return { destroyed: ids, notDestroyed: [] };
}
+45
View File
@@ -0,0 +1,45 @@
// The Files app's preferences, part of the synced settings (settings store
// key `filesSettings`), so Export, Reset and other devices include them.
export interface FilesSettings {
defaultViewMode: "list" | "grid";
showIcons: boolean;
coloredIcons: boolean;
defaultSortKey: "name" | "size" | "modified";
defaultSortDir: "asc" | "desc";
showHiddenFiles: boolean;
showThumbnails: boolean;
}
export const DEFAULT_FILES_SETTINGS: FilesSettings = {
defaultViewMode: "list",
showIcons: true,
coloredIcons: true,
defaultSortKey: "name",
defaultSortDir: "asc",
showHiddenFiles: false,
showThumbnails: true,
};
const CHOICES: { [K in keyof FilesSettings]: readonly FilesSettings[K][] } = {
defaultViewMode: ["list", "grid"],
showIcons: [true, false],
coloredIcons: [true, false],
defaultSortKey: ["name", "size", "modified"],
defaultSortDir: ["asc", "desc"],
showHiddenFiles: [true, false],
showThumbnails: [true, false],
};
/** Files settings from an import, the server or an old build: known keys with valid values, the rest default. */
export function normalizeFilesSettings(value: unknown): FilesSettings {
const out: FilesSettings = { ...DEFAULT_FILES_SETTINGS };
if (typeof value !== "object" || value === null || Array.isArray(value)) return out;
const record = value as Record<string, unknown>;
for (const key of Object.keys(CHOICES) as (keyof FilesSettings)[]) {
if ((CHOICES[key] as readonly unknown[]).includes(record[key])) {
(out as unknown as Record<string, unknown>)[key] = record[key];
}
}
return out;
}
+94
View File
@@ -0,0 +1,94 @@
import type { FileNode } from '@/lib/jmap/types';
import { fileNodeKind } from '@/lib/jmap/filenode-kind';
/*
* The Files trash. Neither Stalwart 0.16 nor 1.0 keeps deleted FileNodes:
* FileNode/set destroy removes a node and everything below it for good (1.0
* can label a folder `role: "trash"`, 0.16 ignores the role, and neither moves
* anything there). So the trash is a folder the app manages: `.Trash` at the
* root, hidden from every listing. Each delete puts its items into a folder
* of its own inside it, named after when it happened and where the items came
* from, so Restore can put them back and two deleted files of the same name
* never clash:
*
* /.Trash/1730112000000~<parentId>/report.pdf
*
* The name is the record, so a WebDAV client or another device sees the same
* trash, and nothing else has to be stored.
*/
export const TRASH_FOLDER_NAME = '.Trash';
/** The trash as a place in the Files view. JMAP ids never contain "*". */
export const TRASH_LOCATION = '*trash';
/** The trash's path, for links and the breadcrumb. */
export const TRASH_PATH = `/${TRASH_FOLDER_NAME}`;
export function isTrashLocation(id: string | null | undefined): boolean {
return id === TRASH_LOCATION;
}
/** The trash folder of the account's own drive, if there is one yet. */
export function findTrashFolder(nodes: FileNode[]): FileNode | undefined {
return nodes.find(n =>
(n.parentId ?? null) === null
&& n.name === TRASH_FOLDER_NAME
&& !n.isShared
&& !n.id.includes(':')
&& fileNodeKind(n) === 'directory');
}
/** The name of the folder one delete's items go into. */
export function trashBatchName(parentId: string | null, deletedAt: number = Date.now()): string {
return `${deletedAt}~${parentId ?? ''}`;
}
/** When a batch was deleted and from which folder (null: the root); null for any other name. */
export function parseTrashBatchName(name: string): { deletedAt: number; parentId: string | null } | null {
const match = /^(\d{10,})~([A-Za-z0-9_-]*)$/.exec(name);
if (!match) return null;
return { deletedAt: Number(match[1]), parentId: match[2] || null };
}
/** The trash folder and everything in it, to keep out of normal listings. */
export function trashSubtreeIds(nodes: FileNode[], trashId: string | undefined): Set<string> {
const ids = new Set<string>();
if (!trashId) return ids;
ids.add(trashId);
let grew = true;
while (grew) {
grew = false;
for (const n of nodes) {
if (n.parentId && ids.has(n.parentId) && !ids.has(n.id)) {
ids.add(n.id);
grew = true;
}
}
}
return ids;
}
export interface TrashedItem {
node: FileNode;
/** The delete's folder in the trash; null for something put into the trash directly. */
batchId: string | null;
deletedAt: number | null;
/** Where it came from; null for the root or when unknown. */
fromParentId: string | null;
}
/** What the trash holds, one entry per deleted item. */
export function trashedItems(nodes: FileNode[], trash: FileNode | undefined): TrashedItem[] {
if (!trash) return [];
const items: TrashedItem[] = [];
for (const child of nodes.filter(n => n.parentId === trash.id)) {
const batch = fileNodeKind(child) === 'directory' ? parseTrashBatchName(child.name) : null;
if (!batch) {
items.push({ node: child, batchId: null, deletedAt: null, fromParentId: null });
continue;
}
for (const node of nodes.filter(n => n.parentId === child.id)) {
items.push({ node, batchId: child.id, deletedAt: batch.deletedAt, fromParentId: batch.parentId });
}
}
return items;
}
+126
View File
@@ -0,0 +1,126 @@
import type { FileNode } from '@/lib/jmap/types';
import { fileNodeKind } from '@/lib/jmap/filenode-kind';
/*
* Downloading folders: the server has no archive download, so the browser
* fetches every file below the folders and packs them into one zip (jszip),
* with the folders' own names at the top.
*/
export interface ZipEntry {
/** Path inside the zip, e.g. "Taxes/2025/return.pdf". */
path: string;
node: FileNode;
}
export interface ZipPlan {
files: ZipEntry[];
/** Folders with nothing in them, kept so the zip has the same shape. */
emptyFolders: string[];
/** Bytes to fetch, for the progress. */
totalBytes: number;
}
/** What goes into the zip of some items: files as they are, folders with everything below them. */
export function planZip(nodes: FileNode[], ids: string[]): ZipPlan {
const byParent = new Map<string | null, FileNode[]>();
for (const n of nodes) {
const key = n.parentId ?? null;
const list = byParent.get(key) ?? [];
list.push(n);
byParent.set(key, list);
}
const plan: ZipPlan = { files: [], emptyFolders: [], totalBytes: 0 };
const used = new Set<string>();
const unique = (path: string) => {
if (!used.has(path)) {
used.add(path);
return path;
}
const slash = path.lastIndexOf('/');
const dir = path.slice(0, slash + 1);
const name = path.slice(slash + 1);
const dot = name.lastIndexOf('.');
const base = dot > 0 ? name.slice(0, dot) : name;
const ext = dot > 0 ? name.slice(dot) : '';
let n = 1;
while (used.has(`${dir}${base} (${n})${ext}`)) n++;
const next = `${dir}${base} (${n})${ext}`;
used.add(next);
return next;
};
const seen = new Set<string>();
const add = (node: FileNode, prefix: string) => {
if (seen.has(node.id)) return;
seen.add(node.id);
const kind = fileNodeKind(node);
// A symlink (Stalwart 1.0) has no content to pack.
if (kind === 'symlink') return;
const path = unique(prefix + node.name.replace(/[\\/]/g, '_'));
if (kind === 'directory') {
const children = byParent.get(node.id) ?? [];
if (children.length === 0) plan.emptyFolders.push(path);
for (const child of children) add(child, `${path}/`);
return;
}
if (!node.blobId) return;
plan.files.push({ path, node });
plan.totalBytes += node.size || 0;
};
for (const id of ids) {
const node = nodes.find(n => n.id === id);
if (node) add(node, '');
}
return plan;
}
export interface ZipProgress {
/** 0..1 over fetching and packing. */
progress: number;
/** The file being fetched, while fetching. */
current?: string;
}
/** Fetch the plan's files and pack them. Throws an AbortError when `signal` aborts. */
export async function buildZip(
plan: ZipPlan,
fetchFile: (node: FileNode) => Promise<Blob>,
options: { signal?: AbortSignal; onProgress?: (p: ZipProgress) => void } = {},
): Promise<Blob> {
const { signal, onProgress } = options;
const { default: JSZip } = await import('jszip');
const zip = new JSZip();
const abortIfAsked = () => {
if (signal?.aborted) throw new DOMException('The download was canceled', 'AbortError');
};
for (const folder of plan.emptyFolders) zip.folder(folder);
// Fetching is most of the work; packing gets the last tenth.
let fetched = 0;
for (const entry of plan.files) {
abortIfAsked();
onProgress?.({ progress: plan.totalBytes > 0 ? (fetched / plan.totalBytes) * 0.9 : 0, current: entry.path });
zip.file(entry.path, await fetchFile(entry.node), { date: entry.node.modified ? new Date(entry.node.modified) : undefined });
fetched += entry.node.size || 0;
}
abortIfAsked();
const blob = await zip.generateAsync(
// Photos, videos and office files are compressed already; a light level
// keeps big folders quick.
{ type: 'blob', compression: 'DEFLATE', compressionOptions: { level: 1 } },
(meta) => onProgress?.({ progress: 0.9 + (meta.percent / 100) * 0.1 }),
);
abortIfAsked();
return blob;
}
/** Hand a blob to the browser as a download. */
export function saveBlob(blob: Blob, name: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
+30 -4
View File
@@ -1,6 +1,32 @@
import type { ShortcutSection } from "./types";
// contacts's keys for the help modal: one or more sections like MAIL_SECTIONS
// in ./mail.ts. Empty sections are not drawn, and an app without any rows
// gets no tab.
export const CONTACTS_SECTIONS: readonly ShortcutSection[] = [];
// Contacts' keys (components/contacts/contact-list.tsx handles them).
export const CONTACTS_SECTIONS: readonly ShortcutSection[] = [
{
id: "list",
title: "contacts.shortcuts.list",
entries: [
{ key: "↓ / ↑", description: "contacts.shortcuts.move" },
{ key: "Home / End", description: "contacts.shortcuts.first_last" },
{ key: "Shift + ↓ / ↑", description: "contacts.shortcuts.extend" },
{ key: "Enter", description: "contacts.shortcuts.open" },
{ key: "Space", description: "contacts.shortcuts.toggle" },
{ key: "Mod + A", description: "contacts.shortcuts.select_all" },
{ key: "Esc", description: "contacts.shortcuts.clear" },
],
},
{
id: "actions",
title: "contacts.shortcuts.actions",
entries: [
{ key: "Del / Backspace", description: "contacts.shortcuts.delete" },
{ key: "Shift + F10", description: "contacts.shortcuts.menu" },
],
},
{
id: "global",
title: "shortcuts.sections.global",
wide: true,
entries: [{ key: "/", description: "contacts.shortcuts.search" }],
},
];
+35 -4
View File
@@ -1,6 +1,37 @@
import type { ShortcutSection } from "./types";
// files's keys for the help modal: one or more sections like MAIL_SECTIONS
// in ./mail.ts. Empty sections are not drawn, and an app without any rows
// gets no tab.
export const FILES_SECTIONS: readonly ShortcutSection[] = [];
// Files' keys (components/files/file-browser.tsx handles them).
export const FILES_SECTIONS: readonly ShortcutSection[] = [
{
id: "list",
title: "files.shortcuts.list",
entries: [
{ key: "↓ / ↑", description: "files.shortcuts.move" },
{ key: "← / →", description: "files.shortcuts.move_grid" },
{ key: "Home / End", description: "files.shortcuts.first_last" },
{ key: "Shift + ↓ / ↑", description: "files.shortcuts.extend" },
{ key: "Enter", description: "files.shortcuts.open" },
{ key: "Space", description: "files.shortcuts.toggle" },
{ key: "Mod + A", description: "files.shortcuts.select_all" },
{ key: "Esc", description: "files.shortcuts.clear" },
{ key: "Alt + ↑", description: "files.shortcuts.up" },
],
},
{
id: "actions",
title: "files.shortcuts.actions",
entries: [
{ key: "F2", description: "files.shortcuts.rename" },
{ key: "Del / Backspace", description: "files.shortcuts.delete" },
{ key: "Mod + C", description: "files.shortcuts.copy" },
{ key: "Mod + X", description: "files.shortcuts.cut" },
{ key: "Mod + V", description: "files.shortcuts.paste" },
],
},
{
id: "global",
title: "shortcuts.sections.global",
wide: true,
entries: [{ key: "/ / Mod + F", description: "files.shortcuts.search" }],
},
];
+5
View File
@@ -14,6 +14,11 @@ export interface ParsedMailto {
htmlBody?: string;
/** Only from api.compose.open: files the plugin uploaded, attached as they are. */
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>;
/**
* Started in the app itself (Contacts' "Send email"), not by the OS: it is
* written from the account in use, without asking which one.
*/
inApp?: boolean;
}
const MAX_RECIPIENTS = 200;
+95 -12
View File
@@ -2915,7 +2915,7 @@
"add_cc": "إضافة إلى «نسخة»",
"add_bcc": "إضافة إلى «نسخة مخفية»",
"select_hint": "اختر شخصًا لعرض تفاصيله",
"write_email": "كتابة رسالة",
"write_email": "إرسال بريد إلكتروني",
"add_to_contacts": "إضافة إلى جهات الاتصال",
"email": "البريد",
"read_only_note": "تأتي إدخالات الدليل من خادم البريد ولا يمكن تعديلها هنا.",
@@ -2928,6 +2928,11 @@
}
},
"title": "جهات الاتصال",
"disabled_title": "عطّل المسؤول جهات الاتصال",
"disabled_description": "تواصل مع المسؤول إذا كنت بحاجة إليها.",
"drag_count": "{count, plural, one {جهة اتصال واحدة} other {# جهة اتصال}}",
"select_contact": "تحديد {name}",
"new": "جديد",
"search_placeholder": "بحث في جهات الاتصال...",
"create_new": "جهة اتصال جديدة",
"no_category": "بلا فئة",
@@ -2935,11 +2940,17 @@
"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": "لا توجد جهات اتصال بعد",
"empty_state_title": "لا توجد جهات اتصال بعد",
"empty_state_subtitle": "أنشئ أول جهة اتصال أو استوردها من ملف vCard",
"empty_view_title": "لا توجد جهات اتصال هنا",
"empty_view_hint": "أضف جهة اتصال أو استورد ملف vCard.",
"empty_search": "لا توجد جهات اتصال مطابقة لبحثك",
"empty_search_hint": "جرّب مصطلح بحث مختلف",
"empty_filtered": "لا توجد جهات اتصال مطابقة لمرشحاتك",
@@ -2952,6 +2963,7 @@
"back_to_contacts": "العودة إلى جهات الاتصال",
"back_to_email": "العودة إلى البريد",
"open_categories": "فتح الفئات",
"row_actions": "إجراءات {name}",
"tabs": {
"all": "الكل",
"groups": "المجموعات"
@@ -2961,6 +2973,7 @@
},
"address_books": {
"create": "دفتر عناوين جديد",
"create_confirm": "إنشاء",
"created": "تم إنشاء دفتر العناوين",
"create_failed": "فشل إنشاء دفتر العناوين",
"title": "دفاتر عناويني",
@@ -2983,7 +2996,10 @@
"delete": "حذف دفتر العناوين",
"confirm_delete": "حذف \"{name}\"؟ ستتم إزالة جميع جهات الاتصال في دفتر العناوين هذا.",
"deleted": "تم حذف دفتر العناوين",
"delete_failed": "فشل حذف دفتر العناوين"
"delete_failed": "فشل حذف دفتر العناوين",
"move_to": "نقل إلى…",
"move_title": "نقل إلى دفتر عناوين",
"move_here": "نقل إلى هنا"
},
"detail": {
"emails": "عناوين البريد الإلكتروني",
@@ -3009,7 +3025,7 @@
"cert_imported": "تم استيراد الشهادة إلى مخزن S/MIME",
"cert_import_failed": "فشل استيراد الشهادة",
"no_contact_selected": "اختر جهة اتصال لعرض التفاصيل",
"compose_email": "إنشاء رسالة",
"compose_email": "إرسال بريد إلكتروني",
"copy_email": "نسخ البريد الإلكتروني",
"copy_phone": "نسخ رقم الهاتف",
"copy_url": "نسخ الرابط",
@@ -3066,8 +3082,8 @@
"form": {
"create_title": "جهة اتصال جديدة",
"edit_title": "تعديل جهة الاتصال",
"section_address_book": "الدليل",
"select_address_book": "اختر دليلًا...",
"section_address_book": "دفتر العناوين",
"select_address_book": "دفتر العناوين",
"section_identity": "الاسم والهوية",
"contact_type": "نوع جهة الاتصال",
"type_person": "شخص",
@@ -3154,6 +3170,13 @@
"add_phone": "إضافة هاتف",
"save": "حفظ",
"cancel": "إلغاء",
"discard_title": "تجاهل التغييرات؟",
"discard_message": "لم يتم حفظ تغييراتك.",
"discard": "تجاهل",
"keep_editing": "متابعة التعديل",
"remove_entry": "إزالة",
"close": "إغلاق",
"remove_category": "إزالة {name}",
"creating": "جارٍ الإنشاء...",
"updating": "جارٍ التحديث...",
"name_required": "أدخل اسمًا أول أو اسم عائلة أو مؤسسة",
@@ -3170,6 +3193,8 @@
},
"groups": {
"create": "مجموعة جديدة",
"delete": "حذف المجموعة",
"remove_member": "إزالة {name} من المجموعة",
"edit": "تعديل المجموعة",
"empty": "لا توجد مجموعات بعد",
"delete_confirm_title": "حذف المجموعة",
@@ -3182,7 +3207,7 @@
"search_members": "بحث عن جهات اتصال لإضافتها...",
"no_members": "لا يوجد أعضاء في هذه المجموعة",
"member_count": "{count, plural, =0 {لا يوجد أعضاء} one {عضو واحد} other {# أعضاء}}",
"send_email": "إرسال بريد إلكتروني إلى المجموعة",
"send_email": "إرسال بريد إلكتروني",
"send_email_to": "إلى",
"send_email_cc": "نسخة",
"send_email_bcc": "نسخة مخفية",
@@ -3220,6 +3245,7 @@
"deleted": "{count, plural, one {تم حذف جهة اتصال واحدة} other {تم حذف # جهة اتصال}}",
"add_to_group": "إضافة إلى مجموعة",
"choose_group": "اختر مجموعة",
"new_group": "مجموعة جديدة…",
"adding_contacts": "جارٍ إضافة {count, plural, one {جهة اتصال واحدة} other {# جهة اتصال}}",
"added_to_group": "تمت إضافة جهات الاتصال إلى المجموعة",
"export": "تصدير",
@@ -3266,6 +3292,20 @@
"sort_by": "الترتيب حسب",
"sort_first_name": "الاسم الأول",
"sort_last_name": "اسم العائلة"
},
"shortcuts": {
"list": "القائمة",
"actions": "الإجراءات",
"first_last": "الأول أو الأخير",
"extend": "توسيع التحديد",
"toggle": "تحديد أو إلغاء التحديد",
"select_all": "تحديد الكل",
"clear": "مسح التحديد أو البحث",
"delete": "حذف",
"search": "البحث",
"move": "جهة الاتصال التالية أو السابقة",
"open": "فتح جهة الاتصال",
"menu": "فتح قائمة جهة الاتصال"
}
},
"calendar": {
@@ -3946,8 +3986,6 @@
"name_control_chars": "لا يمكن أن تحتوي الأسماء على أحرف تحكم.",
"name_too_long": "هذا الاسم طويل جدًا بالنسبة للخادم.",
"delete": "حذف",
"delete_confirm_title": "حذف المورد",
"delete_confirm_message": "هل أنت متأكد أنك تريد حذف \"{name}\"؟ لا يمكن التراجع عن هذا.",
"download": "تنزيل",
"name": "الاسم",
"size": "الحجم",
@@ -3973,18 +4011,57 @@
"error_partial_copy": "بقي جزء من النسخة في مكانه.",
"create_folder_success": "تم إنشاء المجلد",
"create_folder_error": "فشل إنشاء المجلد",
"delete_success": "تم الحذف بنجاح",
"delete_error": "فشل الحذف",
"trash": "سلة المهملات",
"restore": "استعادة",
"restored": "{count, plural, one {تمت استعادة العنصر} other {تمت استعادة # عنصر}}",
"restore_error": "تعذّرت الاستعادة",
"moved_to_trash": "{count, plural, one {نُقل إلى سلة المهملات} other {نُقل # عنصر إلى سلة المهملات}}",
"delete_forever": "حذف نهائي",
"delete_forever_title": "{count, plural, one {حذف نهائي؟} other {حذف # عنصر نهائيًا؟}}",
"delete_forever_message": "سيُحذف \"{name}\". لا يمكنك التراجع عن ذلك.",
"delete_forever_message_count": "{count, plural, one {سيُحذف عنصر واحد} other {سيُحذف # عنصر}}. لا يمكنك التراجع عن ذلك.",
"deleted_forever": "{count, plural, one {حُذف نهائيًا} other {حُذف # عنصر نهائيًا}}",
"empty_trash": "إفراغ سلة المهملات",
"empty_trash_title": "إفراغ سلة المهملات؟",
"empty_trash_message": "{count, plural, one {سيُحذف العنصر الموجود في سلة المهملات} other {ستُحذف جميع العناصر (#) في سلة المهملات}} نهائيًا. لا يمكنك التراجع عن ذلك.",
"trash_emptied": "أُفرغت سلة المهملات",
"empty_trash_error": "تعذّر إفراغ سلة المهملات",
"trash_empty_title": "سلة المهملات فارغة",
"trash_empty_hint": "تبقى الملفات المحذوفة هنا حتى تُفرغ سلة المهملات.",
"deleted": "تاريخ الحذف",
"deleted_from": "حُذف من {folder}",
"shortcuts": {
"list": "القائمة",
"actions": "الإجراءات",
"first_last": "الأول أو الأخير",
"extend": "توسيع التحديد",
"toggle": "تحديد أو إلغاء التحديد",
"select_all": "تحديد الكل",
"clear": "مسح التحديد أو البحث",
"delete": "حذف",
"search": "البحث",
"move": "العنصر التالي أو السابق",
"move_grid": "العنصر المجاور في الشبكة",
"open": "فتح",
"up": "مجلد واحد للأعلى",
"rename": "إعادة التسمية",
"copy": "نسخ",
"cut": "قص",
"paste": "لصق"
},
"rename_success": "تمت إعادة التسمية بنجاح",
"rename_error": "فشلت إعادة التسمية",
"download_error": "فشل التنزيل",
"download_zip": "تنزيل كملف ZIP",
"zipping": "جارٍ تجهيز {name}",
"download_canceled": "أُلغي التنزيل",
"zip_error": "تعذّر تنزيل {name}",
"not_available": "تخزين الملفات غير متاح على هذا الخادم",
"cancel": "إلغاء",
"create": "إنشاء",
"save": "حفظ",
"no_results": "لا توجد ملفات مطابقة لبحثك",
"batch_delete_confirm_message": "هل أنت متأكد أنك تريد حذف {count, plural, one {عنصر واحد} other {# عنصر}}؟ لا يمكن التراجع عن هذا.",
"batch_delete_success": "{count, plural, one {تم حذف عنصر واحد} other {تم حذف # عنصر}}",
"grid_view": "عرض شبكي",
"list_view": "عرض قائمة",
"details": "التفاصيل",
@@ -3996,6 +4073,9 @@
"paste": "لصق",
"move_success": "{count, plural, one {تم نقل عنصر واحد} other {تم نقل # عنصر}}",
"move_error": "فشل النقل",
"move_to": "نقل إلى…",
"move_to_title": "نقل إلى",
"move_here": "نقل إلى هنا",
"paste_success": "تم اللصق بنجاح",
"paste_error": "فشل اللصق",
"new_text_file": "ملف نصي جديد",
@@ -4075,7 +4155,10 @@
"zoom_out": "تصغير",
"zoom_reset": "ملاءمة الشاشة",
"rotate": "تدوير",
"close_preview": "إغلاق"
"close_preview": "إغلاق",
"previous_file": "الملف السابق",
"next_file": "الملف التالي",
"loading": "جارٍ التحميل…"
},
"smime": {
"your_certificates": "شهاداتك",
+95 -12
View File
@@ -2915,7 +2915,7 @@
"add_cc": "Afegeix a A/c",
"add_bcc": "Afegeix a C/o",
"select_hint": "Selecciona algú per veure'n els detalls",
"write_email": "Escriu un correu",
"write_email": "Envia un correu",
"add_to_contacts": "Afegeix als contactes",
"email": "Correu",
"read_only_note": "Les entrades del directori vénen del teu servidor de correu i no es poden editar aquí.",
@@ -2928,6 +2928,11 @@
}
},
"title": "Contactes",
"disabled_title": "L'administrador ha desactivat els contactes",
"disabled_description": "Contacteu amb l'administrador si els necessiteu.",
"drag_count": "{count, plural, one {1 contacte} other {# contactes}}",
"select_contact": "Selecciona {name}",
"new": "Nou",
"search_placeholder": "Cerca contactes...",
"create_new": "Contacte nou",
"no_category": "Sense categoria",
@@ -2935,11 +2940,17 @@
"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",
"empty_state_title": "Encara no hi ha contactes",
"empty_state_subtitle": "Creeu el primer contacte o importeu-lo des d'un fitxer vCard",
"empty_view_title": "No hi ha contactes aquí",
"empty_view_hint": "Afegiu un contacte o importeu un fitxer vCard.",
"empty_search": "Cap contacte coincideix amb la cerca",
"empty_search_hint": "Proveu amb un altre terme de cerca",
"empty_filtered": "Cap contacte coincideix amb els filtres",
@@ -2952,6 +2963,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"
@@ -2961,6 +2973,7 @@
},
"address_books": {
"create": "Llibreta d'adreces nova",
"create_confirm": "Crea",
"created": "Llibreta d'adreces creada",
"create_failed": "No s'ha pogut crear la llibreta d'adreces",
"title": "Les meves llibretes d'adreces",
@@ -2983,7 +2996,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",
@@ -3009,7 +3025,7 @@
"cert_imported": "Certificat importat a l'emmagatzematge S/MIME",
"cert_import_failed": "No s'ha pogut importar el certificat",
"no_contact_selected": "Seleccioneu un contacte per veure'n els detalls",
"compose_email": "Redacta un correu",
"compose_email": "Envia un correu",
"copy_email": "Copia el correu electrònic",
"copy_phone": "Copia el número de telèfon",
"copy_url": "Copia l'URL",
@@ -3066,8 +3082,8 @@
"form": {
"create_title": "Contacte nou",
"edit_title": "Edita el contacte",
"section_address_book": "Directori",
"select_address_book": "Seleccioneu un directori...",
"section_address_book": "Llibreta d'adreces",
"select_address_book": "Llibreta d'adreces",
"section_identity": "Nom i identitat",
"contact_type": "Tipus de contacte",
"type_person": "Persona",
@@ -3154,6 +3170,13 @@
"add_phone": "Afegeix un telèfon",
"save": "Desa",
"cancel": "Cancel·la",
"discard_title": "Voleu descartar els canvis?",
"discard_message": "Els canvis no s'han desat.",
"discard": "Descarta",
"keep_editing": "Continua editant",
"remove_entry": "Elimina",
"close": "Tanca",
"remove_category": "Elimina {name}",
"creating": "Creant...",
"updating": "Actualitzant...",
"name_required": "Introduïu un nom, un cognom o una organització",
@@ -3170,6 +3193,8 @@
},
"groups": {
"create": "Grup nou",
"delete": "Suprimeix el grup",
"remove_member": "Treu {name} del grup",
"edit": "Edita el grup",
"empty": "Encara no hi ha grups",
"delete_confirm_title": "Suprimeix el grup",
@@ -3182,7 +3207,7 @@
"search_members": "Cerca contactes per afegir...",
"no_members": "Cap membre en aquest grup",
"member_count": "{count, plural, =0 {Cap membre} one {1 membre} other {# membres}}",
"send_email": "Envia un correu al grup",
"send_email": "Envia un correu",
"send_email_to": "Per a",
"send_email_cc": "CC",
"send_email_bcc": "CCO",
@@ -3220,6 +3245,7 @@
"deleted": "{count, plural, one {1 contacte suprimit} other {# contactes suprimits}}",
"add_to_group": "Afegeix al grup",
"choose_group": "Trieu un grup",
"new_group": "Grup nou…",
"adding_contacts": "Afegint {count, plural, one {1 contacte} other {# contactes}}",
"added_to_group": "Contactes afegits al grup",
"export": "Exporta",
@@ -3266,6 +3292,20 @@
"sort_by": "Ordena per",
"sort_first_name": "Nom",
"sort_last_name": "Cognom"
},
"shortcuts": {
"list": "Llista",
"actions": "Accions",
"first_last": "Primer o darrer",
"extend": "Amplia la selecció",
"toggle": "Selecciona o desselecciona",
"select_all": "Selecciona-ho tot",
"clear": "Esborra la selecció o la cerca",
"delete": "Suprimeix",
"search": "Cerca",
"move": "Contacte següent o anterior",
"open": "Obre el contacte",
"menu": "Obre el menú del contacte"
}
},
"calendar": {
@@ -3946,8 +3986,6 @@
"name_control_chars": "Els noms no poden contenir caràcters de control.",
"name_too_long": "Aquest nom és massa llarg per al servidor.",
"delete": "Suprimeix",
"delete_confirm_title": "Suprimeix el recurs",
"delete_confirm_message": "Segur que voleu suprimir «{name}»? Això no es pot desfer.",
"download": "Baixa",
"name": "Nom",
"size": "Mida",
@@ -3973,18 +4011,57 @@
"error_partial_copy": "Part de la còpia s'ha quedat al seu lloc.",
"create_folder_success": "Carpeta creada",
"create_folder_error": "No s'ha pogut crear la carpeta",
"delete_success": "Suprimit correctament",
"delete_error": "No s'ha pogut suprimir",
"trash": "Paperera",
"restore": "Restaura",
"restored": "{count, plural, one {S'ha restaurat l'element} other {S'han restaurat # elements}}",
"restore_error": "No s'ha pogut restaurar",
"moved_to_trash": "{count, plural, one {S'ha mogut a la paperera} other {S'han mogut # elements a la paperera}}",
"delete_forever": "Suprimeix definitivament",
"delete_forever_title": "{count, plural, one {Voleu suprimir-lo definitivament?} other {Voleu suprimir # elements definitivament?}}",
"delete_forever_message": "Se suprimirà «{name}». No es pot desfer.",
"delete_forever_message_count": "{count, plural, one {Se suprimirà 1 element} other {Se suprimiran # elements}}. No es pot desfer.",
"deleted_forever": "{count, plural, one {S'ha suprimit definitivament} other {S'han suprimit # elements definitivament}}",
"empty_trash": "Buida la paperera",
"empty_trash_title": "Voleu buidar la paperera?",
"empty_trash_message": "{count, plural, one {L'element de la paperera se suprimirà} other {Els # elements de la paperera se suprimiran}} definitivament. No es pot desfer.",
"trash_emptied": "S'ha buidat la paperera",
"empty_trash_error": "No s'ha pogut buidar la paperera",
"trash_empty_title": "La paperera és buida",
"trash_empty_hint": "Els fitxers suprimits es queden aquí fins que buideu la paperera.",
"deleted": "Suprimit",
"deleted_from": "Suprimit de {folder}",
"shortcuts": {
"list": "Llista",
"actions": "Accions",
"first_last": "Primer o darrer",
"extend": "Amplia la selecció",
"toggle": "Selecciona o desselecciona",
"select_all": "Selecciona-ho tot",
"clear": "Esborra la selecció o la cerca",
"delete": "Suprimeix",
"search": "Cerca",
"move": "Element següent o anterior",
"move_grid": "Element del costat a la quadrícula",
"open": "Obre",
"up": "Una carpeta amunt",
"rename": "Canvia el nom",
"copy": "Copia",
"cut": "Retalla",
"paste": "Enganxa"
},
"rename_success": "Nom canviat correctament",
"rename_error": "No s'ha pogut canviar el nom",
"download_error": "No s'ha pogut baixar",
"download_zip": "Baixa com a ZIP",
"zipping": "S'està preparant {name}",
"download_canceled": "S'ha cancel·lat la baixada",
"zip_error": "No s'ha pogut baixar {name}",
"not_available": "L'emmagatzematge de fitxers no està disponible en aquest servidor",
"cancel": "Cancel·la",
"create": "Crea",
"save": "Desa",
"no_results": "Cap fitxer coincideix amb la cerca",
"batch_delete_confirm_message": "Segur que voleu suprimir {count, plural, one {1 element} other {# elements}}? Això no es pot desfer.",
"batch_delete_success": "{count, plural, one {1 element suprimit} other {# elements suprimits}}",
"grid_view": "Vista de quadrícula",
"list_view": "Vista de llista",
"details": "Detalls",
@@ -3996,6 +4073,9 @@
"paste": "Enganxa",
"move_success": "{count, plural, one {1 element mogut} other {# elements moguts}}",
"move_error": "No s'ha pogut moure",
"move_to": "Mou a…",
"move_to_title": "Mou a",
"move_here": "Mou aquí",
"paste_success": "Enganxat correctament",
"paste_error": "No s'ha pogut enganxar",
"new_text_file": "Fitxer de text nou",
@@ -4075,7 +4155,10 @@
"zoom_out": "Redueix",
"zoom_reset": "Ajusta a la pantalla",
"rotate": "Gira",
"close_preview": "Tanca"
"close_preview": "Tanca",
"previous_file": "Fitxer anterior",
"next_file": "Fitxer següent",
"loading": "S'està carregant…"
},
"smime": {
"your_certificates": "Els vostres certificats",
+95 -12
View File
@@ -2915,7 +2915,7 @@
"add_cc": "Přidat do Kopie",
"add_bcc": "Přidat do Skrytá kopie",
"select_hint": "Vyberte osobu a zobrazte podrobnosti",
"write_email": "Napsat e-mail",
"write_email": "Poslat e-mail",
"add_to_contacts": "Přidat do kontaktů",
"email": "E-mail",
"read_only_note": "Položky adresáře pocházejí z vašeho poštovního serveru a nelze je zde upravit.",
@@ -2928,6 +2928,11 @@
}
},
"title": "Kontakty",
"disabled_title": "Kontakty vypnul správce",
"disabled_description": "Pokud je potřebujete, obraťte se na správce.",
"drag_count": "{count, plural, one {1 kontakt} few {# kontakty} other {# kontaktů}}",
"select_contact": "Vybrat {name}",
"new": "Nový",
"search_placeholder": "Hledat kontakty...",
"create_new": "Nový kontakt",
"no_category": "Bez kategorie",
@@ -2935,11 +2940,17 @@
"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",
"empty_state_title": "Žádné kontakty",
"empty_state_subtitle": "Vytvořte první kontakt nebo importujte ze souboru vCard",
"empty_view_title": "Nejsou zde žádné kontakty",
"empty_view_hint": "Přidejte kontakt nebo importujte soubor vCard.",
"empty_search": "Hledání neodpovídají žádné kontakty",
"empty_search_hint": "Zkuste použít jiný hledaný výraz",
"empty_filtered": "Žádné kontakty neodpovídají filtrům",
@@ -2960,6 +2971,7 @@
},
"address_books": {
"create": "Nový adresář",
"create_confirm": "Vytvořit",
"created": "Adresář vytvořen",
"create_failed": "Nepodařilo se vytvořit adresář",
"title": "Moje adresáře",
@@ -2982,7 +2994,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",
@@ -3008,7 +3023,7 @@
"cert_imported": "Certifikát byl importován do úložiště S/MIME",
"cert_import_failed": "Import certifikátu selhal",
"no_contact_selected": "Vyberte kontakt pro zobrazení podrobností",
"compose_email": "Napsat e-mail",
"compose_email": "Poslat e-mail",
"copy_email": "Kopírovat e-mail",
"copy_phone": "Kopírovat telefon",
"copy_url": "Kopírovat URL",
@@ -3066,7 +3081,7 @@
"create_title": "Nový kontakt",
"edit_title": "Upravit kontakt",
"section_address_book": "Adresář",
"select_address_book": "Vyberte adresář...",
"select_address_book": "Adresář",
"section_identity": "Jméno a identita",
"contact_type": "Typ kontaktu",
"type_person": "Osoba",
@@ -3153,6 +3168,13 @@
"add_phone": "Přidat telefon",
"save": "Uložit",
"cancel": "Zrušit",
"discard_title": "Zahodit změny?",
"discard_message": "Vaše změny nebyly uloženy.",
"discard": "Zahodit",
"keep_editing": "Pokračovat v úpravách",
"remove_entry": "Odebrat",
"close": "Zavřít",
"remove_category": "Odebrat {name}",
"creating": "Vytváření...",
"updating": "Aktualizování...",
"name_required": "Zadejte jméno, příjmení nebo organizaci",
@@ -3169,6 +3191,8 @@
},
"groups": {
"create": "Nová skupina",
"delete": "Smazat skupinu",
"remove_member": "Odebrat {name} ze skupiny",
"edit": "Upravit skupinu",
"empty": "Žádné skupiny",
"delete_confirm_title": "Odstranit skupinu",
@@ -3181,7 +3205,7 @@
"search_members": "Hledat kontakty k přidání...",
"no_members": "Tato skupina nemá žádné členy",
"member_count": "{count, plural, =0 {Žádní členové} one {1 člen} few {# členové} other {# členů}}",
"send_email": "Poslat e-mail skupině",
"send_email": "Poslat e-mail",
"send_email_to": "To",
"send_email_cc": "Cc",
"send_email_bcc": "Bcc",
@@ -3219,6 +3243,7 @@
"deleted": "{count, plural, one {Odstraněn 1 kontakt} few {Odstraněny # kontakty} other {Odstraněno # kontaktů}}",
"add_to_group": "Přidat do skupiny",
"choose_group": "Vyberte skupinu",
"new_group": "Nová skupina…",
"adding_contacts": "Přidávání {count, plural, one {1 kontaktu} few {# kontaktů} other {# kontaktů}}",
"added_to_group": "Kontakty byly přidány do skupiny",
"export": "Exportovat",
@@ -3266,7 +3291,22 @@
"sort_first_name": "Jméno",
"sort_last_name": "Příjmení"
},
"open_categories": "Otevřít kategorie"
"shortcuts": {
"list": "Seznam",
"actions": "Akce",
"first_last": "První nebo poslední",
"extend": "Rozšířit výběr",
"toggle": "Vybrat nebo zrušit výběr",
"select_all": "Vybrat vše",
"clear": "Zrušit výběr nebo hledání",
"delete": "Smazat",
"search": "Hledat",
"move": "Další nebo předchozí kontakt",
"open": "Otevřít kontakt",
"menu": "Otevřít nabídku kontaktu"
},
"open_categories": "Otevřít kategorie",
"row_actions": "Akce pro {name}"
},
"calendar": {
"title": "Kalendář",
@@ -3900,8 +3940,6 @@
"name_control_chars": "Názvy nesmí obsahovat řídicí znaky.",
"name_too_long": "Tento název je pro server příliš dlouhý.",
"delete": "Odstranit",
"delete_confirm_title": "Odstranit položku",
"delete_confirm_message": "Opravdu chcete odstranit \"{name}\"? Tuto akci nelze vrátit zpět.",
"download": "Stáhnout",
"name": "Název",
"size": "Velikost",
@@ -3926,18 +3964,57 @@
"error_partial_copy": "Část kopie zůstala na místě.",
"create_folder_success": "Složka byla vytvořena",
"create_folder_error": "Vytvoření složky selhalo",
"delete_success": "Úspěšně odstraněno",
"delete_error": "Odstranění selhalo",
"trash": "Koš",
"restore": "Obnovit",
"restored": "{count, plural, one {Položka obnovena} few {Obnoveny # položky} other {Obnoveno # položek}}",
"restore_error": "Obnovení se nezdařilo",
"moved_to_trash": "{count, plural, one {Přesunuto do koše} few {# položky přesunuty do koše} other {# položek přesunuto do koše}}",
"delete_forever": "Trvale smazat",
"delete_forever_title": "{count, plural, one {Trvale smazat?} few {Trvale smazat # položky?} other {Trvale smazat # položek?}}",
"delete_forever_message": "Položka „{name}“ bude smazána. Tuto akci nelze vrátit.",
"delete_forever_message_count": "{count, plural, one {Bude smazána 1 položka} few {Budou smazány # položky} other {Bude smazáno # položek}}. Tuto akci nelze vrátit.",
"deleted_forever": "{count, plural, one {Trvale smazáno} few {Trvale smazány # položky} other {Trvale smazáno # položek}}",
"empty_trash": "Vysypat koš",
"empty_trash_title": "Vysypat koš?",
"empty_trash_message": "{count, plural, one {Položka v koši bude trvale smazána} few {Všechny # položky v koši budou trvale smazány} other {Všech # položek v koši bude trvale smazáno}}. Tuto akci nelze vrátit.",
"trash_emptied": "Koš byl vysypán",
"empty_trash_error": "Koš se nepodařilo vysypat",
"trash_empty_title": "Koš je prázdný",
"trash_empty_hint": "Smazané soubory zůstávají zde, dokud koš nevysypete.",
"deleted": "Smazáno",
"deleted_from": "Smazáno z {folder}",
"shortcuts": {
"list": "Seznam",
"actions": "Akce",
"first_last": "První nebo poslední",
"extend": "Rozšířit výběr",
"toggle": "Vybrat nebo zrušit výběr",
"select_all": "Vybrat vše",
"clear": "Zrušit výběr nebo hledání",
"delete": "Smazat",
"search": "Hledat",
"move": "Další nebo předchozí položka",
"move_grid": "Sousední položka v mřížce",
"open": "Otevřít",
"up": "O složku výš",
"rename": "Přejmenovat",
"copy": "Kopírovat",
"cut": "Vyjmout",
"paste": "Vložit"
},
"rename_success": "Úspěšně přejmenováno",
"rename_error": "Přejmenování selhalo",
"download_error": "Stahování selhalo",
"download_zip": "Stáhnout jako ZIP",
"zipping": "Připravuje se {name}",
"download_canceled": "Stahování zrušeno",
"zip_error": "{name} se nepodařilo stáhnout",
"not_available": "Úložiště souborů není na tomto serveru k dispozici",
"cancel": "Zrušit",
"create": "Vytvořit",
"save": "Uložit",
"no_results": "Hledání neodpovídají žádné soubory",
"batch_delete_confirm_message": "Opravdu chcete odstranit {count, plural, one {1 položku} few {# položky} other {# položek}}? Tuto akci nelze vrátit zpět.",
"batch_delete_success": "{count, plural, one {Odstraněna 1 položka} few {Odstraněny # položky} other {Odstraněno # položek}}",
"grid_view": "Zobrazení mřížky",
"list_view": "Zobrazení seznamu",
"details": "Podrobnosti",
@@ -3949,6 +4026,9 @@
"paste": "Vložit",
"move_success": "{count, plural, one {Přesunuta 1 položka} few {Přesunuty # položky} other {Přesunuto # položek}}",
"move_error": "Přesun selhal",
"move_to": "Přesunout do…",
"move_to_title": "Přesunout do",
"move_here": "Přesunout sem",
"paste_success": "Úspěšně vloženo",
"paste_error": "Vložení selhalo",
"new_text_file": "Nový textový soubor",
@@ -4029,7 +4109,10 @@
"zoom_out": "Oddálit",
"zoom_reset": "Přizpůsobit obrazovce",
"rotate": "Otočit",
"close_preview": "Zavřít"
"close_preview": "Zavřít",
"previous_file": "Předchozí soubor",
"next_file": "Další soubor",
"loading": "Načítání…"
},
"smime": {
"your_certificates": "Vaše certifikáty",
+95 -12
View File
@@ -2915,7 +2915,7 @@
"add_cc": "Tilføj til Cc",
"add_bcc": "Tilføj til Bcc",
"select_hint": "Vælg en person for at se detaljer",
"write_email": "Skriv e-mail",
"write_email": "Send e-mail",
"add_to_contacts": "Tilføj til kontakter",
"email": "E-mail",
"read_only_note": "Posterne kommer fra din mailserver og kan ikke redigeres her.",
@@ -2928,6 +2928,11 @@
}
},
"title": "Kontakter",
"disabled_title": "Kontakter er slået fra af din administrator",
"disabled_description": "Kontakt din administrator, hvis du har brug for dem.",
"drag_count": "{count, plural, one {1 kontakt} other {# kontakter}}",
"select_contact": "Vælg {name}",
"new": "Ny",
"search_placeholder": "Søg efter kontakter...",
"create_new": "Ny kontakt",
"no_category": "Ingen kategori",
@@ -2935,11 +2940,17 @@
"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",
"empty_state_title": "Ingen kontakter endnu",
"empty_state_subtitle": "Opret din første kontakt eller importér fra en vCard-fil",
"empty_view_title": "Ingen kontakter her",
"empty_view_hint": "Tilføj en kontakt, eller importér en vCard-fil.",
"empty_search": "Ingen kontakter matcher din søgning",
"empty_search_hint": "Prøv en anden søgeterm",
"empty_filtered": "Ingen kontakter matcher dine filtre",
@@ -2960,6 +2971,7 @@
},
"address_books": {
"create": "Ny adressebog",
"create_confirm": "Opret",
"created": "Adressebog oprettet",
"create_failed": "Kunne ikke oprette adressebog",
"title": "Mine adressebøger",
@@ -2982,7 +2994,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",
@@ -3008,7 +3023,7 @@
"cert_imported": "Certifikat importeret til S/MIME-lager",
"cert_import_failed": "Kunne ikke importere certifikat",
"no_contact_selected": "Vælg en kontakt for at se detaljer",
"compose_email": "Skriv e-mail",
"compose_email": "Send e-mail",
"copy_email": "Kopier e-mail",
"copy_phone": "Kopier telefonnummer",
"copy_url": "Kopier URL",
@@ -3066,7 +3081,7 @@
"create_title": "Ny kontakt",
"edit_title": "Redigér kontakt",
"section_address_book": "Adressebog",
"select_address_book": "Vælg en adressebog...",
"select_address_book": "Adressebog",
"section_identity": "Navn & identitet",
"contact_type": "Kontakttype",
"type_person": "Person",
@@ -3153,6 +3168,13 @@
"add_phone": "Tilføj telefon",
"save": "Gem",
"cancel": "Annuller",
"discard_title": "Kassér ændringer?",
"discard_message": "Dine ændringer er ikke gemt.",
"discard": "Kassér",
"keep_editing": "Fortsæt redigering",
"remove_entry": "Fjern",
"close": "Luk",
"remove_category": "Fjern {name}",
"creating": "Opretter...",
"updating": "Opdaterer...",
"name_required": "Angiv et fornavn, efternavn eller en organisation",
@@ -3169,6 +3191,8 @@
},
"groups": {
"create": "Ny gruppe",
"delete": "Slet gruppe",
"remove_member": "Fjern {name} fra gruppen",
"edit": "Redigér gruppe",
"empty": "Ingen grupper endnu",
"delete_confirm_title": "Slet gruppe",
@@ -3181,7 +3205,7 @@
"search_members": "Søg efter kontakter at tilføje...",
"no_members": "Ingen medlemmer i denne gruppe",
"member_count": "{count, plural, =0 {Ingen medlemmer} one {1 medlem} other {# medlemmer}}",
"send_email": "Send email to group",
"send_email": "Send e-mail",
"send_email_to": "To",
"send_email_cc": "Cc",
"send_email_bcc": "Bcc",
@@ -3219,6 +3243,7 @@
"deleted": "{count, plural, one {1 kontakt slettet} other {# kontakter slettet}}",
"add_to_group": "Tilføj til gruppe",
"choose_group": "Vælg en gruppe",
"new_group": "Ny gruppe…",
"adding_contacts": "Tilføjer {count, plural, one {1 kontakt} other {# kontakter}}",
"added_to_group": "Kontakter tilføjet til gruppe",
"export": "Eksportér",
@@ -3266,7 +3291,22 @@
"sort_first_name": "Fornavn",
"sort_last_name": "Efternavn"
},
"open_categories": "Åbn kategorier"
"shortcuts": {
"list": "Liste",
"actions": "Handlinger",
"first_last": "Første eller sidste",
"extend": "Udvid markeringen",
"toggle": "Vælg eller fravælg",
"select_all": "Vælg alle",
"clear": "Ryd markering eller søgning",
"delete": "Slet",
"search": "Søg",
"move": "Næste eller forrige kontakt",
"open": "Åbn kontakten",
"menu": "Åbn kontaktens menu"
},
"open_categories": "Åbn kategorier",
"row_actions": "Handlinger for {name}"
},
"calendar": {
"title": "Kalender",
@@ -3946,8 +3986,6 @@
"name_control_chars": "Navne må ikke indeholde kontroltegn.",
"name_too_long": "Dette navn er for langt til serveren.",
"delete": "Slet",
"delete_confirm_title": "Slet ressource",
"delete_confirm_message": "Er du sikker på, at du vil slette \"{name}\"? Dette kan ikke fortrydes.",
"download": "Download",
"name": "Navn",
"size": "Størrelse",
@@ -3972,18 +4010,57 @@
"error_partial_copy": "En del af kopien blev liggende.",
"create_folder_success": "Mappe oprettet",
"create_folder_error": "Kunne ikke oprette mappe",
"delete_success": "Slettet",
"delete_error": "Kunne ikke slette",
"trash": "Papirkurv",
"restore": "Gendan",
"restored": "{count, plural, one {Elementet er gendannet} other {# elementer er gendannet}}",
"restore_error": "Kunne ikke gendanne",
"moved_to_trash": "{count, plural, one {Flyttet til papirkurven} other {# elementer flyttet til papirkurven}}",
"delete_forever": "Slet permanent",
"delete_forever_title": "{count, plural, one {Slet permanent?} other {Slet # elementer permanent?}}",
"delete_forever_message": "\"{name}\" bliver slettet. Det kan ikke fortrydes.",
"delete_forever_message_count": "{count, plural, one {1 element bliver slettet} other {# elementer bliver slettet}}. Det kan ikke fortrydes.",
"deleted_forever": "{count, plural, one {Slettet permanent} other {# elementer slettet permanent}}",
"empty_trash": "Tøm papirkurven",
"empty_trash_title": "Tøm papirkurven?",
"empty_trash_message": "{count, plural, one {Elementet i papirkurven} other {Alle # elementer i papirkurven}} bliver slettet permanent. Det kan ikke fortrydes.",
"trash_emptied": "Papirkurven er tømt",
"empty_trash_error": "Kunne ikke tømme papirkurven",
"trash_empty_title": "Papirkurven er tom",
"trash_empty_hint": "Slettede filer bliver her, indtil du tømmer papirkurven.",
"deleted": "Slettet",
"deleted_from": "Slettet fra {folder}",
"shortcuts": {
"list": "Liste",
"actions": "Handlinger",
"first_last": "Første eller sidste",
"extend": "Udvid markeringen",
"toggle": "Vælg eller fravælg",
"select_all": "Vælg alle",
"clear": "Ryd markering eller søgning",
"delete": "Slet",
"search": "Søg",
"move": "Næste eller forrige element",
"move_grid": "Element ved siden af i gitteret",
"open": "Åbn",
"up": "En mappe op",
"rename": "Omdøb",
"copy": "Kopiér",
"cut": "Klip",
"paste": "Indsæt"
},
"rename_success": "Omdøbt",
"rename_error": "Kunne ikke omdøbe",
"download_error": "Kunne ikke downloade",
"download_zip": "Download som ZIP",
"zipping": "Forbereder {name}",
"download_canceled": "Download annulleret",
"zip_error": "Kunne ikke downloade {name}",
"not_available": "Fillager er ikke tilgængeligt på denne server",
"cancel": "Annuller",
"create": "Opret",
"save": "Gem",
"no_results": "Ingen filer matcher din søgning",
"batch_delete_confirm_message": "Er du sikker på, at du vil slette {count, plural, one {1 element} other {# elementer}}? Dette kan ikke fortrydes.",
"batch_delete_success": "{count, plural, one {1 element slettet} other {# elementer slettet}}",
"grid_view": "Gittervisning",
"list_view": "Listevisning",
"details": "Detaljer",
@@ -3995,6 +4072,9 @@
"paste": "Sæt ind",
"move_success": "{count, plural, one {1 element flyttet} other {# elementer flyttet}}",
"move_error": "Kunne ikke flytte",
"move_to": "Flyt til…",
"move_to_title": "Flyt til",
"move_here": "Flyt hertil",
"paste_success": "Indsat",
"paste_error": "Kunne ikke indsætte",
"new_text_file": "Ny tekstfil",
@@ -4075,7 +4155,10 @@
"zoom_out": "Zoom ud",
"zoom_reset": "Tilpas til skærm",
"rotate": "Rotér",
"close_preview": "Luk"
"close_preview": "Luk",
"previous_file": "Forrige fil",
"next_file": "Næste fil",
"loading": "Indlæser…"
},
"smime": {
"your_certificates": "Dine certifikater",
+96 -13
View File
@@ -2915,7 +2915,7 @@
"add_cc": "Zu Cc",
"add_bcc": "Zu Bcc",
"select_hint": "Wählen Sie jemanden aus, um die Details zu sehen",
"write_email": "E-Mail schreiben",
"write_email": "E-Mail senden",
"add_to_contacts": "Zu Kontakten hinzufügen",
"email": "E-Mail",
"read_only_note": "Verzeichniseinträge kommen von Ihrem Mailserver und können hier nicht bearbeitet werden.",
@@ -2928,6 +2928,11 @@
}
},
"title": "Kontakte",
"disabled_title": "Kontakte wurden von Ihrem Administrator deaktiviert",
"disabled_description": "Wenden Sie sich an Ihren Administrator, wenn Sie Zugriff benötigen.",
"drag_count": "{count, plural, one {1 Kontakt} other {# Kontakte}}",
"select_contact": "{name} auswählen",
"new": "Neu",
"search_placeholder": "Kontakte suchen...",
"create_new": "Neuer Kontakt",
"no_category": "Ohne Kategorie",
@@ -2935,11 +2940,17 @@
"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",
"empty_state_title": "Keine Kontakte",
"empty_state_subtitle": "Erstellen Sie Ihren ersten Kontakt oder importieren Sie aus einer vCard-Datei",
"empty_view_title": "Hier sind keine Kontakte",
"empty_view_hint": "Fügen Sie einen Kontakt hinzu oder importieren Sie eine vCard-Datei.",
"empty_search": "Keine Kontakte gefunden",
"empty_search_hint": "Versuchen Sie einen anderen Suchbegriff",
"empty_filtered": "Keine Kontakte entsprechen Ihren Filtern",
@@ -2960,6 +2971,7 @@
},
"address_books": {
"create": "Neues Adressbuch",
"create_confirm": "Erstellen",
"created": "Adressbuch erstellt",
"create_failed": "Adressbuch konnte nicht erstellt werden",
"title": "Meine Adressbücher",
@@ -2982,7 +2994,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",
@@ -2999,7 +3014,7 @@
"related_contacts": "Verwandte Kontakte",
"crypto_keys": "Kryptografische Schlüssel",
"no_contact_selected": "Kontakt auswählen, um Details anzuzeigen",
"compose_email": "E-Mail schreiben",
"compose_email": "E-Mail senden",
"copy_email": "E-Mail kopieren",
"copy_phone": "Telefonnummer kopieren",
"copy_url": "URL kopieren",
@@ -3065,8 +3080,8 @@
"form": {
"create_title": "Neuer Kontakt",
"edit_title": "Kontakt bearbeiten",
"section_address_book": "Verzeichnis",
"select_address_book": "Verzeichnis auswählen...",
"section_address_book": "Adressbuch",
"select_address_book": "Adressbuch",
"section_identity": "Name & Identität",
"contact_type": "Kontakttyp",
"type_person": "Person",
@@ -3153,6 +3168,13 @@
"add_phone": "Telefon hinzufügen",
"save": "Speichern",
"cancel": "Abbrechen",
"discard_title": "Änderungen verwerfen?",
"discard_message": "Ihre Änderungen wurden nicht gespeichert.",
"discard": "Verwerfen",
"keep_editing": "Weiter bearbeiten",
"remove_entry": "Entfernen",
"close": "Schließen",
"remove_category": "{name} entfernen",
"creating": "Wird erstellt...",
"updating": "Wird aktualisiert...",
"name_required": "Bitte Vorname, Nachname oder Organisation angeben",
@@ -3169,6 +3191,8 @@
},
"groups": {
"create": "Neue Gruppe",
"delete": "Gruppe löschen",
"remove_member": "{name} aus der Gruppe entfernen",
"edit": "Gruppe bearbeiten",
"empty": "Keine Gruppen",
"delete_confirm_title": "Gruppe löschen",
@@ -3181,7 +3205,7 @@
"search_members": "Kontakte zum Hinzufügen suchen...",
"no_members": "Keine Mitglieder in dieser Gruppe",
"member_count": "{count, plural, =0 {Keine Mitglieder} one {1 Mitglied} other {# Mitglieder}}",
"send_email": "E-Mail an Gruppe senden",
"send_email": "E-Mail senden",
"send_email_to": "An",
"send_email_cc": "Cc",
"send_email_bcc": "Bcc",
@@ -3219,6 +3243,7 @@
"deleted": "{count, plural, one {1 Kontakt gelöscht} other {# Kontakte gelöscht}}",
"add_to_group": "Zur Gruppe hinzufügen",
"choose_group": "Gruppe wählen",
"new_group": "Neue Gruppe…",
"adding_contacts": "{count, plural, one {1 Kontakt} other {# Kontakte}} hinzufügen",
"added_to_group": "Kontakte zur Gruppe hinzugefügt",
"export": "Exportieren",
@@ -3266,7 +3291,22 @@
"sort_first_name": "Vorname",
"sort_last_name": "Nachname"
},
"open_categories": "Kategorien öffnen"
"shortcuts": {
"list": "Liste",
"actions": "Aktionen",
"first_last": "Erstes oder letztes",
"extend": "Auswahl erweitern",
"toggle": "Auswählen oder abwählen",
"select_all": "Alle auswählen",
"clear": "Auswahl oder Suche aufheben",
"delete": "Löschen",
"search": "Suchen",
"move": "Nächster oder vorheriger Kontakt",
"open": "Kontakt öffnen",
"menu": "Kontaktmenü öffnen"
},
"open_categories": "Kategorien öffnen",
"row_actions": "Aktionen für {name}"
},
"calendar": {
"title": "Kalender",
@@ -3900,8 +3940,6 @@
"name_control_chars": "Namen dürfen keine Steuerzeichen enthalten.",
"name_too_long": "Dieser Name ist für den Server zu lang.",
"delete": "Löschen",
"delete_confirm_title": "Ressource löschen",
"delete_confirm_message": "Möchten Sie \"{name}\" wirklich löschen? Dies kann nicht rückgängig gemacht werden.",
"download": "Herunterladen",
"name": "Name",
"size": "Größe",
@@ -3926,18 +3964,57 @@
"error_partial_copy": "Ein Teil der Kopie wurde nicht entfernt.",
"create_folder_success": "Ordner erstellt",
"create_folder_error": "Ordner konnte nicht erstellt werden",
"delete_success": "Erfolgreich gelöscht",
"delete_error": "Löschen fehlgeschlagen",
"trash": "Papierkorb",
"restore": "Wiederherstellen",
"restored": "{count, plural, one {Element wiederhergestellt} other {# Elemente wiederhergestellt}}",
"restore_error": "Wiederherstellen fehlgeschlagen",
"moved_to_trash": "{count, plural, one {In den Papierkorb verschoben} other {# Elemente in den Papierkorb verschoben}}",
"delete_forever": "Endgültig löschen",
"delete_forever_title": "{count, plural, one {Endgültig löschen?} other {# Elemente endgültig löschen?}}",
"delete_forever_message": "„{name}“ wird gelöscht. Das lässt sich nicht rückgängig machen.",
"delete_forever_message_count": "{count, plural, one {1 Element wird gelöscht} other {# Elemente werden gelöscht}}. Das lässt sich nicht rückgängig machen.",
"deleted_forever": "{count, plural, one {Endgültig gelöscht} other {# Elemente endgültig gelöscht}}",
"empty_trash": "Papierkorb leeren",
"empty_trash_title": "Papierkorb leeren?",
"empty_trash_message": "{count, plural, one {Das Element im Papierkorb wird} other {Alle # Elemente im Papierkorb werden}} endgültig gelöscht. Das lässt sich nicht rückgängig machen.",
"trash_emptied": "Papierkorb geleert",
"empty_trash_error": "Papierkorb konnte nicht geleert werden",
"trash_empty_title": "Der Papierkorb ist leer",
"trash_empty_hint": "Gelöschte Dateien bleiben hier, bis Sie den Papierkorb leeren.",
"deleted": "Gelöscht",
"deleted_from": "Gelöscht aus {folder}",
"shortcuts": {
"list": "Liste",
"actions": "Aktionen",
"first_last": "Erstes oder letztes",
"extend": "Auswahl erweitern",
"toggle": "Auswählen oder abwählen",
"select_all": "Alle auswählen",
"clear": "Auswahl oder Suche aufheben",
"delete": "Löschen",
"search": "Suchen",
"move": "Nächstes oder vorheriges Element",
"move_grid": "Element daneben im Raster",
"open": "Öffnen",
"up": "Einen Ordner nach oben",
"rename": "Umbenennen",
"copy": "Kopieren",
"cut": "Ausschneiden",
"paste": "Einfügen"
},
"rename_success": "Erfolgreich umbenannt",
"rename_error": "Umbenennen fehlgeschlagen",
"download_error": "Herunterladen fehlgeschlagen",
"download_zip": "Als ZIP herunterladen",
"zipping": "{name} wird vorbereitet",
"download_canceled": "Download abgebrochen",
"zip_error": "{name} konnte nicht heruntergeladen werden",
"not_available": "Dateispeicher ist auf diesem Server nicht verfügbar",
"cancel": "Abbrechen",
"create": "Erstellen",
"save": "Speichern",
"no_results": "Keine Dateien entsprechen Ihrer Suche",
"batch_delete_confirm_message": "Möchten Sie wirklich {count, plural, one {1 Element} other {# Elemente}} löschen? Dies kann nicht rückgängig gemacht werden.",
"batch_delete_success": "{count, plural, one {1 Element gelöscht} other {# Elemente gelöscht}}",
"grid_view": "Rasteransicht",
"list_view": "Listenansicht",
"details": "Details",
@@ -3949,6 +4026,9 @@
"paste": "Einfügen",
"move_success": "{count, plural, one {1 Element verschoben} other {# Elemente verschoben}}",
"move_error": "Verschieben fehlgeschlagen",
"move_to": "Verschieben nach…",
"move_to_title": "Verschieben nach",
"move_here": "Hierher verschieben",
"paste_success": "Erfolgreich eingefügt",
"paste_error": "Einfügen fehlgeschlagen",
"new_text_file": "Neue Textdatei",
@@ -4029,7 +4109,10 @@
"zoom_out": "Verkleinern",
"zoom_reset": "An Bildschirm anpassen",
"rotate": "Drehen",
"close_preview": "Schließen"
"close_preview": "Schließen",
"previous_file": "Vorherige Datei",
"next_file": "Nächste Datei",
"loading": "Wird geladen…"
},
"smime": {
"your_certificates": "Ihre Zertifikate",
+149 -66
View File
@@ -2915,7 +2915,7 @@
"add_cc": "Add to Cc",
"add_bcc": "Add to Bcc",
"select_hint": "Select someone to see their details",
"write_email": "Write email",
"write_email": "Send email",
"add_to_contacts": "Add to contacts",
"email": "Email",
"read_only_note": "Directory entries come from your mail server and can't be edited here.",
@@ -2928,18 +2928,29 @@
}
},
"title": "Contacts",
"disabled_title": "Contacts is turned off by your administrator",
"disabled_description": "Ask your administrator if you need them.",
"drag_count": "{count, plural, one {1 contact} other {# contacts}}",
"select_contact": "Select {name}",
"new": "New",
"search_placeholder": "Search contacts...",
"create_new": "New Contact",
"no_category": "No Category",
"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_rename_failed": "Couldn't rename the 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",
"empty_state_title": "No contacts yet",
"empty_state_subtitle": "Create your first contact or import from a vCard file",
"empty_view_title": "No contacts here",
"empty_view_hint": "Add a contact or import a vCard file.",
"empty_search": "No contacts match your search",
"empty_search_hint": "Try a different search term",
"empty_filtered": "No contacts match your filters",
@@ -2952,6 +2963,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"
@@ -2961,44 +2973,48 @@
},
"address_books": {
"create": "New address book",
"create_confirm": "Create",
"created": "Address book created",
"create_failed": "Failed to create address book",
"title": "My Address Books",
"create_failed": "Couldn't create the address book",
"title": "My address books",
"shared_prefix": "Shared: {name}",
"moved": "Contact moved to {name}",
"moved_plural": "{count} contacts moved to {name}",
"move_failed": "Failed to move contact",
"address_book": "Address Book",
"move_failed": "Couldn't move the contact",
"address_book": "Address book",
"rename": "Rename address book",
"name_label": "Address book name",
"renamed": "Address book renamed",
"rename_failed": "Failed to rename address book",
"rename_failed": "Couldn't rename the address book",
"default": "Default",
"set_default": "Set as default",
"default_updated": "Default address book updated",
"set_default_failed": "Failed to set default address book",
"set_default_failed": "Couldn't set the default address book",
"manage": "Manage address books",
"share": "Share address book",
"new_contact_in_book": "New contact in this address book",
"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": "Couldn't delete the address book",
"move_to": "Move to…",
"move_title": "Move to address book",
"move_here": "Move here"
},
"detail": {
"emails": "Email Addresses",
"phones": "Phone Numbers",
"emails": "Email addresses",
"phones": "Phone numbers",
"organizations": "Organizations",
"addresses": "Addresses",
"notes": "Notes",
"titles": "Titles & Roles",
"online_services": "Online Services",
"titles": "Titles and roles",
"online_services": "Online services",
"anniversaries": "Anniversaries",
"personal_info": "Personal Info",
"personal_info": "Personal info",
"languages": "Languages",
"categories": "Categories",
"related_contacts": "Related Contacts",
"crypto_keys": "Crypto Keys",
"crypto_keys": "Crypto keys",
"cert_issuer": "Issuer",
"cert_expires": "Expires",
"cert_expired": "Expired",
@@ -3007,14 +3023,14 @@
"import_to_smime": "Import to S/MIME",
"cert_already_imported": "Already imported to S/MIME",
"cert_imported": "Certificate imported to S/MIME store",
"cert_import_failed": "Failed to import certificate",
"cert_import_failed": "Couldn't import the certificate",
"no_contact_selected": "Select a contact to view details",
"compose_email": "Compose email",
"compose_email": "Send email",
"copy_email": "Copy email",
"copy_phone": "Copy phone number",
"copy_url": "Copy URL",
"copied": "Copied to clipboard",
"copy_failed": "Failed to copy to clipboard",
"copy_failed": "Couldn't copy to the clipboard",
"created": "Created",
"updated": "Last updated",
"timezone": "Timezone",
@@ -3053,26 +3069,26 @@
"years_since": "{count, plural, one {1 year} other {# years}}"
},
"activity": {
"recent_emails": "Recent Emails",
"upcoming_events": "Upcoming Events",
"recent_emails": "Recent emails",
"upcoming_events": "Upcoming events",
"no_emails": "No recent emails",
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load",
"load_failed": "Couldn't load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
},
"form": {
"create_title": "New Contact",
"edit_title": "Edit Contact",
"section_address_book": "Directory",
"select_address_book": "Select a directory...",
"section_identity": "Name & Identity",
"create_title": "New contact",
"edit_title": "Edit contact",
"section_address_book": "Address book",
"select_address_book": "Select an address book…",
"section_identity": "Name and identity",
"contact_type": "Contact type",
"type_person": "Person",
"type_organization": "Organization",
"section_work": "Work & Organization",
"section_work": "Work and organization",
"prefix": "Prefix",
"prefix_placeholder": "Dr., Mr., Mrs.",
"given_name": "First name",
@@ -3108,7 +3124,7 @@
"region": "State / Region",
"postcode": "Postal code",
"country": "Country",
"online_services": "Online Services",
"online_services": "Online services",
"add_online_service": "Add online service",
"url_placeholder": "https://...",
"service_placeholder": "Service",
@@ -3118,7 +3134,7 @@
"anniversary_wedding": "Anniversary",
"anniversary_death": "Memorial",
"anniversary_other": "Other",
"personal_info": "Personal Info",
"personal_info": "Personal info",
"add_personal_info": "Add entry",
"personal_info_placeholder": "e.g., Photography",
"personal_expertise": "Expertise",
@@ -3154,12 +3170,19 @@
"add_phone": "Add phone",
"save": "Save",
"cancel": "Cancel",
"discard_title": "Discard changes?",
"discard_message": "Your changes haven't been saved.",
"discard": "Discard",
"keep_editing": "Keep editing",
"remove_entry": "Remove",
"close": "Close",
"remove_category": "Remove {name}",
"creating": "Creating...",
"updating": "Updating...",
"name_required": "Enter a first name, last name, or organization",
"email_invalid": "Please enter a valid email address",
"email_error_inline": "Invalid email format",
"save_failed": "Failed to save contact",
"save_failed": "Couldn't save the contact",
"delete": "Delete",
"upload_photo": "Upload photo",
"remove_photo": "Remove photo",
@@ -3169,31 +3192,33 @@
"change_photo": "Change"
},
"groups": {
"create": "New Group",
"edit": "Edit Group",
"create": "New group",
"delete": "Delete group",
"remove_member": "Remove {name} from the group",
"edit": "Edit group",
"empty": "No groups yet",
"delete_confirm_title": "Delete group",
"delete_confirm": "Are you sure you want to delete this group?",
"name_label": "Group Name",
"name_label": "Group name",
"name_placeholder": "e.g., Team, Family",
"name_required": "Group name is required",
"save_failed": "Failed to save group",
"save_failed": "Couldn't save the group",
"members_label": "Members",
"search_members": "Search contacts to add...",
"no_members": "No members in this group",
"member_count": "{count, plural, =0 {No members} one {1 member} other {# members}}",
"send_email": "Send email to group",
"send_email": "Send email",
"send_email_to": "To",
"send_email_cc": "Cc",
"send_email_bcc": "Bcc",
"no_member_emails": "This group has no members with an email address."
},
"import": {
"title": "Import Contacts",
"title": "Import contacts",
"drop_hint": "Click to select a vCard file",
"file_types": ".vcf or .vcard files",
"no_contacts": "No contacts found in file",
"parse_error": "Failed to parse vCard file",
"parse_error": "Couldn't read the vCard file",
"found": "{count, plural, one {1 contact found} other {# contacts found}}",
"duplicate": "Duplicate",
"select_all": "Select all",
@@ -3220,6 +3245,7 @@
"deleted": "{count, plural, one {1 contact deleted} other {# contacts deleted}}",
"add_to_group": "Add to group",
"choose_group": "Choose a group",
"new_group": "New group…",
"adding_contacts": "Adding {count, plural, one {1 contact} other {# contacts}}",
"added_to_group": "Contacts added to group",
"export": "Export",
@@ -3229,9 +3255,9 @@
"created": "Contact created",
"updated": "Contact updated",
"deleted": "Contact deleted",
"error_create": "Failed to create contact",
"error_update": "Failed to update contact",
"error_delete": "Failed to delete contact"
"error_create": "Couldn't create the contact",
"error_update": "Couldn't update the contact",
"error_delete": "Couldn't delete the contact"
},
"context_menu": {
"open": "Open",
@@ -3266,6 +3292,20 @@
"sort_by": "Sort by",
"sort_first_name": "First name",
"sort_last_name": "Last name"
},
"shortcuts": {
"list": "List",
"actions": "Actions",
"first_last": "First or last",
"extend": "Extend the selection",
"toggle": "Select or deselect",
"select_all": "Select all",
"clear": "Clear the selection or search",
"delete": "Delete",
"search": "Search",
"move": "Next or previous contact",
"open": "Open the contact",
"menu": "Open the contact's menu"
}
},
"calendar": {
@@ -3935,8 +3975,8 @@
"title": "Files",
"search_placeholder": "Search this folder",
"empty_state_title": "No files yet",
"upload_files": "Upload Files",
"new_folder": "New Folder",
"upload_files": "Upload files",
"new_folder": "New folder",
"new_folder_name": "Folder name",
"rename": "Rename",
"rename_title": "Rename",
@@ -3946,8 +3986,6 @@
"name_control_chars": "Names cannot contain control characters.",
"name_too_long": "This name is too long for the server.",
"delete": "Delete",
"delete_confirm_title": "Delete resource",
"delete_confirm_message": "Are you sure you want to delete \"{name}\"? This cannot be undone.",
"download": "Download",
"name": "Name",
"size": "Size",
@@ -3963,7 +4001,7 @@
"upload_success": "{count, plural, one {1 file uploaded} other {# files uploaded}}",
"upload_canceled": "Upload canceled",
"upload_canceled_partial": "{count, plural, one {1 file was} other {# files were}} uploaded before you canceled",
"upload_error": "Failed to upload file",
"upload_error": "Couldn't upload the file",
"upload_failed": "{count, plural, one {1 file could not be uploaded} other {# files could not be uploaded}}",
"error_over_quota": "You have reached the limit on files or folders. Delete some and try again.",
"error_too_deep": "Folders cannot be nested this deeply.",
@@ -3972,55 +4010,97 @@
"error_too_large": "This file is too large for the server.",
"error_partial_copy": "Part of the copy was left in place.",
"create_folder_success": "Folder created",
"create_folder_error": "Failed to create folder",
"delete_success": "Deleted successfully",
"delete_error": "Failed to delete",
"rename_success": "Renamed successfully",
"rename_error": "Failed to rename",
"download_error": "Failed to download",
"create_folder_error": "Couldn't create the folder",
"delete_error": "Couldn't delete",
"trash": "Trash",
"restore": "Restore",
"restored": "{count, plural, one {Item restored} other {# items restored}}",
"restore_error": "Couldn't restore",
"moved_to_trash": "{count, plural, one {Moved to trash} other {# items moved to trash}}",
"delete_forever": "Delete permanently",
"delete_forever_title": "{count, plural, one {Delete permanently?} other {Delete # items permanently?}}",
"delete_forever_message": "\"{name}\" will be deleted. You can't undo this.",
"delete_forever_message_count": "{count, plural, one {1 item} other {# items}} will be deleted. You can't undo this.",
"deleted_forever": "{count, plural, one {Deleted permanently} other {# items deleted permanently}}",
"empty_trash": "Empty trash",
"empty_trash_title": "Empty trash?",
"empty_trash_message": "{count, plural, one {The item in the trash} other {All # items in the trash}} will be deleted permanently. You can't undo this.",
"trash_emptied": "Trash emptied",
"empty_trash_error": "Couldn't empty the trash",
"trash_empty_title": "Trash is empty",
"trash_empty_hint": "Deleted files stay here until you empty the trash.",
"deleted": "Deleted",
"deleted_from": "Deleted from {folder}",
"shortcuts": {
"list": "List",
"actions": "Actions",
"first_last": "First or last",
"extend": "Extend the selection",
"toggle": "Select or deselect",
"select_all": "Select all",
"clear": "Clear the selection or search",
"delete": "Delete",
"search": "Search",
"move": "Next or previous item",
"move_grid": "Item beside it in the grid",
"open": "Open",
"up": "Up a folder",
"rename": "Rename",
"copy": "Copy",
"cut": "Cut",
"paste": "Paste"
},
"rename_success": "Renamed",
"rename_error": "Couldn't rename",
"download_error": "Couldn't download",
"download_zip": "Download as zip",
"zipping": "Preparing {name}",
"download_canceled": "Download canceled",
"zip_error": "Couldn't download {name}",
"not_available": "File storage is not available on this server",
"cancel": "Cancel",
"create": "Create",
"save": "Save",
"no_results": "No files match your search",
"batch_delete_confirm_message": "Are you sure you want to delete {count, plural, one {1 item} other {# items}}? This cannot be undone.",
"batch_delete_success": "{count, plural, one {1 item deleted} other {# items deleted}}",
"grid_view": "Grid view",
"list_view": "List view",
"details": "Details",
"path": "Path",
"preview": "Preview",
"preview_error": "Failed to load preview",
"preview_error": "Couldn't load the preview",
"cut": "Cut",
"copy": "Copy",
"paste": "Paste",
"move_success": "{count, plural, one {1 item moved} other {# items moved}}",
"move_error": "Failed to move",
"paste_success": "Pasted successfully",
"paste_error": "Failed to paste",
"new_text_file": "New Text File",
"move_error": "Couldn't move",
"move_to": "Move to…",
"move_to_title": "Move to",
"move_here": "Move here",
"paste_success": "Pasted",
"paste_error": "Couldn't paste",
"new_text_file": "New text file",
"file_name": "File name",
"retry": "Retry",
"refresh": "Refresh",
"duplicate": "Duplicate",
"duplicate_success": "Duplicated successfully",
"duplicate_error": "Failed to duplicate",
"duplicate_success": "Duplicated",
"duplicate_error": "Couldn't duplicate",
"share": "Share",
"shared": "Shared",
"shared_with_me": "Shared with me",
"shared_by": "Shared by {name}",
"shared_files_from": "Files from {name}",
"create_file_success": "File created",
"create_file_error": "Failed to create file",
"create_file_error": "Couldn't create the file",
"favorites": "Starred",
"recent": "Recent",
"show_in_folder": "Show in folder",
"remove_from_recent": "Remove from recent",
"upload_folder": "Upload Folder",
"upload_folder": "Upload folder",
"file_too_large": "\"{name}\" exceeds the maximum file size ({max})",
"undo": "Undo",
"undo_success": "Action undone",
"undo_error": "Failed to undo",
"undo_error": "Couldn't undo",
"toolbar": "File actions",
"open_folder_tree": "Open folder tree",
"file_list": "Files and folders",
@@ -4075,7 +4155,10 @@
"zoom_out": "Zoom out",
"zoom_reset": "Fit to screen",
"rotate": "Rotate",
"close_preview": "Close"
"close_preview": "Close",
"previous_file": "Previous file",
"next_file": "Next file",
"loading": "Loading…"
},
"smime": {
"your_certificates": "Your Certificates",
+96 -13
View File
@@ -2915,7 +2915,7 @@
"add_cc": "Añadir a CC",
"add_bcc": "Añadir a CCO",
"select_hint": "Selecciona a alguien para ver sus datos",
"write_email": "Escribir correo",
"write_email": "Enviar correo",
"add_to_contacts": "Añadir a contactos",
"email": "Correo",
"read_only_note": "Las entradas del directorio vienen de tu servidor de correo y no se pueden editar aquí.",
@@ -2928,6 +2928,11 @@
}
},
"title": "Contactos",
"disabled_title": "Tu administrador ha desactivado los contactos",
"disabled_description": "Pídeselos a tu administrador si los necesitas.",
"drag_count": "{count, plural, one {1 contacto} other {# contactos}}",
"select_contact": "Seleccionar a {name}",
"new": "Nuevo",
"search_placeholder": "Buscar contactos...",
"create_new": "Nuevo contacto",
"no_category": "Sin categoría",
@@ -2935,11 +2940,17 @@
"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",
"empty_state_title": "Sin contactos",
"empty_state_subtitle": "Crea tu primer contacto o importa desde un archivo vCard",
"empty_view_title": "No hay contactos aquí",
"empty_view_hint": "Añade un contacto o importa un archivo vCard.",
"empty_search": "Ningún contacto coincide con tu búsqueda",
"empty_search_hint": "Prueba con otro término de búsqueda",
"empty_filtered": "Ningún contacto coincide con tus filtros",
@@ -2960,6 +2971,7 @@
},
"address_books": {
"create": "Nueva libreta de direcciones",
"create_confirm": "Crear",
"created": "Libreta de direcciones creada",
"create_failed": "No se pudo crear la libreta de direcciones",
"title": "Mis Libretas de Direcciones",
@@ -2982,7 +2994,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",
@@ -2999,7 +3014,7 @@
"related_contacts": "Contactos relacionados",
"crypto_keys": "Claves criptográficas",
"no_contact_selected": "Selecciona un contacto para ver los detalles",
"compose_email": "Escribir correo",
"compose_email": "Enviar correo",
"copy_email": "Copiar correo",
"copy_phone": "Copiar teléfono",
"copy_url": "Copiar URL",
@@ -3065,8 +3080,8 @@
"form": {
"create_title": "Nuevo contacto",
"edit_title": "Editar contacto",
"section_address_book": "Directorio",
"select_address_book": "Seleccionar un directorio...",
"section_address_book": "Libreta de direcciones",
"select_address_book": "Libreta de direcciones",
"section_identity": "Nombre e identidad",
"contact_type": "Tipo de contacto",
"type_person": "Persona",
@@ -3153,6 +3168,13 @@
"add_phone": "Agregar teléfono",
"save": "Guardar",
"cancel": "Cancelar",
"discard_title": "¿Descartar los cambios?",
"discard_message": "Tus cambios no se han guardado.",
"discard": "Descartar",
"keep_editing": "Seguir editando",
"remove_entry": "Quitar",
"close": "Cerrar",
"remove_category": "Quitar {name}",
"creating": "Creando...",
"updating": "Actualizando...",
"name_required": "Introduce un nombre, un apellido o una organización",
@@ -3169,6 +3191,8 @@
},
"groups": {
"create": "Nuevo grupo",
"delete": "Eliminar grupo",
"remove_member": "Quitar a {name} del grupo",
"edit": "Editar grupo",
"empty": "No hay grupos",
"delete_confirm_title": "Eliminar grupo",
@@ -3181,7 +3205,7 @@
"search_members": "Buscar contactos para agregar...",
"no_members": "No hay miembros en este grupo",
"member_count": "{count, plural, =0 {Sin miembros} one {1 miembro} other {# miembros}}",
"send_email": "Send email to group",
"send_email": "Enviar correo",
"send_email_to": "To",
"send_email_cc": "Cc",
"send_email_bcc": "Bcc",
@@ -3219,6 +3243,7 @@
"deleted": "{count, plural, one {1 contacto eliminado} other {# contactos eliminados}}",
"add_to_group": "Agregar al grupo",
"choose_group": "Elegir un grupo",
"new_group": "Nuevo grupo…",
"adding_contacts": "Agregando {count, plural, one {1 contacto} other {# contactos}}",
"added_to_group": "Contactos agregados al grupo",
"export": "Exportar",
@@ -3266,7 +3291,22 @@
"sort_first_name": "Nombre",
"sort_last_name": "Apellido"
},
"open_categories": "Abrir categorías"
"shortcuts": {
"list": "Lista",
"actions": "Acciones",
"first_last": "Primero o último",
"extend": "Ampliar la selección",
"toggle": "Seleccionar o deseleccionar",
"select_all": "Seleccionar todo",
"clear": "Borrar la selección o la búsqueda",
"delete": "Eliminar",
"search": "Buscar",
"move": "Contacto siguiente o anterior",
"open": "Abrir el contacto",
"menu": "Abrir el menú del contacto"
},
"open_categories": "Abrir categorías",
"row_actions": "Acciones para {name}"
},
"calendar": {
"title": "Calendario",
@@ -3900,8 +3940,6 @@
"name_control_chars": "Los nombres no pueden contener caracteres de control.",
"name_too_long": "Este nombre es demasiado largo para el servidor.",
"delete": "Eliminar",
"delete_confirm_title": "Eliminar recurso",
"delete_confirm_message": "¿Está seguro de que desea eliminar \"{name}\"? Esta acción no se puede deshacer.",
"download": "Descargar",
"name": "Nombre",
"size": "Tamaño",
@@ -3926,18 +3964,57 @@
"error_partial_copy": "Una parte de la copia se ha quedado en su lugar.",
"create_folder_success": "Carpeta creada",
"create_folder_error": "Error al crear la carpeta",
"delete_success": "Eliminado correctamente",
"delete_error": "Error al eliminar",
"trash": "Papelera",
"restore": "Restaurar",
"restored": "{count, plural, one {Elemento restaurado} other {# elementos restaurados}}",
"restore_error": "No se ha podido restaurar",
"moved_to_trash": "{count, plural, one {Movido a la papelera} other {# elementos movidos a la papelera}}",
"delete_forever": "Eliminar definitivamente",
"delete_forever_title": "{count, plural, one {¿Eliminar definitivamente?} other {¿Eliminar # elementos definitivamente?}}",
"delete_forever_message": "Se eliminará «{name}». No se puede deshacer.",
"delete_forever_message_count": "{count, plural, one {Se eliminará 1 elemento} other {Se eliminarán # elementos}}. No se puede deshacer.",
"deleted_forever": "{count, plural, one {Eliminado definitivamente} other {# elementos eliminados definitivamente}}",
"empty_trash": "Vaciar la papelera",
"empty_trash_title": "¿Vaciar la papelera?",
"empty_trash_message": "{count, plural, one {El elemento de la papelera se eliminará} other {Los # elementos de la papelera se eliminarán}} definitivamente. No se puede deshacer.",
"trash_emptied": "Papelera vaciada",
"empty_trash_error": "No se ha podido vaciar la papelera",
"trash_empty_title": "La papelera está vacía",
"trash_empty_hint": "Los archivos eliminados se quedan aquí hasta que vacíes la papelera.",
"deleted": "Eliminado",
"deleted_from": "Eliminado de {folder}",
"shortcuts": {
"list": "Lista",
"actions": "Acciones",
"first_last": "Primero o último",
"extend": "Ampliar la selección",
"toggle": "Seleccionar o deseleccionar",
"select_all": "Seleccionar todo",
"clear": "Borrar la selección o la búsqueda",
"delete": "Eliminar",
"search": "Buscar",
"move": "Elemento siguiente o anterior",
"move_grid": "Elemento contiguo en la cuadrícula",
"open": "Abrir",
"up": "Subir una carpeta",
"rename": "Cambiar nombre",
"copy": "Copiar",
"cut": "Cortar",
"paste": "Pegar"
},
"rename_success": "Renombrado correctamente",
"rename_error": "Error al renombrar",
"download_error": "Error al descargar",
"download_zip": "Descargar como ZIP",
"zipping": "Preparando {name}",
"download_canceled": "Descarga cancelada",
"zip_error": "No se ha podido descargar {name}",
"not_available": "El almacenamiento de archivos no está disponible en este servidor",
"cancel": "Cancelar",
"create": "Crear",
"save": "Guardar",
"no_results": "Ningún archivo coincide con su búsqueda",
"batch_delete_confirm_message": "¿Está seguro de que desea eliminar {count, plural, one {1 elemento} other {# elementos}}? Esta acción no se puede deshacer.",
"batch_delete_success": "{count, plural, one {1 elemento eliminado} other {# elementos eliminados}}",
"grid_view": "Vista de cuadrícula",
"list_view": "Vista de lista",
"details": "Detalles",
@@ -3949,6 +4026,9 @@
"paste": "Pegar",
"move_success": "{count, plural, one {1 elemento movido} other {# elementos movidos}}",
"move_error": "Error al mover",
"move_to": "Mover a…",
"move_to_title": "Mover a",
"move_here": "Mover aquí",
"paste_success": "Pegado correctamente",
"paste_error": "Error al pegar",
"new_text_file": "Nuevo archivo de texto",
@@ -4029,7 +4109,10 @@
"zoom_out": "Alejar",
"zoom_reset": "Ajustar a la pantalla",
"rotate": "Girar",
"close_preview": "Cerrar"
"close_preview": "Cerrar",
"previous_file": "Archivo anterior",
"next_file": "Archivo siguiente",
"loading": "Cargando…"
},
"smime": {
"your_certificates": "Tus certificados",
+95 -12
View File
@@ -2915,7 +2915,7 @@
"add_cc": "Lisa CC-sse",
"add_bcc": "Lisa BCC-sse",
"select_hint": "Vali keegi, et näha tema andmeid",
"write_email": "Kirjuta kiri",
"write_email": "Saada kiri",
"add_to_contacts": "Lisa kontaktidesse",
"email": "E-post",
"read_only_note": "Kataloogi kirjed tulevad sinu meiliserverist ja neid ei saa siin muuta.",
@@ -2928,6 +2928,11 @@
}
},
"title": "Kontaktid",
"disabled_title": "Administraator on kontaktid välja lülitanud",
"disabled_description": "Kui neid vajate, pöörduge administraatori poole.",
"drag_count": "{count, plural, one {1 kontakt} other {# kontakti}}",
"select_contact": "Vali {name}",
"new": "Uus",
"search_placeholder": "Otsi kontakte...",
"create_new": "Uus kontakt",
"no_category": "Kategooria puudub",
@@ -2935,11 +2940,17 @@
"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",
"empty_state_title": "Kontakte veel pole",
"empty_state_subtitle": "Loo oma esimene kontakt või impordi vCard-failist",
"empty_view_title": "Siin pole kontakte",
"empty_view_hint": "Lisage kontakt või importige vCard-fail.",
"empty_search": "Otsingule ei vastanud ükski kontakt",
"empty_search_hint": "Proovi teistsugust otsisõna",
"empty_filtered": "Filtritele ei vastanud ükski kontakt",
@@ -2952,6 +2963,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"
@@ -2961,6 +2973,7 @@
},
"address_books": {
"create": "Uus aadressiraamat",
"create_confirm": "Loo",
"created": "Aadressiraamat loodud",
"create_failed": "Aadressiraamatu loomine ebaõnnestus",
"title": "Minu aadressiraamatud",
@@ -2983,7 +2996,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",
@@ -3009,7 +3025,7 @@
"cert_imported": "Sertifikaat imporditud S/MIME hoidlasse",
"cert_import_failed": "Sertifikaadi importimine ebaõnnestus",
"no_contact_selected": "Vali kontakt, et vaadata üksikasju",
"compose_email": "Koosta kiri",
"compose_email": "Saada kiri",
"copy_email": "Kopeeri e-posti aadress",
"copy_phone": "Kopeeri telefoninumber",
"copy_url": "Kopeeri URL",
@@ -3066,8 +3082,8 @@
"form": {
"create_title": "Uus kontakt",
"edit_title": "Muuda kontakti",
"section_address_book": "Kataloog",
"select_address_book": "Vali kataloog...",
"section_address_book": "Aadressiraamat",
"select_address_book": "Aadressiraamat",
"section_identity": "Nimi ja identiteet",
"contact_type": "Kontakti tüüp",
"type_person": "Isik",
@@ -3154,6 +3170,13 @@
"add_phone": "Lisa telefon",
"save": "Salvesta",
"cancel": "Tühista",
"discard_title": "Kas loobuda muudatustest?",
"discard_message": "Teie muudatusi pole salvestatud.",
"discard": "Loobu",
"keep_editing": "Jätka muutmist",
"remove_entry": "Eemalda",
"close": "Sulge",
"remove_category": "Eemalda {name}",
"creating": "Loomine...",
"updating": "Uuendamine...",
"name_required": "Sisesta eesnimi, perekonnanimi või organisatsioon",
@@ -3170,6 +3193,8 @@
},
"groups": {
"create": "Uus grupp",
"delete": "Kustuta rühm",
"remove_member": "Eemalda {name} rühmast",
"edit": "Muuda gruppi",
"empty": "Gruppe veel pole",
"delete_confirm_title": "Kustuta grupp",
@@ -3182,7 +3207,7 @@
"search_members": "Otsi lisatavaid kontakte...",
"no_members": "Selles grupis pole liikmeid",
"member_count": "{count, plural, =0 {Liikmeid pole} one {1 liige} other {# liiget}}",
"send_email": "Saada kiri grupile",
"send_email": "Saada kiri",
"send_email_to": "Kellele",
"send_email_cc": "Cc",
"send_email_bcc": "Bcc",
@@ -3220,6 +3245,7 @@
"deleted": "{count, plural, one {Kustutati 1 kontakt} other {Kustutati # kontakti}}",
"add_to_group": "Lisa gruppi",
"choose_group": "Vali grupp",
"new_group": "Uus rühm…",
"adding_contacts": "Lisamine: {count, plural, one {1 kontakt} other {# kontakti}}",
"added_to_group": "Kontaktid lisati gruppi",
"export": "Eksport",
@@ -3266,6 +3292,20 @@
"sort_by": "Sordi",
"sort_first_name": "Eesnimi",
"sort_last_name": "Perekonnanimi"
},
"shortcuts": {
"list": "Loend",
"actions": "Toimingud",
"first_last": "Esimene või viimane",
"extend": "Laienda valikut",
"toggle": "Vali või tühista valik",
"select_all": "Vali kõik",
"clear": "Tühjenda valik või otsing",
"delete": "Kustuta",
"search": "Otsi",
"move": "Järgmine või eelmine kontakt",
"open": "Ava kontakt",
"menu": "Ava kontakti menüü"
}
},
"calendar": {
@@ -3946,8 +3986,6 @@
"name_control_chars": "Nimed ei tohi sisaldada juhtmärke.",
"name_too_long": "See nimi on serveri jaoks liiga pikk.",
"delete": "Kustuta",
"delete_confirm_title": "Kustuta ressurss",
"delete_confirm_message": "Kas oled kindel, et soovid kustutada \"{name}\"? Seda ei saa tagasi võtta.",
"download": "Laadi alla",
"name": "Nimi",
"size": "Suurus",
@@ -3973,18 +4011,57 @@
"error_partial_copy": "Osa koopiast jäi alles.",
"create_folder_success": "Kaust loodud",
"create_folder_error": "Kausta loomine ebaõnnestus",
"delete_success": "Edukalt kustutatud",
"delete_error": "Kustutamine ebaõnnestus",
"trash": "Prügikast",
"restore": "Taasta",
"restored": "{count, plural, one {Üksus taastati} other {# üksust taastati}}",
"restore_error": "Taastamine ebaõnnestus",
"moved_to_trash": "{count, plural, one {Teisaldati prügikasti} other {# üksust teisaldati prügikasti}}",
"delete_forever": "Kustuta jäädavalt",
"delete_forever_title": "{count, plural, one {Kas kustutada jäädavalt?} other {Kas kustutada # üksust jäädavalt?}}",
"delete_forever_message": "„{name}“ kustutatakse. Seda ei saa tagasi võtta.",
"delete_forever_message_count": "{count, plural, one {Kustutatakse 1 üksus} other {Kustutatakse # üksust}}. Seda ei saa tagasi võtta.",
"deleted_forever": "{count, plural, one {Kustutati jäädavalt} other {# üksust kustutati jäädavalt}}",
"empty_trash": "Tühjenda prügikast",
"empty_trash_title": "Kas tühjendada prügikast?",
"empty_trash_message": "{count, plural, one {Prügikastis olev üksus} other {Kõik # prügikastis olevat üksust}} kustutatakse jäädavalt. Seda ei saa tagasi võtta.",
"trash_emptied": "Prügikast tühjendati",
"empty_trash_error": "Prügikasti ei õnnestunud tühjendada",
"trash_empty_title": "Prügikast on tühi",
"trash_empty_hint": "Kustutatud failid jäävad siia, kuni prügikasti tühjendate.",
"deleted": "Kustutatud",
"deleted_from": "Kustutatud kaustast {folder}",
"shortcuts": {
"list": "Loend",
"actions": "Toimingud",
"first_last": "Esimene või viimane",
"extend": "Laienda valikut",
"toggle": "Vali või tühista valik",
"select_all": "Vali kõik",
"clear": "Tühjenda valik või otsing",
"delete": "Kustuta",
"search": "Otsi",
"move": "Järgmine või eelmine üksus",
"move_grid": "Kõrvalolev üksus ruudustikus",
"open": "Ava",
"up": "Kaust üles",
"rename": "Nimeta ümber",
"copy": "Kopeeri",
"cut": "Lõika",
"paste": "Kleebi"
},
"rename_success": "Edukalt ümber nimetatud",
"rename_error": "Ümbernimetamine ebaõnnestus",
"download_error": "Allalaadimine ebaõnnestus",
"download_zip": "Laadi alla ZIP-failina",
"zipping": "{name} valmistamine",
"download_canceled": "Allalaadimine tühistati",
"zip_error": "Faili {name} ei õnnestunud alla laadida",
"not_available": "Failihoidla ei ole selles serveris saadaval",
"cancel": "Tühista",
"create": "Loo",
"save": "Salvesta",
"no_results": "Otsingule vastavaid faile ei leitud",
"batch_delete_confirm_message": "Kas oled kindel, et soovid kustutada {count, plural, one {1 üksuse} other {# üksust}}? Seda ei saa tagasi võtta.",
"batch_delete_success": "{count, plural, one {1 üksus kustutatud} other {# üksust kustutatud}}",
"grid_view": "Ruudustikvaade",
"list_view": "Loendivaade",
"details": "Üksikasjad",
@@ -3996,6 +4073,9 @@
"paste": "Kleebi",
"move_success": "{count, plural, one {1 üksus liigutatud} other {# üksust liigutatud}}",
"move_error": "Liigutamine ebaõnnestus",
"move_to": "Teisalda…",
"move_to_title": "Teisalda",
"move_here": "Teisalda siia",
"paste_success": "Edukalt kleebitud",
"paste_error": "Kleepimine ebaõnnestus",
"new_text_file": "Uus tekstifail",
@@ -4075,7 +4155,10 @@
"zoom_out": "Suumi välja",
"zoom_reset": "Sobita ekraanile",
"rotate": "Pööra",
"close_preview": "Sulge"
"close_preview": "Sulge",
"previous_file": "Eelmine fail",
"next_file": "Järgmine fail",
"loading": "Laadimine…"
},
"smime": {
"your_certificates": "Sinu sertifikaadid",
+95 -12
View File
@@ -2915,7 +2915,7 @@
"add_cc": "افزودن به «رونوشت»",
"add_bcc": "افزودن به «رونوشت پنهان»",
"select_hint": "برای دیدن جزئیات، یک نفر را انتخاب کنید",
"write_email": "نوشتن ایمیل",
"write_email": "ارسال ایمیل",
"add_to_contacts": "افزودن به مخاطبان",
"email": "ایمیل",
"read_only_note": "ورودی‌های فهرست از سرور ایمیل شما می‌آیند و اینجا قابل ویرایش نیستند.",
@@ -2928,6 +2928,11 @@
}
},
"title": "مخاطبین",
"disabled_title": "مدیر شما مخاطبان را غیرفعال کرده است",
"disabled_description": "اگر به آن‌ها نیاز دارید، با مدیر تماس بگیرید.",
"drag_count": "{count, plural, one {۱ مخاطب} other {# مخاطب}}",
"select_contact": "انتخاب {name}",
"new": "جدید",
"search_placeholder": "جستجوی مخاطبین...",
"create_new": "مخاطب جدید",
"no_category": "بدون دسته‌بندی",
@@ -2935,11 +2940,17 @@
"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": "هنوز مخاطبی نیست",
"empty_state_title": "هنوز مخاطبی نیست",
"empty_state_subtitle": "اولین مخاطب خود را ایجاد کنید یا از فایل vCard وارد کنید",
"empty_view_title": "مخاطبی اینجا نیست",
"empty_view_hint": "مخاطبی اضافه کنید یا فایل vCard را وارد کنید.",
"empty_search": "مخاطبی با این جستجو مطابقت ندارد",
"empty_search_hint": "عبارت جستجوی دیگری را امتحان کنید",
"empty_filtered": "مخاطبی با این فیلترها مطابقت ندارد",
@@ -2952,6 +2963,7 @@
"back_to_contacts": "بازگشت به مخاطبین",
"back_to_email": "بازگشت به ایمیل",
"open_categories": "باز کردن دسته‌بندی‌ها",
"row_actions": "کنش‌های {name}",
"tabs": {
"all": "همه",
"groups": "گروه‌ها"
@@ -2961,6 +2973,7 @@
},
"address_books": {
"create": "دفترچه آدرس جدید",
"create_confirm": "ایجاد",
"created": "دفترچه آدرس ایجاد شد",
"create_failed": "ایجاد دفترچه آدرس ناموفق بود",
"title": "دفترچه‌های آدرس من",
@@ -2983,7 +2996,10 @@
"delete": "حذف دفترچه آدرس",
"confirm_delete": "حذف \"{name}\"؟ همه مخاطبین این دفترچه آدرس حذف خواهند شد.",
"deleted": "دفترچه آدرس حذف شد",
"delete_failed": "حذف دفترچه آدرس ناموفق بود"
"delete_failed": "حذف دفترچه آدرس ناموفق بود",
"move_to": "انتقال به…",
"move_title": "انتقال به دفترچه نشانی",
"move_here": "انتقال به اینجا"
},
"detail": {
"emails": "آدرس‌های ایمیل",
@@ -3009,7 +3025,7 @@
"cert_imported": "گواهی به مخزن S/MIME وارد شد",
"cert_import_failed": "وارد کردن گواهی ناموفق بود",
"no_contact_selected": "یک مخاطب برای مشاهده جزئیات انتخاب کنید",
"compose_email": "نوشتن ایمیل",
"compose_email": "ارسال ایمیل",
"copy_email": "کپی ایمیل",
"copy_phone": "کپی شماره تلفن",
"copy_url": "کپی آدرس",
@@ -3066,8 +3082,8 @@
"form": {
"create_title": "مخاطب جدید",
"edit_title": "ویرایش مخاطب",
"section_address_book": "دفترچه",
"select_address_book": "انتخاب دفترچه...",
"section_address_book": "دفترچه آدرس",
"select_address_book": "دفترچه آدرس",
"section_identity": "نام و هویت",
"contact_type": "نوع مخاطب",
"type_person": "شخص",
@@ -3154,6 +3170,13 @@
"add_phone": "افزودن تلفن",
"save": "ذخیره",
"cancel": "انصراف",
"discard_title": "تغییرات دور انداخته شوند؟",
"discard_message": "تغییرات شما ذخیره نشده است.",
"discard": "دور انداختن",
"keep_editing": "ادامه ویرایش",
"remove_entry": "حذف",
"close": "بستن",
"remove_category": "حذف {name}",
"creating": "در حال ایجاد...",
"updating": "در حال به‌روزرسانی...",
"name_required": "نام، نام خانوادگی یا سازمان را وارد کنید",
@@ -3170,6 +3193,8 @@
},
"groups": {
"create": "گروه جدید",
"delete": "حذف گروه",
"remove_member": "حذف {name} از گروه",
"edit": "ویرایش گروه",
"empty": "هنوز گروهی نیست",
"delete_confirm_title": "حذف گروه",
@@ -3182,7 +3207,7 @@
"search_members": "جستجوی مخاطبین برای افزودن...",
"no_members": "عضوی در این گروه نیست",
"member_count": "{count, plural, =0 {بدون عضو} one {۱ عضو} other {# عضو}}",
"send_email": "ارسال ایمیل به گروه",
"send_email": "ارسال ایمیل",
"send_email_to": "به",
"send_email_cc": "رونوشت",
"send_email_bcc": "رونوشت مخفی",
@@ -3220,6 +3245,7 @@
"deleted": "{count, plural, one {۱ مخاطب حذف شد} other {# مخاطب حذف شد}}",
"add_to_group": "افزودن به گروه",
"choose_group": "انتخاب گروه",
"new_group": "گروه جدید…",
"adding_contacts": "افزودن {count, plural, one {۱ مخاطب} other {# مخاطب}}",
"added_to_group": "مخاطبین به گروه اضافه شدند",
"export": "خروجی",
@@ -3266,6 +3292,20 @@
"sort_by": "مرتب‌سازی بر اساس",
"sort_first_name": "نام",
"sort_last_name": "نام خانوادگی"
},
"shortcuts": {
"list": "فهرست",
"actions": "کنش‌ها",
"first_last": "اولین یا آخرین",
"extend": "گسترش انتخاب",
"toggle": "انتخاب یا لغو انتخاب",
"select_all": "انتخاب همه",
"clear": "پاک کردن انتخاب یا جستجو",
"delete": "حذف",
"search": "جستجو",
"move": "مخاطب بعدی یا قبلی",
"open": "باز کردن مخاطب",
"menu": "باز کردن منوی مخاطب"
}
},
"calendar": {
@@ -3946,8 +3986,6 @@
"name_control_chars": "نام‌ها نمی‌توانند نویسه‌های کنترلی داشته باشند.",
"name_too_long": "این نام برای سرور بیش از حد طولانی است.",
"delete": "حذف",
"delete_confirm_title": "حذف منبع",
"delete_confirm_message": "آیا از حذف \"{name}\" اطمینان دارید؟ این عملیات قابل بازگشت نیست.",
"download": "دانلود",
"name": "نام",
"size": "حجم",
@@ -3973,18 +4011,57 @@
"error_partial_copy": "بخشی از رونوشت در جای خود باقی ماند.",
"create_folder_success": "پوشه ایجاد شد",
"create_folder_error": "ایجاد پوشه ناموفق بود",
"delete_success": "با موفقیت حذف شد",
"delete_error": "حذف ناموفق بود",
"trash": "سطل زباله",
"restore": "بازیابی",
"restored": "{count, plural, one {مورد بازیابی شد} other {# مورد بازیابی شد}}",
"restore_error": "بازیابی انجام نشد",
"moved_to_trash": "{count, plural, one {به سطل زباله منتقل شد} other {# مورد به سطل زباله منتقل شد}}",
"delete_forever": "حذف دائمی",
"delete_forever_title": "{count, plural, one {برای همیشه حذف شود؟} other {# مورد برای همیشه حذف شود؟}}",
"delete_forever_message": "«{name}» حذف می‌شود. این کار قابل بازگشت نیست.",
"delete_forever_message_count": "{count, plural, one {۱ مورد حذف می‌شود} other {# مورد حذف می‌شود}}. این کار قابل بازگشت نیست.",
"deleted_forever": "{count, plural, one {برای همیشه حذف شد} other {# مورد برای همیشه حذف شد}}",
"empty_trash": "خالی کردن سطل زباله",
"empty_trash_title": "سطل زباله خالی شود؟",
"empty_trash_message": "{count, plural, one {مورد داخل سطل زباله} other {همه # مورد داخل سطل زباله}} برای همیشه حذف می‌شود. این کار قابل بازگشت نیست.",
"trash_emptied": "سطل زباله خالی شد",
"empty_trash_error": "سطل زباله خالی نشد",
"trash_empty_title": "سطل زباله خالی است",
"trash_empty_hint": "فایل‌های حذف‌شده تا زمانی که سطل زباله را خالی کنید اینجا می‌مانند.",
"deleted": "حذف‌شده",
"deleted_from": "حذف‌شده از {folder}",
"shortcuts": {
"list": "فهرست",
"actions": "کنش‌ها",
"first_last": "اولین یا آخرین",
"extend": "گسترش انتخاب",
"toggle": "انتخاب یا لغو انتخاب",
"select_all": "انتخاب همه",
"clear": "پاک کردن انتخاب یا جستجو",
"delete": "حذف",
"search": "جستجو",
"move": "مورد بعدی یا قبلی",
"move_grid": "مورد کناری در جدول",
"open": "باز کردن",
"up": "یک پوشه بالاتر",
"rename": "تغییر نام",
"copy": "کپی",
"cut": "برش",
"paste": "چسباندن"
},
"rename_success": "تغییر نام با موفقیت انجام شد",
"rename_error": "تغییر نام ناموفق بود",
"download_error": "دانلود ناموفق بود",
"download_zip": "دانلود به صورت ZIP",
"zipping": "در حال آماده‌سازی {name}",
"download_canceled": "دانلود لغو شد",
"zip_error": "{name} دانلود نشد",
"not_available": "ذخیره‌سازی فایل روی این سرور در دسترس نیست",
"cancel": "انصراف",
"create": "ایجاد",
"save": "ذخیره",
"no_results": "فایلی با این جستجو مطابقت ندارد",
"batch_delete_confirm_message": "آیا از حذف {count, plural, one {۱ مورد} other {# مورد}} اطمینان دارید؟",
"batch_delete_success": "{count, plural, one {۱ مورد حذف شد} other {# مورد حذف شد}}",
"grid_view": "نمای شبکه‌ای",
"list_view": "نمای لیست",
"details": "جزئیات",
@@ -3996,6 +4073,9 @@
"paste": "چسباندن",
"move_success": "{count, plural, one {۱ مورد منتقل شد} other {# مورد منتقل شد}}",
"move_error": "انتقال ناموفق بود",
"move_to": "انتقال به…",
"move_to_title": "انتقال به",
"move_here": "انتقال به اینجا",
"paste_success": "با موفقیت چسبانده شد",
"paste_error": "چسباندن ناموفق بود",
"new_text_file": "فایل متنی جدید",
@@ -4075,7 +4155,10 @@
"zoom_out": "کوچک‌نمایی",
"zoom_reset": "متناسب با صفحه",
"rotate": "چرخش",
"close_preview": "بستن"
"close_preview": "بستن",
"previous_file": "فایل قبلی",
"next_file": "فایل بعدی",
"loading": "در حال بارگیری…"
},
"smime": {
"your_certificates": "گواهی‌های شما",
+96 -13
View File
@@ -2915,7 +2915,7 @@
"add_cc": "Ajouter à Cc",
"add_bcc": "Ajouter à Cci",
"select_hint": "Sélectionnez une personne pour voir ses détails",
"write_email": "Écrire un e-mail",
"write_email": "Envoyer un courriel",
"add_to_contacts": "Ajouter aux contacts",
"email": "E-mail",
"read_only_note": "Les entrées de l'annuaire proviennent de votre serveur de messagerie et ne peuvent pas être modifiées ici.",
@@ -2928,6 +2928,11 @@
}
},
"title": "Contacts",
"disabled_title": "Les contacts ont été désactivés par votre administrateur",
"disabled_description": "Contactez votre administrateur si vous en avez besoin.",
"drag_count": "{count, plural, one {1 contact} other {# contacts}}",
"select_contact": "Sélectionner {name}",
"new": "Nouveau",
"search_placeholder": "Rechercher des contacts...",
"create_new": "Nouveau contact",
"no_category": "Sans catégorie",
@@ -2935,11 +2940,17 @@
"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",
"empty_state_title": "Aucun contact",
"empty_state_subtitle": "Créez votre premier contact ou importez depuis un fichier vCard",
"empty_view_title": "Aucun contact ici",
"empty_view_hint": "Ajoutez un contact ou importez un fichier vCard.",
"empty_search": "Aucun contact ne correspond à votre recherche",
"empty_search_hint": "Essayez un autre terme de recherche",
"empty_filtered": "Aucun contact ne correspond à vos filtres",
@@ -2960,6 +2971,7 @@
},
"address_books": {
"create": "Nouveau carnet d'adresses",
"create_confirm": "Créer",
"created": "Carnet d'adresses créé",
"create_failed": "Échec de la création du carnet d'adresses",
"title": "Mes Carnets d'adresses",
@@ -2982,7 +2994,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",
@@ -2999,7 +3014,7 @@
"related_contacts": "Contacts associés",
"crypto_keys": "Clés cryptographiques",
"no_contact_selected": "Sélectionnez un contact pour voir les détails",
"compose_email": "Écrire un e-mail",
"compose_email": "Envoyer un courriel",
"copy_email": "Copier l'e-mail",
"copy_phone": "Copier le numéro",
"copy_url": "Copier l'URL",
@@ -3065,8 +3080,8 @@
"form": {
"create_title": "Nouveau contact",
"edit_title": "Modifier le contact",
"section_address_book": "Répertoire",
"select_address_book": "Sélectionner un répertoire...",
"section_address_book": "Carnet d'adresses",
"select_address_book": "Carnet d'adresses",
"section_identity": "Nom et identité",
"contact_type": "Type de contact",
"type_person": "Personne",
@@ -3153,6 +3168,13 @@
"add_phone": "Ajouter un téléphone",
"save": "Enregistrer",
"cancel": "Annuler",
"discard_title": "Abandonner les modifications ?",
"discard_message": "Vos modifications n'ont pas été enregistrées.",
"discard": "Abandonner",
"keep_editing": "Continuer l'édition",
"remove_entry": "Retirer",
"close": "Fermer",
"remove_category": "Retirer {name}",
"creating": "Création...",
"updating": "Mise à jour...",
"name_required": "Saisissez un prénom, un nom ou une organisation",
@@ -3169,6 +3191,8 @@
},
"groups": {
"create": "Nouveau groupe",
"delete": "Supprimer le groupe",
"remove_member": "Retirer {name} du groupe",
"edit": "Modifier le groupe",
"empty": "Aucun groupe",
"delete_confirm_title": "Supprimer le groupe",
@@ -3181,7 +3205,7 @@
"search_members": "Rechercher des contacts à ajouter...",
"no_members": "Aucun membre dans ce groupe",
"member_count": "{count, plural, =0 {Aucun membre} one {1 membre} other {# membres}}",
"send_email": "Send email to group",
"send_email": "Envoyer un courriel",
"send_email_to": "À",
"send_email_cc": "Cc",
"send_email_bcc": "Cci",
@@ -3219,6 +3243,7 @@
"deleted": "{count, plural, one {1 contact supprimé} other {# contacts supprimés}}",
"add_to_group": "Ajouter au groupe",
"choose_group": "Choisir un groupe",
"new_group": "Nouveau groupe…",
"adding_contacts": "Ajout de {count, plural, one {1 contact} other {# contacts}}",
"added_to_group": "Contacts ajoutés au groupe",
"export": "Exporter",
@@ -3266,7 +3291,22 @@
"sort_first_name": "Prénom",
"sort_last_name": "Nom de famille"
},
"open_categories": "Ouvrir les catégories"
"shortcuts": {
"list": "Liste",
"actions": "Actions",
"first_last": "Premier ou dernier",
"extend": "Étendre la sélection",
"toggle": "Sélectionner ou désélectionner",
"select_all": "Tout sélectionner",
"clear": "Effacer la sélection ou la recherche",
"delete": "Supprimer",
"search": "Rechercher",
"move": "Contact suivant ou précédent",
"open": "Ouvrir le contact",
"menu": "Ouvrir le menu du contact"
},
"open_categories": "Ouvrir les catégories",
"row_actions": "Actions pour {name}"
},
"calendar": {
"title": "Calendrier",
@@ -3900,8 +3940,6 @@
"name_control_chars": "Les noms ne peuvent pas contenir de caractères de contrôle.",
"name_too_long": "Ce nom est trop long pour le serveur.",
"delete": "Supprimer",
"delete_confirm_title": "Supprimer la ressource",
"delete_confirm_message": "Êtes-vous sûr de vouloir supprimer \"{name}\" ? Cette action est irréversible.",
"download": "Télécharger",
"name": "Nom",
"size": "Taille",
@@ -3926,18 +3964,57 @@
"error_partial_copy": "Une partie de la copie est restée en place.",
"create_folder_success": "Dossier créé",
"create_folder_error": "Échec de la création du dossier",
"delete_success": "Supprimé avec succès",
"delete_error": "Échec de la suppression",
"trash": "Corbeille",
"restore": "Restaurer",
"restored": "{count, plural, one {Élément restauré} other {# éléments restaurés}}",
"restore_error": "Impossible de restaurer",
"moved_to_trash": "{count, plural, one {Placé dans la corbeille} other {# éléments placés dans la corbeille}}",
"delete_forever": "Supprimer définitivement",
"delete_forever_title": "{count, plural, one {Supprimer définitivement ?} other {Supprimer définitivement # éléments ?}}",
"delete_forever_message": "« {name} » sera supprimé. Cette action est irréversible.",
"delete_forever_message_count": "{count, plural, one {1 élément sera supprimé} other {# éléments seront supprimés}}. Cette action est irréversible.",
"deleted_forever": "{count, plural, one {Supprimé définitivement} other {# éléments supprimés définitivement}}",
"empty_trash": "Vider la corbeille",
"empty_trash_title": "Vider la corbeille ?",
"empty_trash_message": "{count, plural, one {L'élément de la corbeille sera supprimé} other {Les # éléments de la corbeille seront supprimés}} définitivement. Cette action est irréversible.",
"trash_emptied": "Corbeille vidée",
"empty_trash_error": "Impossible de vider la corbeille",
"trash_empty_title": "La corbeille est vide",
"trash_empty_hint": "Les fichiers supprimés restent ici jusqu'à ce que vous vidiez la corbeille.",
"deleted": "Supprimé le",
"deleted_from": "Supprimé de {folder}",
"shortcuts": {
"list": "Liste",
"actions": "Actions",
"first_last": "Premier ou dernier",
"extend": "Étendre la sélection",
"toggle": "Sélectionner ou désélectionner",
"select_all": "Tout sélectionner",
"clear": "Effacer la sélection ou la recherche",
"delete": "Supprimer",
"search": "Rechercher",
"move": "Élément suivant ou précédent",
"move_grid": "Élément voisin dans la grille",
"open": "Ouvrir",
"up": "Remonter d'un dossier",
"rename": "Renommer",
"copy": "Copier",
"cut": "Couper",
"paste": "Coller"
},
"rename_success": "Renommé avec succès",
"rename_error": "Échec du renommage",
"download_error": "Échec du téléchargement",
"download_zip": "Télécharger en ZIP",
"zipping": "Préparation de {name}",
"download_canceled": "Téléchargement annulé",
"zip_error": "Impossible de télécharger {name}",
"not_available": "Le stockage de fichiers n'est pas disponible sur ce serveur",
"cancel": "Annuler",
"create": "Créer",
"save": "Enregistrer",
"no_results": "Aucun fichier ne correspond à votre recherche",
"batch_delete_confirm_message": "Êtes-vous sûr de vouloir supprimer {count, plural, one {1 élément} other {# éléments}} ? Cette action est irréversible.",
"batch_delete_success": "{count, plural, one {1 élément supprimé} other {# éléments supprimés}}",
"grid_view": "Vue en grille",
"list_view": "Vue en liste",
"details": "Détails",
@@ -3949,6 +4026,9 @@
"paste": "Coller",
"move_success": "{count, plural, one {1 élément déplacé} other {# éléments déplacés}}",
"move_error": "Échec du déplacement",
"move_to": "Déplacer vers…",
"move_to_title": "Déplacer vers",
"move_here": "Déplacer ici",
"paste_success": "Collé avec succès",
"paste_error": "Échec du collage",
"new_text_file": "Nouveau fichier texte",
@@ -4029,7 +4109,10 @@
"zoom_out": "Zoom arrière",
"zoom_reset": "Ajuster à l'écran",
"rotate": "Pivoter",
"close_preview": "Fermer"
"close_preview": "Fermer",
"previous_file": "Fichier précédent",
"next_file": "Fichier suivant",
"loading": "Chargement…"
},
"smime": {
"your_certificates": "Vos certificats",
+95 -12
View File
@@ -2833,7 +2833,7 @@
"add_cc": "הוספה לעותק",
"add_bcc": "הוספה לעותק מוסתר",
"select_hint": "בחר אדם כדי לראות את פרטיו",
"write_email": "כתיבת הודעה",
"write_email": "שלח דוא״ל",
"add_to_contacts": "הוספה לאנשי קשר",
"email": "דוא״ל",
"read_only_note": "רשומות הספרייה מגיעות משרת הדואר ואי אפשר לערוך אותן כאן.",
@@ -2846,6 +2846,11 @@
}
},
"title": "אנשי קשר",
"disabled_title": "מנהל המערכת השבית את אנשי הקשר",
"disabled_description": "פנה למנהל המערכת אם אתה צריך אותם.",
"drag_count": "{count, plural, one {איש קשר אחד} other {# אנשי קשר}}",
"select_contact": "בחר את {name}",
"new": "חדש",
"search_placeholder": "חפש אנשי קשר...",
"create_new": "איש קשר חדש",
"no_category": "אין קטגוריה",
@@ -2853,11 +2858,17 @@
"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": "עדיין אין אנשי קשר",
"empty_state_title": "עדיין אין אנשי קשר",
"empty_state_subtitle": "צור את איש הקשר הראשון שלך או ייבא מקובץ vCard",
"empty_view_title": "אין כאן אנשי קשר",
"empty_view_hint": "הוסף איש קשר או ייבא קובץ vCard.",
"empty_search": "אין אנשי קשר שתואמים לחיפוש שלך",
"empty_search_hint": "נסה מונח חיפוש אחר",
"clear_search": "נקה חיפוש",
@@ -2890,6 +2901,7 @@
"set_default_failed": "נכשל בהגדרת פנקס כתובות ברירת מחדל",
"manage": "ניהול פנקסי כתובות",
"create": "ספר כתובות חדש",
"create_confirm": "צור",
"created": "ספר כתובות נוצר",
"create_failed": "נכשל ביצירת ספר כתובות",
"share": "שתף ספר כתובות",
@@ -2897,7 +2909,10 @@
"delete": "מחק ספר כתובות",
"confirm_delete": "מחק \"{name}\"? כל אנשי הקשר בספר כתובות זה יוסרו.",
"deleted": "ספר כתובות נמחק",
"delete_failed": "נכשל במחיקת ספר כתובות"
"delete_failed": "נכשל במחיקת ספר כתובות",
"move_to": "העבר אל…",
"move_title": "העבר לפנקס כתובות",
"move_here": "העבר לכאן"
},
"detail": {
"emails": "כתובות אימייל",
@@ -2923,7 +2938,7 @@
"cert_imported": "אישור מיובא לחנות S/MIME",
"cert_import_failed": "ייבוא האישור נכשל",
"no_contact_selected": "בחר איש קשר כדי להציג פרטים",
"compose_email": "כתוב אימייל",
"compose_email": "שלח דוא״ל",
"copy_email": "העתק אימייל",
"copy_phone": "העתק את מספר הטלפון",
"copy_url": "העתק כתובת URL",
@@ -2969,8 +2984,8 @@
"form": {
"create_title": "איש קשר חדש",
"edit_title": "ערוך איש קשר",
"section_address_book": "ספרייה",
"select_address_book": "בחר ספרייה...",
"section_address_book": "ספר כתובות",
"select_address_book": "ספר כתובות",
"section_identity": "שם וזהות",
"contact_type": "סוג איש קשר",
"type_person": "אדם",
@@ -3057,6 +3072,13 @@
"add_phone": "הוסף טלפון",
"save": "שמור",
"cancel": "לְבַטֵל",
"discard_title": "לבטל את השינויים?",
"discard_message": "השינויים שלך לא נשמרו.",
"discard": "בטל",
"keep_editing": "המשך לערוך",
"remove_entry": "הסר",
"close": "סגור",
"remove_category": "הסר את {name}",
"creating": "יוצר...",
"updating": "מעדכן...",
"name_required": "יש להזין שם פרטי, שם משפחה או ארגון",
@@ -3073,6 +3095,8 @@
},
"groups": {
"create": "קבוצה חדשה",
"delete": "מחק קבוצה",
"remove_member": "הסר את {name} מהקבוצה",
"edit": "ערוך קבוצה",
"empty": "עדיין אין קבוצות",
"delete_confirm_title": "מחק את הקבוצה",
@@ -3085,7 +3109,7 @@
"search_members": "חפש אנשי קשר להוספה...",
"no_members": "אין חברים בקבוצה זו",
"member_count": "{count, plural, =0 {אין חברים} one {חבר אחד} other {# חברים}}",
"send_email": "שלח דוא״ל לקבוצה",
"send_email": "שלח דוא״ל",
"send_email_to": "אל",
"send_email_cc": "עותק",
"send_email_bcc": "עותק נסתר",
@@ -3123,6 +3147,7 @@
"deleted": "{count, plural, one {נמחק איש קשר אחד} other {נמחקו # אנשי קשר}}",
"add_to_group": "הוסף לקבוצה",
"choose_group": "בחר קבוצה",
"new_group": "קבוצה חדשה…",
"adding_contacts": "מוסיף {count, plural, one {איש קשר אחד} other {# אנשי קשר}}",
"added_to_group": "אנשי קשר נוספו לקבוצה",
"export": "ייצוא",
@@ -3140,6 +3165,7 @@
"empty_filtered_hint": "נסה להתאים או להביא סננים",
"back_to_email": "חזור לדוא״ל",
"open_categories": "קטגוריות פתוחות",
"row_actions": "פעולות עבור {name}",
"activity": {
"recent_emails": "דוא״ל אחרונים",
"upcoming_events": "אירועים קרובים",
@@ -3184,6 +3210,20 @@
"sort_by": "מיון לפי",
"sort_first_name": "שם פרטי",
"sort_last_name": "שם משפחה"
},
"shortcuts": {
"list": "רשימה",
"actions": "פעולות",
"first_last": "הראשון או האחרון",
"extend": "הרחב את הבחירה",
"toggle": "בחר או בטל בחירה",
"select_all": "בחר הכול",
"clear": "נקה את הבחירה או החיפוש",
"delete": "מחק",
"search": "חיפוש",
"move": "איש הקשר הבא או הקודם",
"open": "פתח את איש הקשר",
"menu": "פתח את התפריט של איש הקשר"
}
},
"calendar": {
@@ -3817,8 +3857,6 @@
"name_control_chars": "שמות אינם יכולים להכיל תווי בקרה.",
"name_too_long": "השם הזה ארוך מדי עבור השרת.",
"delete": "לִמְחוֹק",
"delete_confirm_title": "מחק משאב",
"delete_confirm_message": "האם אתה בטוח שברצונך למחוק את \"{name}\"? לא ניתן לבטל זאת.",
"download": "הורד",
"name": "שֵׁם",
"size": "גודל",
@@ -3843,18 +3881,57 @@
"error_partial_copy": "חלק מההעתק נשאר במקומו.",
"create_folder_success": "התיקיה נוצרה",
"create_folder_error": "יצירת התיקיה נכשלה",
"delete_success": "נמחק בהצלחה",
"delete_error": "המחיקה נכשלה",
"trash": "אשפה",
"restore": "שחזר",
"restored": "{count, plural, one {הפריט שוחזר} other {# פריטים שוחזרו}}",
"restore_error": "לא ניתן היה לשחזר",
"moved_to_trash": "{count, plural, one {הועבר לאשפה} other {# פריטים הועברו לאשפה}}",
"delete_forever": "מחק לצמיתות",
"delete_forever_title": "{count, plural, one {למחוק לצמיתות?} other {למחוק # פריטים לצמיתות?}}",
"delete_forever_message": "\"{name}\" יימחק. לא ניתן לבטל פעולה זו.",
"delete_forever_message_count": "{count, plural, one {פריט אחד יימחק} other {# פריטים יימחקו}}. לא ניתן לבטל פעולה זו.",
"deleted_forever": "{count, plural, one {נמחק לצמיתות} other {# פריטים נמחקו לצמיתות}}",
"empty_trash": "רוקן את האשפה",
"empty_trash_title": "לרוקן את האשפה?",
"empty_trash_message": "{count, plural, one {הפריט באשפה יימחק} other {כל # הפריטים באשפה יימחקו}} לצמיתות. לא ניתן לבטל פעולה זו.",
"trash_emptied": "האשפה רוקנה",
"empty_trash_error": "לא ניתן היה לרוקן את האשפה",
"trash_empty_title": "האשפה ריקה",
"trash_empty_hint": "קבצים שנמחקו נשארים כאן עד שתרוקן את האשפה.",
"deleted": "נמחק",
"deleted_from": "נמחק מ-{folder}",
"shortcuts": {
"list": "רשימה",
"actions": "פעולות",
"first_last": "הראשון או האחרון",
"extend": "הרחב את הבחירה",
"toggle": "בחר או בטל בחירה",
"select_all": "בחר הכול",
"clear": "נקה את הבחירה או החיפוש",
"delete": "מחק",
"search": "חיפוש",
"move": "הפריט הבא או הקודם",
"move_grid": "הפריט הסמוך ברשת",
"open": "פתח",
"up": "תיקייה אחת למעלה",
"rename": "שנה שם",
"copy": "העתק",
"cut": "גזור",
"paste": "הדבק"
},
"rename_success": "השם שונה בהצלחה",
"rename_error": "שינוי השם נכשל",
"download_error": "ההורדה נכשלה",
"download_zip": "הורד כקובץ ZIP",
"zipping": "מכין את {name}",
"download_canceled": "ההורדה בוטלה",
"zip_error": "לא ניתן היה להוריד את {name}",
"not_available": "אחסון קבצים אינו זמין בשרת זה",
"cancel": "לְבַטֵל",
"create": "צור",
"save": "שמור",
"no_results": "אין קבצים שתואמים לחיפוש שלך",
"batch_delete_confirm_message": "האם למחוק {count, plural, one {פריט אחד} other {# פריטים}}? לא ניתן לבטל זאת.",
"batch_delete_success": "{count, plural, one {פריט אחד נמחק} other {# פריטים נמחקו}}",
"grid_view": "תצוגת רשת",
"list_view": "תצוגת רשימה",
"details": "פרטים",
@@ -3866,6 +3943,9 @@
"paste": "הדבק",
"move_success": "{count, plural, one {פריט אחד הועבר} other {# פריטים הועברו}}",
"move_error": "לא הצליח לזוז",
"move_to": "העבר אל…",
"move_to_title": "העבר אל",
"move_here": "העבר לכאן",
"paste_success": "הודבק בהצלחה",
"paste_error": "ההדבקה נכשלה",
"new_text_file": "קובץ טקסט חדש",
@@ -3947,7 +4027,10 @@
"zoom_out": "הקטן",
"zoom_reset": "התאם למסך",
"rotate": "סובב",
"close_preview": "סגור"
"close_preview": "סגור",
"previous_file": "הקובץ הקודם",
"next_file": "הקובץ הבא",
"loading": "טוען…"
},
"smime": {
"your_certificates": "התעודות שלך",
+95 -12
View File
@@ -2915,7 +2915,7 @@
"add_cc": "Hozzáadás: Másolat",
"add_bcc": "Hozzáadás: Titkos másolat",
"select_hint": "Válassz ki valakit a részletekhez",
"write_email": "E-mail írása",
"write_email": "E-mail küldése",
"add_to_contacts": "Hozzáadás a névjegyekhez",
"email": "E-mail",
"read_only_note": "A címtár bejegyzései a levelezőszerverről származnak, itt nem szerkeszthetők.",
@@ -2928,6 +2928,11 @@
}
},
"title": "Névjegyek",
"disabled_title": "A névjegyeket a rendszergazda kikapcsolta",
"disabled_description": "Ha szüksége van rájuk, forduljon a rendszergazdához.",
"drag_count": "{count, plural, one {1 névjegy} other {# névjegy}}",
"select_contact": "{name} kijelölése",
"new": "Új",
"search_placeholder": "Névjegyek keresése...",
"create_new": "Új névjegy",
"no_category": "Nincs kategória",
@@ -2935,11 +2940,17 @@
"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",
"empty_state_title": "Még nincsenek névjegyek",
"empty_state_subtitle": "Hozd létre az első névjegyed vagy importálj vCard fájlból",
"empty_view_title": "Itt nincsenek névjegyek",
"empty_view_hint": "Adjon hozzá egy névjegyet, vagy importáljon egy vCard-fájlt.",
"empty_search": "Nincs a keresésnek megfelelő névjegy",
"empty_search_hint": "Próbálj meg más keresési kifejezést",
"empty_filtered": "Nincs a szűrőknek megfelelő névjegy",
@@ -2952,6 +2963,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"
@@ -2961,6 +2973,7 @@
},
"address_books": {
"create": "Új címjegyzék",
"create_confirm": "Létrehozás",
"created": "Címjegyzék létrehozva",
"create_failed": "Nem sikerült létrehozni a címjegyzéket",
"title": "Címjegyzékeim",
@@ -2983,7 +2996,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",
@@ -3009,7 +3025,7 @@
"cert_imported": "Tanúsítvány importálva az S/MIME tárolóba",
"cert_import_failed": "Nem sikerült importálni a tanúsítványt",
"no_contact_selected": "Válassz ki egy névjegyet a részletek megtekintéséhez",
"compose_email": "E-mail írása",
"compose_email": "E-mail küldése",
"copy_email": "E-mail másolása",
"copy_phone": "Telefonszám másolása",
"copy_url": "URL másolása",
@@ -3066,8 +3082,8 @@
"form": {
"create_title": "Új névjegy",
"edit_title": "Névjegy szerkesztése",
"section_address_book": "Címtár",
"select_address_book": "Címtár kiválasztása...",
"section_address_book": "Címjegyzék",
"select_address_book": "Címjegyzék",
"section_identity": "Név és azonosság",
"contact_type": "Névjegy típusa",
"type_person": "Személy",
@@ -3154,6 +3170,13 @@
"add_phone": "Telefon hozzáadása",
"save": "Mentés",
"cancel": "Mégse",
"discard_title": "Elveti a változtatásokat?",
"discard_message": "A változtatásai nincsenek mentve.",
"discard": "Elvetés",
"keep_editing": "Szerkesztés folytatása",
"remove_entry": "Eltávolítás",
"close": "Bezárás",
"remove_category": "{name} eltávolítása",
"creating": "Létrehozás...",
"updating": "Frissítés...",
"name_required": "Adjon meg egy keresztnevet, vezetéknevet vagy szervezetet",
@@ -3170,6 +3193,8 @@
},
"groups": {
"create": "Új csoport",
"delete": "Csoport törlése",
"remove_member": "{name} eltávolítása a csoportból",
"edit": "Csoport szerkesztése",
"empty": "Még nincsenek csoportok",
"delete_confirm_title": "Csoport törlése",
@@ -3182,7 +3207,7 @@
"search_members": "Névjegyek keresése a hozzáadáshoz...",
"no_members": "Nincs tag ebben a csoportban",
"member_count": "{count, plural, =0 {Nincs tag} one {1 tag} other {# tag}}",
"send_email": "Send email to group",
"send_email": "E-mail küldése",
"send_email_to": "To",
"send_email_cc": "Cc",
"send_email_bcc": "Bcc",
@@ -3220,6 +3245,7 @@
"deleted": "{count, plural, one {1 névjegy törölve} other {# névjegy törölve}}",
"add_to_group": "Hozzáadás a csoporthoz",
"choose_group": "Csoport kiválasztása",
"new_group": "Új csoport…",
"adding_contacts": "{count, plural, one {1 névjegy} other {# névjegy}} hozzáadása",
"added_to_group": "Névjegyek hozzáadva a csoporthoz",
"export": "Exportálás",
@@ -3266,6 +3292,20 @@
"sort_by": "Rendezés",
"sort_first_name": "Keresztnév",
"sort_last_name": "Vezetéknév"
},
"shortcuts": {
"list": "Lista",
"actions": "Műveletek",
"first_last": "Első vagy utolsó",
"extend": "Kijelölés bővítése",
"toggle": "Kijelölés vagy kijelölés megszüntetése",
"select_all": "Összes kijelölése",
"clear": "Kijelölés vagy keresés törlése",
"delete": "Törlés",
"search": "Keresés",
"move": "Következő vagy előző névjegy",
"open": "Névjegy megnyitása",
"menu": "Névjegy menüjének megnyitása"
}
},
"calendar": {
@@ -3946,8 +3986,6 @@
"name_control_chars": "A nevek nem tartalmazhatnak vezérlőkaraktereket.",
"name_too_long": "Ez a név túl hosszú a kiszolgáló számára.",
"delete": "Törlés",
"delete_confirm_title": "Erőforrás törlése",
"delete_confirm_message": "Biztosan törölni szeretnéd a \"{name}\" elemet? Ez nem vonható vissza.",
"download": "Letöltés",
"name": "Név",
"size": "Méret",
@@ -3973,18 +4011,57 @@
"error_partial_copy": "A másolat egy része a helyén maradt.",
"create_folder_success": "Mappa létrehozva",
"create_folder_error": "Nem sikerült létrehozni a mappát",
"delete_success": "Sikeresen törölve",
"delete_error": "Nem sikerült törölni",
"trash": "Kuka",
"restore": "Visszaállítás",
"restored": "{count, plural, one {Elem visszaállítva} other {# elem visszaállítva}}",
"restore_error": "Nem sikerült visszaállítani",
"moved_to_trash": "{count, plural, one {Áthelyezve a kukába} other {# elem áthelyezve a kukába}}",
"delete_forever": "Végleges törlés",
"delete_forever_title": "{count, plural, one {Véglegesen törli?} other {Véglegesen törli a(z) # elemet?}}",
"delete_forever_message": "A(z) „{name}” törlődik. Ez nem vonható vissza.",
"delete_forever_message_count": "{count, plural, one {1 elem törlődik} other {# elem törlődik}}. Ez nem vonható vissza.",
"deleted_forever": "{count, plural, one {Véglegesen törölve} other {# elem véglegesen törölve}}",
"empty_trash": "Kuka ürítése",
"empty_trash_title": "Kiüríti a kukát?",
"empty_trash_message": "{count, plural, one {A kukában lévő elem} other {A kukában lévő mind a(z) # elem}} véglegesen törlődik. Ez nem vonható vissza.",
"trash_emptied": "Kuka kiürítve",
"empty_trash_error": "Nem sikerült kiüríteni a kukát",
"trash_empty_title": "A kuka üres",
"trash_empty_hint": "A törölt fájlok itt maradnak, amíg ki nem üríti a kukát.",
"deleted": "Törölve",
"deleted_from": "Törölve innen: {folder}",
"shortcuts": {
"list": "Lista",
"actions": "Műveletek",
"first_last": "Első vagy utolsó",
"extend": "Kijelölés bővítése",
"toggle": "Kijelölés vagy kijelölés megszüntetése",
"select_all": "Összes kijelölése",
"clear": "Kijelölés vagy keresés törlése",
"delete": "Törlés",
"search": "Keresés",
"move": "Következő vagy előző elem",
"move_grid": "Szomszédos elem a rácsban",
"open": "Megnyitás",
"up": "Egy mappával feljebb",
"rename": "Átnevezés",
"copy": "Másolás",
"cut": "Kivágás",
"paste": "Beillesztés"
},
"rename_success": "Sikeresen átnevezve",
"rename_error": "Nem sikerült átnevezni",
"download_error": "Nem sikerült letölteni",
"download_zip": "Letöltés ZIP-ként",
"zipping": "{name} előkészítése",
"download_canceled": "Letöltés megszakítva",
"zip_error": "Nem sikerült letölteni: {name}",
"not_available": "A fájltárolás nem elérhető ezen a szerveren",
"cancel": "Mégse",
"create": "Létrehozás",
"save": "Mentés",
"no_results": "Nincs a keresésnek megfelelő fájl",
"batch_delete_confirm_message": "Biztosan törölni szeretnéd {count, plural, one {az 1 elemet} other {a # elemet}}? Ez nem vonható vissza.",
"batch_delete_success": "{count, plural, one {1 elem törölve} other {# elem törölve}}",
"grid_view": "Rács nézet",
"list_view": "Lista nézet",
"details": "Részletek",
@@ -3996,6 +4073,9 @@
"paste": "Beillesztés",
"move_success": "{count, plural, one {1 elem áthelyezve} other {# elem áthelyezve}}",
"move_error": "Nem sikerült áthelyezni",
"move_to": "Áthelyezés ide…",
"move_to_title": "Áthelyezés ide",
"move_here": "Áthelyezés ide",
"paste_success": "Sikeresen beillesztve",
"paste_error": "Nem sikerült beilleszteni",
"new_text_file": "Új szöveges fájl",
@@ -4075,7 +4155,10 @@
"zoom_out": "Kicsinyítés",
"zoom_reset": "Igazítás a képernyőhöz",
"rotate": "Forgatás",
"close_preview": "Bezárás"
"close_preview": "Bezárás",
"previous_file": "Előző fájl",
"next_file": "Következő fájl",
"loading": "Betöltés…"
},
"smime": {
"your_certificates": "Tanúsítványaid",
+95 -12
View File
@@ -2915,7 +2915,7 @@
"add_cc": "Aggiungi a Cc",
"add_bcc": "Aggiungi a Ccn",
"select_hint": "Seleziona una persona per vederne i dettagli",
"write_email": "Scrivi email",
"write_email": "Invia email",
"add_to_contacts": "Aggiungi ai contatti",
"email": "Email",
"read_only_note": "Le voci provengono dal tuo server di posta e non possono essere modificate qui.",
@@ -2928,6 +2928,11 @@
}
},
"title": "Contatti",
"disabled_title": "I contatti sono stati disattivati dall'amministratore",
"disabled_description": "Rivolgiti all'amministratore se ti servono.",
"drag_count": "{count, plural, one {1 contatto} other {# contatti}}",
"select_contact": "Seleziona {name}",
"new": "Nuovo",
"search_placeholder": "Cerca contatti...",
"create_new": "Nuovo contatto",
"no_category": "Senza categoria",
@@ -2935,11 +2940,17 @@
"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",
"empty_state_title": "Nessun contatto",
"empty_state_subtitle": "Crea il tuo primo contatto o importa da un file vCard",
"empty_view_title": "Nessun contatto qui",
"empty_view_hint": "Aggiungi un contatto o importa un file vCard.",
"empty_search": "Nessun contatto corrisponde alla ricerca",
"empty_search_hint": "Prova con un altro termine di ricerca",
"empty_filtered": "Nessun contatto corrisponde ai filtri",
@@ -2960,6 +2971,7 @@
},
"address_books": {
"create": "Nuova rubrica",
"create_confirm": "Crea",
"created": "Rubrica creata",
"create_failed": "Impossibile creare la rubrica",
"title": "Le mie Rubriche",
@@ -2982,7 +2994,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",
@@ -2999,7 +3014,7 @@
"related_contacts": "Contatti correlati",
"crypto_keys": "Chiavi crittografiche",
"no_contact_selected": "Seleziona un contatto per vedere i dettagli",
"compose_email": "Scrivi email",
"compose_email": "Invia email",
"copy_email": "Copia email",
"copy_phone": "Copia telefono",
"copy_url": "Copia URL",
@@ -3066,7 +3081,7 @@
"create_title": "Nuovo contatto",
"edit_title": "Modifica contatto",
"section_address_book": "Rubrica",
"select_address_book": "Seleziona una rubrica...",
"select_address_book": "Rubrica",
"section_identity": "Nome e identità",
"contact_type": "Tipo di contatto",
"type_person": "Persona",
@@ -3153,6 +3168,13 @@
"add_phone": "Aggiungi telefono",
"save": "Salva",
"cancel": "Annulla",
"discard_title": "Eliminare le modifiche?",
"discard_message": "Le modifiche non sono state salvate.",
"discard": "Elimina",
"keep_editing": "Continua a modificare",
"remove_entry": "Rimuovi",
"close": "Chiudi",
"remove_category": "Rimuovi {name}",
"creating": "Creazione...",
"updating": "Aggiornamento...",
"name_required": "Inserisci un nome, un cognome o un'organizzazione",
@@ -3169,6 +3191,8 @@
},
"groups": {
"create": "Nuovo gruppo",
"delete": "Elimina gruppo",
"remove_member": "Rimuovi {name} dal gruppo",
"edit": "Modifica gruppo",
"empty": "Nessun gruppo",
"delete_confirm_title": "Elimina gruppo",
@@ -3181,7 +3205,7 @@
"search_members": "Cerca contatti da aggiungere...",
"no_members": "Nessun membro in questo gruppo",
"member_count": "{count, plural, =0 {Nessun membro} one {1 membro} other {# membri}}",
"send_email": "Send email to group",
"send_email": "Invia email",
"send_email_to": "To",
"send_email_cc": "Cc",
"send_email_bcc": "Bcc",
@@ -3219,6 +3243,7 @@
"deleted": "{count, plural, one {1 contatto eliminato} other {# contatti eliminati}}",
"add_to_group": "Aggiungi al gruppo",
"choose_group": "Scegli un gruppo",
"new_group": "Nuovo gruppo…",
"adding_contacts": "Aggiunta di {count, plural, one {1 contatto} other {# contatti}}",
"added_to_group": "Contatti aggiunti al gruppo",
"export": "Esporta",
@@ -3266,7 +3291,22 @@
"sort_first_name": "Nome",
"sort_last_name": "Cognome"
},
"open_categories": "Apri categorie"
"shortcuts": {
"list": "Elenco",
"actions": "Azioni",
"first_last": "Primo o ultimo",
"extend": "Estendi la selezione",
"toggle": "Seleziona o deseleziona",
"select_all": "Seleziona tutto",
"clear": "Cancella la selezione o la ricerca",
"delete": "Elimina",
"search": "Cerca",
"move": "Contatto successivo o precedente",
"open": "Apri il contatto",
"menu": "Apri il menu del contatto"
},
"open_categories": "Apri categorie",
"row_actions": "Azioni per {name}"
},
"calendar": {
"title": "Calendario",
@@ -3900,8 +3940,6 @@
"name_control_chars": "I nomi non possono contenere caratteri di controllo.",
"name_too_long": "Questo nome è troppo lungo per il server.",
"delete": "Elimina",
"delete_confirm_title": "Elimina risorsa",
"delete_confirm_message": "Sei sicuro di voler eliminare \"{name}\"? Questa azione non può essere annullata.",
"download": "Scarica",
"name": "Nome",
"size": "Dimensione",
@@ -3926,18 +3964,57 @@
"error_partial_copy": "Una parte della copia è rimasta al suo posto.",
"create_folder_success": "Cartella creata",
"create_folder_error": "Creazione della cartella non riuscita",
"delete_success": "Eliminato con successo",
"delete_error": "Eliminazione non riuscita",
"trash": "Cestino",
"restore": "Ripristina",
"restored": "{count, plural, one {Elemento ripristinato} other {# elementi ripristinati}}",
"restore_error": "Impossibile ripristinare",
"moved_to_trash": "{count, plural, one {Spostato nel cestino} other {# elementi spostati nel cestino}}",
"delete_forever": "Elimina definitivamente",
"delete_forever_title": "{count, plural, one {Eliminare definitivamente?} other {Eliminare definitivamente # elementi?}}",
"delete_forever_message": "«{name}» verrà eliminato. L'operazione non può essere annullata.",
"delete_forever_message_count": "{count, plural, one {Verrà eliminato 1 elemento} other {Verranno eliminati # elementi}}. L'operazione non può essere annullata.",
"deleted_forever": "{count, plural, one {Eliminato definitivamente} other {# elementi eliminati definitivamente}}",
"empty_trash": "Svuota il cestino",
"empty_trash_title": "Svuotare il cestino?",
"empty_trash_message": "{count, plural, one {L'elemento nel cestino verrà eliminato} other {Tutti i # elementi nel cestino verranno eliminati}} definitivamente. L'operazione non può essere annullata.",
"trash_emptied": "Cestino svuotato",
"empty_trash_error": "Impossibile svuotare il cestino",
"trash_empty_title": "Il cestino è vuoto",
"trash_empty_hint": "I file eliminati restano qui finché non svuoti il cestino.",
"deleted": "Eliminato",
"deleted_from": "Eliminato da {folder}",
"shortcuts": {
"list": "Elenco",
"actions": "Azioni",
"first_last": "Primo o ultimo",
"extend": "Estendi la selezione",
"toggle": "Seleziona o deseleziona",
"select_all": "Seleziona tutto",
"clear": "Cancella la selezione o la ricerca",
"delete": "Elimina",
"search": "Cerca",
"move": "Elemento successivo o precedente",
"move_grid": "Elemento accanto nella griglia",
"open": "Apri",
"up": "Su di una cartella",
"rename": "Rinomina",
"copy": "Copia",
"cut": "Taglia",
"paste": "Incolla"
},
"rename_success": "Rinominato con successo",
"rename_error": "Rinominazione non riuscita",
"download_error": "Download non riuscito",
"download_zip": "Scarica come ZIP",
"zipping": "Preparazione di {name}",
"download_canceled": "Download annullato",
"zip_error": "Impossibile scaricare {name}",
"not_available": "L'archiviazione file non è disponibile su questo server",
"cancel": "Annulla",
"create": "Crea",
"save": "Salva",
"no_results": "Nessun file corrisponde alla ricerca",
"batch_delete_confirm_message": "Sei sicuro di voler eliminare {count, plural, one {1 elemento} other {# elementi}}? Questa azione non può essere annullata.",
"batch_delete_success": "{count, plural, one {1 elemento eliminato} other {# elementi eliminati}}",
"grid_view": "Vista a griglia",
"list_view": "Vista a elenco",
"details": "Dettagli",
@@ -3949,6 +4026,9 @@
"paste": "Incolla",
"move_success": "{count, plural, one {1 elemento spostato} other {# elementi spostati}}",
"move_error": "Spostamento non riuscito",
"move_to": "Sposta in…",
"move_to_title": "Sposta in",
"move_here": "Sposta qui",
"paste_success": "Incollato con successo",
"paste_error": "Incollaggio non riuscito",
"new_text_file": "Nuovo file di testo",
@@ -4029,7 +4109,10 @@
"zoom_out": "Riduci",
"zoom_reset": "Adatta allo schermo",
"rotate": "Ruota",
"close_preview": "Chiudi"
"close_preview": "Chiudi",
"previous_file": "File precedente",
"next_file": "File successivo",
"loading": "Caricamento…"
},
"smime": {
"your_certificates": "I tuoi certificati",
+96 -13
View File
@@ -2915,7 +2915,7 @@
"add_cc": "CC に追加",
"add_bcc": "BCC に追加",
"select_hint": "詳細を表示するには相手を選択してください",
"write_email": "メールを作成",
"write_email": "メール送信",
"add_to_contacts": "連絡先に追加",
"email": "メール",
"read_only_note": "ディレクトリの項目はメールサーバーから取得され、ここでは編集できません。",
@@ -2928,6 +2928,11 @@
}
},
"title": "連絡先",
"disabled_title": "連絡先は管理者によって無効にされています",
"disabled_description": "必要な場合は管理者にお問い合わせください。",
"drag_count": "{count, plural, other {#件の連絡先}}",
"select_contact": "{name} を選択",
"new": "新規",
"search_placeholder": "連絡先を検索...",
"create_new": "新しい連絡先",
"no_category": "カテゴリなし",
@@ -2935,11 +2940,17 @@
"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": "連絡先がありません",
"empty_state_title": "連絡先がありません",
"empty_state_subtitle": "最初の連絡先を作成するか、vCardファイルからインポートしてください",
"empty_view_title": "ここに連絡先はありません",
"empty_view_hint": "連絡先を追加するか、vCard ファイルを読み込んでください。",
"empty_search": "検索に一致する連絡先がありません",
"empty_search_hint": "別の検索語をお試しください",
"empty_filtered": "フィルターに一致する連絡先がありません",
@@ -2960,6 +2971,7 @@
},
"address_books": {
"create": "新しいアドレス帳",
"create_confirm": "作成",
"created": "アドレス帳を作成しました",
"create_failed": "アドレス帳の作成に失敗しました",
"title": "マイアドレス帳",
@@ -2982,7 +2994,10 @@
"delete": "アドレス帳を削除",
"confirm_delete": "「{name}」を削除しますか?このアドレス帳のすべての連絡先が削除されます。",
"deleted": "アドレス帳を削除しました",
"delete_failed": "アドレス帳の削除に失敗しました"
"delete_failed": "アドレス帳の削除に失敗しました",
"move_to": "移動先…",
"move_title": "アドレス帳に移動",
"move_here": "ここに移動"
},
"detail": {
"emails": "メールアドレス",
@@ -2999,7 +3014,7 @@
"related_contacts": "関連する連絡先",
"crypto_keys": "暗号鍵",
"no_contact_selected": "連絡先を選択して詳細を表示",
"compose_email": "メールを作成",
"compose_email": "メール送信",
"copy_email": "メールをコピー",
"copy_phone": "電話番号をコピー",
"copy_url": "URLをコピー",
@@ -3065,8 +3080,8 @@
"form": {
"create_title": "新しい連絡先",
"edit_title": "連絡先を編集",
"section_address_book": "ディレクトリ",
"select_address_book": "ディレクトリを選択...",
"section_address_book": "アドレス帳",
"select_address_book": "アドレス帳",
"section_identity": "名前と識別情報",
"contact_type": "連絡先の種類",
"type_person": "個人",
@@ -3153,6 +3168,13 @@
"add_phone": "電話追加",
"save": "保存",
"cancel": "キャンセル",
"discard_title": "変更を破棄しますか?",
"discard_message": "変更は保存されていません。",
"discard": "破棄",
"keep_editing": "編集を続ける",
"remove_entry": "削除",
"close": "閉じる",
"remove_category": "{name} を削除",
"creating": "作成中...",
"updating": "更新中...",
"name_required": "名、姓、または組織を入力してください",
@@ -3169,6 +3191,8 @@
},
"groups": {
"create": "新しいグループ",
"delete": "グループを削除",
"remove_member": "{name} をグループから削除",
"edit": "グループを編集",
"empty": "グループがありません",
"delete_confirm_title": "グループを削除",
@@ -3181,7 +3205,7 @@
"search_members": "追加する連絡先を検索...",
"no_members": "このグループにメンバーがいません",
"member_count": "{count, plural, =0 {メンバーなし} other {#人のメンバー}}",
"send_email": "Send email to group",
"send_email": "メール送信",
"send_email_to": "To",
"send_email_cc": "Cc",
"send_email_bcc": "Bcc",
@@ -3219,6 +3243,7 @@
"deleted": "{count, plural, other {#件の連絡先を削除しました}}",
"add_to_group": "グループに追加",
"choose_group": "グループを選択",
"new_group": "新しいグループ…",
"adding_contacts": "{count, plural, other {#件の連絡先}}を追加",
"added_to_group": "連絡先をグループに追加しました",
"export": "エクスポート",
@@ -3266,7 +3291,22 @@
"sort_first_name": "名",
"sort_last_name": "姓"
},
"open_categories": "カテゴリを開く"
"shortcuts": {
"list": "リスト",
"actions": "操作",
"first_last": "最初または最後",
"extend": "選択範囲を広げる",
"toggle": "選択または選択解除",
"select_all": "すべて選択",
"clear": "選択または検索を解除",
"delete": "削除",
"search": "検索",
"move": "次または前の連絡先",
"open": "連絡先を開く",
"menu": "連絡先のメニューを開く"
},
"open_categories": "カテゴリを開く",
"row_actions": "{name} の操作"
},
"calendar": {
"title": "カレンダー",
@@ -3900,8 +3940,6 @@
"name_control_chars": "名前に制御文字は使用できません。",
"name_too_long": "この名前はサーバーには長すぎます。",
"delete": "削除",
"delete_confirm_title": "リソースを削除",
"delete_confirm_message": "\"{name}\"を削除してもよろしいですか?この操作は元に戻せません。",
"download": "ダウンロード",
"name": "名前",
"size": "サイズ",
@@ -3926,18 +3964,57 @@
"error_partial_copy": "コピーの一部は削除されずに残っています。",
"create_folder_success": "フォルダーを作成しました",
"create_folder_error": "フォルダーの作成に失敗しました",
"delete_success": "正常に削除しました",
"delete_error": "削除に失敗しました",
"trash": "ゴミ箱",
"restore": "復元",
"restored": "{count, plural, other {#件のアイテムを復元しました}}",
"restore_error": "復元できませんでした",
"moved_to_trash": "{count, plural, other {#件のアイテムをゴミ箱に移動しました}}",
"delete_forever": "完全に削除",
"delete_forever_title": "{count, plural, other {#件のアイテムを完全に削除しますか?}}",
"delete_forever_message": "「{name}」は削除されます。この操作は元に戻せません。",
"delete_forever_message_count": "{count, plural, other {#件のアイテムが削除されます}}。この操作は元に戻せません。",
"deleted_forever": "{count, plural, other {#件のアイテムを完全に削除しました}}",
"empty_trash": "ゴミ箱を空にする",
"empty_trash_title": "ゴミ箱を空にしますか?",
"empty_trash_message": "{count, plural, other {ゴミ箱内の#件のアイテムがすべて完全に削除されます}}。この操作は元に戻せません。",
"trash_emptied": "ゴミ箱を空にしました",
"empty_trash_error": "ゴミ箱を空にできませんでした",
"trash_empty_title": "ゴミ箱は空です",
"trash_empty_hint": "削除したファイルはゴミ箱を空にするまでここに残ります。",
"deleted": "削除日",
"deleted_from": "{folder} から削除",
"shortcuts": {
"list": "リスト",
"actions": "操作",
"first_last": "最初または最後",
"extend": "選択範囲を広げる",
"toggle": "選択または選択解除",
"select_all": "すべて選択",
"clear": "選択または検索を解除",
"delete": "削除",
"search": "検索",
"move": "次または前の項目",
"move_grid": "グリッドの隣の項目",
"open": "開く",
"up": "1つ上のフォルダー",
"rename": "名前を変更",
"copy": "コピー",
"cut": "切り取り",
"paste": "貼り付け"
},
"rename_success": "正常に名前を変更しました",
"rename_error": "名前の変更に失敗しました",
"download_error": "ダウンロードに失敗しました",
"download_zip": "ZIPでダウンロード",
"zipping": "{name} を準備しています",
"download_canceled": "ダウンロードをキャンセルしました",
"zip_error": "{name} をダウンロードできませんでした",
"not_available": "ファイルストレージはこのサーバーで利用できません",
"cancel": "キャンセル",
"create": "作成",
"save": "保存",
"no_results": "検索に一致するファイルがありません",
"batch_delete_confirm_message": "{count, plural, other {#件のアイテム}}を削除してもよろしいですか?この操作は元に戻せません。",
"batch_delete_success": "{count, plural, other {#件のアイテムを削除しました}}",
"grid_view": "グリッド表示",
"list_view": "リスト表示",
"details": "詳細",
@@ -3949,6 +4026,9 @@
"paste": "貼り付け",
"move_success": "{count, plural, other {#件のアイテムを移動しました}}",
"move_error": "移動に失敗しました",
"move_to": "移動先…",
"move_to_title": "移動先",
"move_here": "ここに移動",
"paste_success": "正常に貼り付けました",
"paste_error": "貼り付けに失敗しました",
"new_text_file": "新規テキストファイル",
@@ -4029,7 +4109,10 @@
"zoom_out": "縮小",
"zoom_reset": "画面に合わせる",
"rotate": "回転",
"close_preview": "閉じる"
"close_preview": "閉じる",
"previous_file": "前のファイル",
"next_file": "次のファイル",
"loading": "読み込み中…"
},
"smime": {
"your_certificates": "あなたの証明書",
+96 -13
View File
@@ -2915,7 +2915,7 @@
"add_cc": "참조에 추가",
"add_bcc": "숨은 참조에 추가",
"select_hint": "세부 정보를 보려면 사람을 선택하세요",
"write_email": "메일 쓰기",
"write_email": "이메일 보내기",
"add_to_contacts": "연락처에 추가",
"email": "이메일",
"read_only_note": "디렉터리 항목은 메일 서버에서 가져오며 여기에서 편집할 수 없습니다.",
@@ -2928,6 +2928,11 @@
}
},
"title": "연락처",
"disabled_title": "관리자가 연락처를 사용 중지했습니다",
"disabled_description": "필요하면 관리자에게 문의하세요.",
"drag_count": "{count, plural, other {연락처 #개}}",
"select_contact": "{name} 선택",
"new": "새로 만들기",
"search_placeholder": "연락처 검색...",
"create_new": "새 연락처",
"no_category": "카테고리 없음",
@@ -2935,11 +2940,17 @@
"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": "아직 연락처가 없어요",
"empty_state_title": "아직 연락처가 없어요",
"empty_state_subtitle": "새 연락처를 만들거나 vCard 파일에서 가져와 보세요",
"empty_view_title": "여기에 연락처가 없습니다",
"empty_view_hint": "연락처를 추가하거나 vCard 파일을 가져오세요.",
"empty_search": "검색된 연락처가 없어요",
"empty_search_hint": "다른 검색어로 다시 시도해 보세요",
"empty_filtered": "필터에 일치하는 연락처가 없어요",
@@ -2960,6 +2971,7 @@
},
"address_books": {
"create": "새 주소록",
"create_confirm": "만들기",
"created": "주소록이 생성되었습니다",
"create_failed": "주소록을 만들지 못했습니다",
"title": "내 주소록",
@@ -2982,7 +2994,10 @@
"delete": "주소록 삭제",
"confirm_delete": "\"{name}\"을(를) 삭제하시겠습니까? 이 주소록의 모든 연락처가 삭제됩니다.",
"deleted": "주소록이 삭제되었습니다",
"delete_failed": "주소록 삭제에 실패했습니다"
"delete_failed": "주소록 삭제에 실패했습니다",
"move_to": "이동…",
"move_title": "주소록으로 이동",
"move_here": "여기로 이동"
},
"detail": {
"emails": "이메일",
@@ -3008,7 +3023,7 @@
"cert_imported": "인증서가 S/MIME 저장소에 추가되었어요",
"cert_import_failed": "인증서를 가져오지 못했어요",
"no_contact_selected": "세부 정보를 보려면 연락처를 선택해 주세요",
"compose_email": "메일 쓰기",
"compose_email": "이메일 보내기",
"copy_email": "이메일 복사",
"copy_phone": "전화번호 복사",
"copy_url": "URL 복사",
@@ -3065,8 +3080,8 @@
"form": {
"create_title": "새 연락처",
"edit_title": "연락처 수정",
"section_address_book": "디렉터리",
"select_address_book": "디렉터리 선택...",
"section_address_book": "주소록",
"select_address_book": "주소록",
"section_identity": "이름 및 신원",
"contact_type": "연락처 유형",
"type_person": "개인",
@@ -3153,6 +3168,13 @@
"add_phone": "전화번호 추가",
"save": "저장",
"cancel": "취소",
"discard_title": "변경 사항을 삭제할까요?",
"discard_message": "변경 사항이 저장되지 않았습니다.",
"discard": "삭제",
"keep_editing": "계속 편집",
"remove_entry": "제거",
"close": "닫기",
"remove_category": "{name} 제거",
"creating": "만드는 중...",
"updating": "업데이트 중...",
"name_required": "이름, 성 또는 조직을 입력하세요",
@@ -3169,6 +3191,8 @@
},
"groups": {
"create": "새 그룹",
"delete": "그룹 삭제",
"remove_member": "그룹에서 {name} 제거",
"edit": "그룹 수정",
"empty": "아직 그룹이 없어요",
"delete_confirm_title": "그룹 삭제",
@@ -3181,7 +3205,7 @@
"search_members": "추가할 연락처 검색...",
"no_members": "이 그룹에는 멤버가 없어요",
"member_count": "멤버 {count}명",
"send_email": "Send email to group",
"send_email": "이메일 보내기",
"send_email_to": "To",
"send_email_cc": "Cc",
"send_email_bcc": "Bcc",
@@ -3219,6 +3243,7 @@
"deleted": "{count}개의 연락처가 삭제되었어요",
"add_to_group": "그룹에 추가",
"choose_group": "그룹 선택",
"new_group": "새 그룹…",
"adding_contacts": "{count}개의 연락처를 추가하는 중...",
"added_to_group": "연락처가 그룹에 추가되었어요",
"export": "내보내기",
@@ -3266,7 +3291,22 @@
"sort_first_name": "이름",
"sort_last_name": "성"
},
"open_categories": "카테고리 열기"
"shortcuts": {
"list": "목록",
"actions": "작업",
"first_last": "처음 또는 마지막",
"extend": "선택 확장",
"toggle": "선택 또는 선택 해제",
"select_all": "모두 선택",
"clear": "선택 또는 검색 지우기",
"delete": "삭제",
"search": "검색",
"move": "다음 또는 이전 연락처",
"open": "연락처 열기",
"menu": "연락처 메뉴 열기"
},
"open_categories": "카테고리 열기",
"row_actions": "{name} 작업"
},
"calendar": {
"title": "캘린더",
@@ -3900,8 +3940,6 @@
"name_control_chars": "이름에 제어 문자를 사용할 수 없습니다.",
"name_too_long": "이 이름은 서버에서 사용하기에 너무 깁니다.",
"delete": "삭제",
"delete_confirm_title": "항목 삭제",
"delete_confirm_message": "정말 \"{name}\"을(를) 삭제할까요? 이 작업은 되돌릴 수 없어요.",
"download": "다운로드",
"name": "이름",
"size": "크기",
@@ -3926,18 +3964,57 @@
"error_partial_copy": "복사본의 일부가 그대로 남아 있어요.",
"create_folder_success": "폴더가 만들어졌어요",
"create_folder_error": "폴더를 만들지 못했어요",
"delete_success": "성공적으로 삭제되었어요",
"delete_error": "삭제에 실패했어요",
"trash": "휴지통",
"restore": "복원",
"restored": "{count, plural, other {항목 #개를 복원했습니다}}",
"restore_error": "복원하지 못했습니다",
"moved_to_trash": "{count, plural, other {항목 #개를 휴지통으로 이동했습니다}}",
"delete_forever": "영구 삭제",
"delete_forever_title": "{count, plural, other {항목 #개를 영구 삭제할까요?}}",
"delete_forever_message": "\"{name}\" 항목이 삭제됩니다. 되돌릴 수 없습니다.",
"delete_forever_message_count": "{count, plural, other {항목 #개가 삭제됩니다}}. 되돌릴 수 없습니다.",
"deleted_forever": "{count, plural, other {항목 #개를 영구 삭제했습니다}}",
"empty_trash": "휴지통 비우기",
"empty_trash_title": "휴지통을 비울까요?",
"empty_trash_message": "{count, plural, other {휴지통의 항목 #개가 모두 영구 삭제됩니다}}. 되돌릴 수 없습니다.",
"trash_emptied": "휴지통을 비웠습니다",
"empty_trash_error": "휴지통을 비우지 못했습니다",
"trash_empty_title": "휴지통이 비어 있습니다",
"trash_empty_hint": "삭제한 파일은 휴지통을 비울 때까지 여기에 남아 있습니다.",
"deleted": "삭제한 날짜",
"deleted_from": "{folder}에서 삭제됨",
"shortcuts": {
"list": "목록",
"actions": "작업",
"first_last": "처음 또는 마지막",
"extend": "선택 확장",
"toggle": "선택 또는 선택 해제",
"select_all": "모두 선택",
"clear": "선택 또는 검색 지우기",
"delete": "삭제",
"search": "검색",
"move": "다음 또는 이전 항목",
"move_grid": "격자에서 옆 항목",
"open": "열기",
"up": "상위 폴더로",
"rename": "이름 바꾸기",
"copy": "복사",
"cut": "잘라내기",
"paste": "붙여넣기"
},
"rename_success": "이름이 변경되었어요",
"rename_error": "이름을 바꾸지 못했어요",
"download_error": "다운로드에 실패했어요",
"download_zip": "ZIP으로 다운로드",
"zipping": "{name} 준비 중",
"download_canceled": "다운로드를 취소했습니다",
"zip_error": "{name}을(를) 다운로드하지 못했습니다",
"not_available": "이 서버에서는 파일 저장소를 사용할 수 없어요",
"cancel": "취소",
"create": "만들기",
"save": "저장",
"no_results": "검색된 파일이 없어요",
"batch_delete_confirm_message": "선택한 {count}개의 항목을 정말 삭제할까요? 이 작업은 되돌릴 수 없어요.",
"batch_delete_success": "{count}개의 항목이 삭제되었어요",
"grid_view": "그리드 뷰",
"list_view": "리스트 뷰",
"details": "세부 정보",
@@ -3949,6 +4026,9 @@
"paste": "붙여넣기",
"move_success": "{count}개의 항목이 이동되었어요",
"move_error": "이동에 실패했어요",
"move_to": "이동…",
"move_to_title": "이동",
"move_here": "여기로 이동",
"paste_success": "붙여넣기 성공",
"paste_error": "붙여넣기에 실패했어요",
"new_text_file": "새 텍스트 파일",
@@ -4029,7 +4109,10 @@
"zoom_out": "축소",
"zoom_reset": "화면에 맞추기",
"rotate": "회전",
"close_preview": "닫기"
"close_preview": "닫기",
"previous_file": "이전 파일",
"next_file": "다음 파일",
"loading": "불러오는 중…"
},
"smime": {
"your_certificates": "내 인증서",
+96 -13
View File
@@ -2915,7 +2915,7 @@
"add_cc": "Pievienot laukam Kopija",
"add_bcc": "Pievienot laukam Slēptā kopija",
"select_hint": "Atlasiet personu, lai redzētu informāciju",
"write_email": "Rakstīt e-pastu",
"write_email": "Sūtīt e-pastu",
"add_to_contacts": "Pievienot kontaktiem",
"email": "E-pasts",
"read_only_note": "Kataloga ieraksti nāk no jūsu pasta servera, un tos šeit nevar rediģēt.",
@@ -2928,6 +2928,11 @@
}
},
"title": "Kontakti",
"disabled_title": "Administrators ir izslēdzis kontaktpersonas",
"disabled_description": "Ja tās ir nepieciešamas, sazinieties ar administratoru.",
"drag_count": "{count, plural, one {1 kontaktpersona} other {# kontaktpersonas}}",
"select_contact": "Atlasīt {name}",
"new": "Jauns",
"search_placeholder": "Meklēt kontaktus...",
"create_new": "Jauns kontakts",
"no_category": "Bez kategorijas",
@@ -2936,6 +2941,8 @@
"empty_state": "Kontaktu vēl nav",
"empty_state_title": "Kontaktu vēl nav",
"empty_state_subtitle": "Izveidojiet pirmo kontaktu vai importējiet no vCard faila",
"empty_view_title": "Šeit nav kontaktpersonu",
"empty_view_hint": "Pievienojiet kontaktpersonu vai importējiet vCard failu.",
"empty_search": "Nav kontaktu, kas atbilstu vaicājumam",
"empty_search_hint": "Mēģiniet citu meklēšanas vaicājumu",
"empty_filtered": "Nav kontaktu, kas atbilstu filtriem",
@@ -2956,6 +2963,7 @@
},
"address_books": {
"create": "Jauna adrešu grāmata",
"create_confirm": "Izveidot",
"created": "Adrešu grāmata izveidota",
"create_failed": "Neizdevās izveidot adrešu grāmatu",
"title": "Manas adrešu grāmatas",
@@ -2978,7 +2986,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",
@@ -3004,7 +3015,7 @@
"cert_imported": "Sertifikāts importēts S/MIME krātuvē",
"cert_import_failed": "Neizdevās importēt sertifikātu",
"no_contact_selected": "Atlasiet kontaktu, lai skatītu informāciju",
"compose_email": "Rakstīt e-pastu",
"compose_email": "Sūtīt e-pastu",
"copy_email": "Kopēt e-pastu",
"copy_phone": "Kopēt tālruni",
"copy_url": "Kopēt URL",
@@ -3061,8 +3072,8 @@
"form": {
"create_title": "Jauns kontakts",
"edit_title": "Rediģēt kontaktu",
"section_address_book": "Katalogs",
"select_address_book": "Izvēlieties katalogu...",
"section_address_book": "Adrešu grāmata",
"select_address_book": "Adrešu grāmata",
"section_identity": "Vārds un identitāte",
"contact_type": "Kontakta veids",
"type_person": "Persona",
@@ -3149,6 +3160,13 @@
"add_phone": "Pievienot tālruni",
"save": "Saglabāt",
"cancel": "Atcelt",
"discard_title": "Atmest izmaiņas?",
"discard_message": "Jūsu izmaiņas nav saglabātas.",
"discard": "Atmest",
"keep_editing": "Turpināt rediģēšanu",
"remove_entry": "Noņemt",
"close": "Aizvērt",
"remove_category": "Noņemt {name}",
"creating": "Izveido...",
"updating": "Atjaunina...",
"name_required": "Ievadiet vārdu, uzvārdu vai organizāciju",
@@ -3165,6 +3183,8 @@
},
"groups": {
"create": "Jauna grupa",
"delete": "Dzēst grupu",
"remove_member": "Noņemt {name} no grupas",
"edit": "Rediģēt grupu",
"empty": "Grupu vēl nav",
"delete_confirm_title": "Dzēst grupu",
@@ -3177,7 +3197,7 @@
"search_members": "Meklēt kontaktus, ko pievienot...",
"no_members": "Šajā grupā nav dalībnieku",
"member_count": "{count, plural, =0 {Nav dalībnieku} one {1 dalībnieks} other {# dalībnieki}}",
"send_email": "Send email to group",
"send_email": "Sūtīt e-pastu",
"send_email_to": "To",
"send_email_cc": "Cc",
"send_email_bcc": "Bcc",
@@ -3215,6 +3235,7 @@
"deleted": "Izdzēsts {count, plural, one {1 kontakts} other {# kontakti}}",
"add_to_group": "Pievienot grupai",
"choose_group": "Izvēlēties grupu",
"new_group": "Jauna grupa…",
"adding_contacts": "Pievieno {count, plural, one {1 kontaktu} other {# kontaktus}}",
"added_to_group": "Kontakti pievienoti grupai",
"export": "Eksportēt",
@@ -3232,6 +3253,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",
@@ -3266,7 +3291,22 @@
"sort_first_name": "Vārds",
"sort_last_name": "Uzvārds"
},
"open_categories": "Atvērt kategorijas"
"shortcuts": {
"list": "Saraksts",
"actions": "Darbības",
"first_last": "Pirmais vai pēdējais",
"extend": "Paplašināt atlasi",
"toggle": "Atlasīt vai noņemt atlasi",
"select_all": "Atlasīt visu",
"clear": "Notīrīt atlasi vai meklēšanu",
"delete": "Dzēst",
"search": "Meklēt",
"move": "Nākamā vai iepriekšējā kontaktpersona",
"open": "Atvērt kontaktpersonu",
"menu": "Atvērt kontaktpersonas izvēlni"
},
"open_categories": "Atvērt kategorijas",
"row_actions": "Darbības: {name}"
},
"calendar": {
"title": "Kalendārs",
@@ -3900,8 +3940,6 @@
"name_control_chars": "Nosaukumos nedrīkst būt vadības rakstzīmes.",
"name_too_long": "Šis nosaukums serverim ir pārāk garš.",
"delete": "Dzēst",
"delete_confirm_title": "Dzēst resursu",
"delete_confirm_message": "Vai tiešām vēlaties dzēst «{name}»? Šo darbību nevar atcelt.",
"download": "Lejupielādēt",
"name": "Nosaukums",
"size": "Izmērs",
@@ -3926,18 +3964,57 @@
"error_partial_copy": "Daļa kopijas palika savā vietā.",
"create_folder_success": "Mape izveidota",
"create_folder_error": "Neizdevās izveidot mapi",
"delete_success": "Sekmīgi izdzēsts",
"delete_error": "Neizdevās izdzēst",
"trash": "Miskaste",
"restore": "Atjaunot",
"restored": "{count, plural, one {Vienums atjaunots} other {Atjaunoti # vienumi}}",
"restore_error": "Neizdevās atjaunot",
"moved_to_trash": "{count, plural, one {Pārvietots uz miskasti} other {# vienumi pārvietoti uz miskasti}}",
"delete_forever": "Dzēst neatgriezeniski",
"delete_forever_title": "{count, plural, one {Dzēst neatgriezeniski?} other {Dzēst # vienumus neatgriezeniski?}}",
"delete_forever_message": "“{name}” tiks dzēsts. To nevar atsaukt.",
"delete_forever_message_count": "{count, plural, one {Tiks dzēsts 1 vienums} other {Tiks dzēsti # vienumi}}. To nevar atsaukt.",
"deleted_forever": "{count, plural, one {Dzēsts neatgriezeniski} other {# vienumi dzēsti neatgriezeniski}}",
"empty_trash": "Iztukšot miskasti",
"empty_trash_title": "Iztukšot miskasti?",
"empty_trash_message": "{count, plural, one {Miskastē esošais vienums tiks dzēsts} other {Visi # miskastē esošie vienumi tiks dzēsti}} neatgriezeniski. To nevar atsaukt.",
"trash_emptied": "Miskaste iztukšota",
"empty_trash_error": "Neizdevās iztukšot miskasti",
"trash_empty_title": "Miskaste ir tukša",
"trash_empty_hint": "Dzēstie faili paliek šeit, līdz iztukšojat miskasti.",
"deleted": "Dzēsts",
"deleted_from": "Dzēsts no {folder}",
"shortcuts": {
"list": "Saraksts",
"actions": "Darbības",
"first_last": "Pirmais vai pēdējais",
"extend": "Paplašināt atlasi",
"toggle": "Atlasīt vai noņemt atlasi",
"select_all": "Atlasīt visu",
"clear": "Notīrīt atlasi vai meklēšanu",
"delete": "Dzēst",
"search": "Meklēt",
"move": "Nākamais vai iepriekšējais vienums",
"move_grid": "Blakus esošais vienums režģī",
"open": "Atvērt",
"up": "Mapi uz augšu",
"rename": "Pārdēvēt",
"copy": "Kopēt",
"cut": "Izgriezt",
"paste": "Ielīmēt"
},
"rename_success": "Sekmīgi pārdēvēts",
"rename_error": "Neizdevās pārdēvēt",
"download_error": "Neizdevās lejupielādēt",
"download_zip": "Lejupielādēt kā ZIP",
"zipping": "Sagatavo {name}",
"download_canceled": "Lejupielāde atcelta",
"zip_error": "Neizdevās lejupielādēt {name}",
"not_available": "Failu krātuve šajā serverī nav pieejama",
"cancel": "Atcelt",
"create": "Izveidot",
"save": "Saglabāt",
"no_results": "Nav failu, kas atbilstu vaicājumam",
"batch_delete_confirm_message": "Vai tiešām vēlaties dzēst {count, plural, one {1 elementu} other {# elementus}}?",
"batch_delete_success": "Izdzēsts {count, plural, one {1 elements} other {# elementi}}",
"grid_view": "Režģis",
"list_view": "Saraksts",
"details": "Sīkāka informācija",
@@ -3949,6 +4026,9 @@
"paste": "Ielīmēt",
"move_success": "Pārvietots {count, plural, one {1 elements} other {# elementi}}",
"move_error": "Neizdevās pārvietot",
"move_to": "Pārvietot uz…",
"move_to_title": "Pārvietot uz",
"move_here": "Pārvietot šeit",
"paste_success": "Ielīmēts sekmīgi",
"paste_error": "Neizdevās ielīmēt",
"new_text_file": "Jauns teksta fails",
@@ -4029,7 +4109,10 @@
"zoom_out": "Tālināt",
"zoom_reset": "Ietilpināt ekrānā",
"rotate": "Pagriezt",
"close_preview": "Aizvērt"
"close_preview": "Aizvērt",
"previous_file": "Iepriekšējais fails",
"next_file": "Nākamais fails",
"loading": "Ielādē…"
},
"smime": {
"your_certificates": "Jūsu sertifikāti",
+95 -12
View File
@@ -2915,7 +2915,7 @@
"add_cc": "Cc-д нэмэх",
"add_bcc": "Bcc-д нэмэх",
"select_hint": "Дэлгэрэнгүйг харахын тулд хэн нэгнийг сонгоно уу",
"write_email": "Имэйл бичих",
"write_email": "Имэйл илгээх",
"add_to_contacts": "Харилцагчид нэмэх",
"email": "Имэйл",
"read_only_note": "Лавлахын бичлэгүүд таны шуудангийн серверээс ирдэг тул энд засах боломжгүй.",
@@ -2928,6 +2928,11 @@
}
},
"title": "Харилцах утас",
"disabled_title": "Таны администратор харилцагчдыг унтраасан",
"disabled_description": "Хэрэгтэй бол администратортаа хандана уу.",
"drag_count": "{count, plural, one {1 харилцагч} other {# харилцагч}}",
"select_contact": "{name}-г сонгох",
"new": "Шинэ",
"search_placeholder": "Харилцагч хайх...",
"create_new": "Шинэ холбоо барих",
"no_category": "Ангилалгүй",
@@ -2935,11 +2940,17 @@
"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": "Харилцагч байхгүй",
"empty_state_title": "Хараахан холбоо барих хүн алга",
"empty_state_subtitle": "Анхны харилцагчаа үүсгэх эсвэл vCard файлаас импортлох",
"empty_view_title": "Энд харилцагч алга",
"empty_view_hint": "Харилцагч нэмэх эсвэл vCard файл импортлоно уу.",
"empty_search": "Таны хайлтад тохирох харилцагч алга",
"empty_search_hint": "Өөр хайлтын үг хэрэглэж үзнэ үү",
"empty_filtered": "Таны шүүлтүүрт тохирох харилцагч алга",
@@ -2952,6 +2963,7 @@
"back_to_contacts": "Харилцагч руу буцах",
"back_to_email": "Имэйл рүү буцах",
"open_categories": "Нээлттэй ангилалууд",
"row_actions": "{name}-н үйлдлүүд",
"tabs": {
"all": "Бүгд",
"groups": "Бүлэг"
@@ -2961,6 +2973,7 @@
},
"address_books": {
"create": "Шинэ хаягийн дэвтэр",
"create_confirm": "Үүсгэх",
"created": "Хаягийн дэвтэр үүсгэсэн",
"create_failed": "Хаягийн дэвтэр үүсгэж чадсангүй",
"title": "Миний хаягийн дэвтэр",
@@ -2983,7 +2996,10 @@
"delete": "Хаягийн дэвтэр устгах",
"confirm_delete": "\"{name}\"-г устгах уу? Энэ хаягийн дэвтэр дэх бүх харилцагчийг устгах болно.",
"deleted": "Хаягийн дэвтэр устсан",
"delete_failed": "Хаягийн дэвтрийг устгаж чадсангүй"
"delete_failed": "Хаягийн дэвтрийг устгаж чадсангүй",
"move_to": "Зөөх…",
"move_title": "Хаягийн дэвтэр рүү зөөх",
"move_here": "Энд зөөх"
},
"detail": {
"emails": "Имэйл хаягууд",
@@ -3009,7 +3025,7 @@
"cert_imported": "Сертификат S/MIME дэлгүүрт импортлогдсон",
"cert_import_failed": "Сертификат импортлож чадсангүй",
"no_contact_selected": "Дэлгэрэнгүйг харахын тулд харилцагчаа сонгоно уу",
"compose_email": "Имэйл бичих",
"compose_email": "Имэйл илгээх",
"copy_email": "Имэйл хуулах",
"copy_phone": "Утасны дугаарыг хуулах",
"copy_url": "URL хуулах",
@@ -3066,8 +3082,8 @@
"form": {
"create_title": "Шинэ холбоо барих",
"edit_title": "Харилцагчийг засах",
"section_address_book": "Лавлах",
"select_address_book": "Лавлах сонгох...",
"section_address_book": "Хаягийн дэвтэр",
"select_address_book": "Хаягийн дэвтэр",
"section_identity": "Нэр & Хувийн мэдээлэл",
"contact_type": "Холбоо барих төрөл",
"type_person": "Хүн",
@@ -3154,6 +3170,13 @@
"add_phone": "Утас нэмэх",
"save": "Хадгалах",
"cancel": "Цуцлах",
"discard_title": "Өөрчлөлтийг хаях уу?",
"discard_message": "Таны өөрчлөлт хадгалагдаагүй байна.",
"discard": "Хаях",
"keep_editing": "Үргэлжлүүлэн засах",
"remove_entry": "Хасах",
"close": "Хаах",
"remove_category": "{name}-г хасах",
"creating": "Бүтээж байна...",
"updating": "Шинэчилж байна...",
"name_required": "Нэр, овог, байгууллагыг оруулна уу",
@@ -3170,6 +3193,8 @@
},
"groups": {
"create": "Шинэ групп",
"delete": "Бүлгийг устгах",
"remove_member": "{name}-г бүлгээс хасах",
"edit": "Засварлах групп",
"empty": "Одоохондоо бүлэг байхгүй",
"delete_confirm_title": "Бүлгийг устгах",
@@ -3182,7 +3207,7 @@
"search_members": "Нэмэх харилцагчдыг хайх...",
"no_members": "Энэ бүлэгт гишүүн алга",
"member_count": "{count, plural, =0 {Гишүүд байхгүй} one {1 гишүүн} other {# гишүүн}}",
"send_email": "Групп руу имэйл илгээх",
"send_email": "Имэйл илгээх",
"send_email_to": "руу",
"send_email_cc": "Cc",
"send_email_bcc": "Bcc",
@@ -3220,6 +3245,7 @@
"deleted": "{count, plural, one {1 контакт устгагдлаа} other {# контакт устгагдлаа}}",
"add_to_group": "Бүлэгт нэмэх",
"choose_group": "Бүлэг сонгох",
"new_group": "Шинэ бүлэг…",
"adding_contacts": "{count, plural, one {1 контакт} other {# контакт}} нэмэж байна",
"added_to_group": "Харилцагчдыг группт нэмсэн",
"export": "Экспорт хийх",
@@ -3266,6 +3292,20 @@
"sort_by": "Эрэмбэлэх",
"sort_first_name": "Нэр",
"sort_last_name": "Овог"
},
"shortcuts": {
"list": "Жагсаалт",
"actions": "Үйлдлүүд",
"first_last": "Эхний эсвэл сүүлийн",
"extend": "Сонголтыг өргөтгөх",
"toggle": "Сонгох эсвэл цуцлах",
"select_all": "Бүгдийг сонгох",
"clear": "Сонголт эсвэл хайлтыг цэвэрлэх",
"delete": "Устгах",
"search": "Хайх",
"move": "Дараах эсвэл өмнөх харилцагч",
"open": "Харилцагчийг нээх",
"menu": "Харилцагчийн цэсийг нээх"
}
},
"calendar": {
@@ -3946,8 +3986,6 @@
"name_control_chars": "Нэрэнд удирдах тэмдэгт агуулж болохгүй.",
"name_too_long": "Энэ нэр серверийн хувьд хэт урт байна.",
"delete": "Устгах",
"delete_confirm_title": "Нөөцийг устгах",
"delete_confirm_message": "Та \"{name}\"-г устгахдаа итгэлтэй байна уу? Үүнийг буцаах боломжгүй.",
"download": "Татаж авах",
"name": "Нэр",
"size": "Хэмжээ",
@@ -3973,18 +4011,57 @@
"error_partial_copy": "Хуулбарын нэг хэсэг хэвээр үлдлээ.",
"create_folder_success": "Хавтас үүсгэсэн",
"create_folder_error": "Хавтас үүсгэж чадсангүй",
"delete_success": "Амжилттай устгасан",
"delete_error": "Устгаж чадсангүй",
"trash": "Хогийн сав",
"restore": "Сэргээх",
"restored": "{count, plural, one {Зүйлийг сэргээлээ} other {# зүйлийг сэргээлээ}}",
"restore_error": "Сэргээж чадсангүй",
"moved_to_trash": "{count, plural, one {Хогийн сав руу зөөлөө} other {# зүйлийг хогийн сав руу зөөлөө}}",
"delete_forever": "Бүрмөсөн устгах",
"delete_forever_title": "{count, plural, one {Бүрмөсөн устгах уу?} other {# зүйлийг бүрмөсөн устгах уу?}}",
"delete_forever_message": "\"{name}\" устгагдана. Үүнийг буцаах боломжгүй.",
"delete_forever_message_count": "{count, plural, one {1 зүйл устгагдана} other {# зүйл устгагдана}}. Үүнийг буцаах боломжгүй.",
"deleted_forever": "{count, plural, one {Бүрмөсөн устгалаа} other {# зүйлийг бүрмөсөн устгалаа}}",
"empty_trash": "Хогийн савыг хоослох",
"empty_trash_title": "Хогийн савыг хоослох уу?",
"empty_trash_message": "{count, plural, one {Хогийн саван дахь зүйл} other {Хогийн саван дахь бүх # зүйл}} бүрмөсөн устгагдана. Үүнийг буцаах боломжгүй.",
"trash_emptied": "Хогийн савыг хоосоллоо",
"empty_trash_error": "Хогийн савыг хоосолж чадсангүй",
"trash_empty_title": "Хогийн сав хоосон байна",
"trash_empty_hint": "Устгасан файлууд хогийн савыг хоослох хүртэл энд хадгалагдана.",
"deleted": "Устгасан",
"deleted_from": "{folder}-с устгасан",
"shortcuts": {
"list": "Жагсаалт",
"actions": "Үйлдлүүд",
"first_last": "Эхний эсвэл сүүлийн",
"extend": "Сонголтыг өргөтгөх",
"toggle": "Сонгох эсвэл цуцлах",
"select_all": "Бүгдийг сонгох",
"clear": "Сонголт эсвэл хайлтыг цэвэрлэх",
"delete": "Устгах",
"search": "Хайх",
"move": "Дараах эсвэл өмнөх зүйл",
"move_grid": "Сүлжээн дэх хажуугийн зүйл",
"open": "Нээх",
"up": "Нэг хавтас дээш",
"rename": "Нэр солих",
"copy": "Хуулах",
"cut": "Хайчлах",
"paste": "Буулгах"
},
"rename_success": "Нэрээ амжилттай өөрчилсөн",
"rename_error": "Нэрээ өөрчилж чадсангүй",
"download_error": "Татаж авч чадсангүй",
"download_zip": "ZIP хэлбэрээр татах",
"zipping": "{name}-г бэлдэж байна",
"download_canceled": "Татаж авалтыг цуцаллаа",
"zip_error": "{name}-г татаж чадсангүй",
"not_available": "Энэ сервер дээр файл хадгалах боломжгүй",
"cancel": "Цуцлах",
"create": "Үүсгэх",
"save": "Хадгалах",
"no_results": "Таны хайлтад тохирох файл алга",
"batch_delete_confirm_message": "{count, plural, one {1 элемент} other {# элемент}} устгахыг баталгаажуулж байна уу? Энэ үйлдлийг буцаах боломжгүй.",
"batch_delete_success": "{count, plural, one {1 элемент устгагдлаа} other {# элемент устгагдлаа}}",
"grid_view": "Сүлжээ харах",
"list_view": "Жагсаалт харах",
"details": "Дэлгэрэнгүй мэдээлэл",
@@ -3996,6 +4073,9 @@
"paste": "Наах",
"move_success": "{count, plural, one {1 зүйл зөөгдлөө} other {# зүйл зөөгдлөө}}",
"move_error": "Хөдөлж чадсангүй",
"move_to": "Зөөх…",
"move_to_title": "Зөөх",
"move_here": "Энд зөөх",
"paste_success": "Амжилттай буулгалаа",
"paste_error": "Буулгаж чадсангүй",
"new_text_file": "Шинэ текст файл",
@@ -4075,7 +4155,10 @@
"zoom_out": "Жижигрүүлэх",
"zoom_reset": "Дэлгэцэнд тааруулах",
"rotate": "Эргүүлэх",
"close_preview": "Хаах"
"close_preview": "Хаах",
"previous_file": "Өмнөх файл",
"next_file": "Дараах файл",
"loading": "Ачаалж байна…"
},
"smime": {
"your_certificates": "Таны сертификатууд",
+94 -11
View File
@@ -2915,7 +2915,7 @@
"add_cc": "Legg til i Kopi",
"add_bcc": "Legg til i Blindkopi",
"select_hint": "Velg en person for å se detaljer",
"write_email": "Skriv e-post",
"write_email": "Send e-post",
"add_to_contacts": "Legg til i kontakter",
"email": "E-post",
"read_only_note": "Oppføringene kommer fra e-postserveren din og kan ikke redigeres her.",
@@ -2928,6 +2928,11 @@
}
},
"title": "Kontakter",
"disabled_title": "Kontakter er slått av av administratoren",
"disabled_description": "Spør administratoren hvis du trenger dem.",
"drag_count": "{count, plural, one {1 kontakt} other {# kontakter}}",
"select_contact": "Velg {name}",
"new": "Ny",
"search_placeholder": "Søk etter kontakter...",
"create_new": "Ny kontakt",
"no_category": "Ingen kategori",
@@ -2935,11 +2940,17 @@
"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å",
"empty_state_title": "Ingen kontakter ennå",
"empty_state_subtitle": "Opprett din første kontakt eller importer fra en vCard-fil",
"empty_view_title": "Ingen kontakter her",
"empty_view_hint": "Legg til en kontakt eller importer en vCard-fil.",
"empty_search": "Ingen kontakter passer med søket ditt",
"empty_search_hint": "Prøv et annet søkeord",
"empty_filtered": "Ingen kontakter passer med filtrene dine",
@@ -2952,6 +2963,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"
@@ -2961,6 +2973,7 @@
},
"address_books": {
"create": "Ny adressebok",
"create_confirm": "Opprett",
"created": "Adresseboken er opprettet",
"create_failed": "Kunne ikke opprette adresseboken",
"title": "Mine adressebøker",
@@ -2983,7 +2996,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",
@@ -3009,7 +3025,7 @@
"cert_imported": "Sertifikatet er importert til S/MIME-lageret",
"cert_import_failed": "Kunne ikke importere sertifikatet",
"no_contact_selected": "Velg en kontakt for å se detaljer",
"compose_email": "Skriv en e-post",
"compose_email": "Send e-post",
"copy_email": "Kopier e-postadresse",
"copy_phone": "Kopier telefonnummer",
"copy_url": "Kopier nettadresse",
@@ -3067,7 +3083,7 @@
"create_title": "Ny kontakt",
"edit_title": "Rediger kontakt",
"section_address_book": "Adressebok",
"select_address_book": "Velg en adressebok...",
"select_address_book": "Adressebok",
"section_identity": "Navn og identitet",
"contact_type": "Kontakttype",
"type_person": "Person",
@@ -3154,6 +3170,13 @@
"add_phone": "Legg til telefonnummer",
"save": "Lagre",
"cancel": "Avbryt",
"discard_title": "Vil du forkaste endringene?",
"discard_message": "Endringene dine er ikke lagret.",
"discard": "Forkast",
"keep_editing": "Fortsett å redigere",
"remove_entry": "Fjern",
"close": "Lukk",
"remove_category": "Fjern {name}",
"creating": "Oppretter...",
"updating": "Oppdaterer...",
"name_required": "Skriv inn et fornavn, etternavn eller organisasjon",
@@ -3170,6 +3193,8 @@
},
"groups": {
"create": "Ny gruppe",
"delete": "Slett gruppe",
"remove_member": "Fjern {name} fra gruppen",
"edit": "Rediger gruppe",
"empty": "Ingen grupper ennå",
"delete_confirm_title": "Slett gruppe",
@@ -3182,7 +3207,7 @@
"search_members": "Søk etter kontakter som skal legges til...",
"no_members": "Ingen medlemmer i denne gruppen",
"member_count": "{count, plural, =0 {Ingen medlemmer} one {1 medlem} other {# medlemmer}}",
"send_email": "Send e-post til gruppen",
"send_email": "Send e-post",
"send_email_to": "Til",
"send_email_cc": "Kopi",
"send_email_bcc": "Blindkopi",
@@ -3220,6 +3245,7 @@
"deleted": "{count, plural, one {1 kontakt slettet} other {# kontakter slettet}}",
"add_to_group": "Legg til i gruppe",
"choose_group": "Velg en gruppe",
"new_group": "Ny gruppe…",
"adding_contacts": "Legger til {count, plural, one {1 kontakt} other {# kontakter}}",
"added_to_group": "Kontaktene er lagt til i gruppen",
"export": "Eksporter",
@@ -3266,6 +3292,20 @@
"sort_by": "Sorter etter",
"sort_first_name": "Fornavn",
"sort_last_name": "Etternavn"
},
"shortcuts": {
"list": "Liste",
"actions": "Handlinger",
"first_last": "Første eller siste",
"extend": "Utvid markeringen",
"toggle": "Velg eller fjern valg",
"select_all": "Velg alle",
"clear": "Fjern markering eller søk",
"delete": "Slett",
"search": "Søk",
"move": "Neste eller forrige kontakt",
"open": "Åpne kontakten",
"menu": "Åpne kontaktens meny"
}
},
"calendar": {
@@ -3946,8 +3986,6 @@
"name_control_chars": "Navn kan ikke inneholde kontrolltegn.",
"name_too_long": "Dette navnet er for langt for serveren.",
"delete": "Slett",
"delete_confirm_title": "Slett fil eller mappe",
"delete_confirm_message": "Er du sikker på at du vil slette «{name}»? Dette kan ikke angres.",
"download": "Last ned",
"name": "Navn",
"size": "Størrelse",
@@ -3973,18 +4011,57 @@
"error_partial_copy": "En del av kopien ble liggende igjen.",
"create_folder_success": "Mappen er opprettet",
"create_folder_error": "Kunne ikke opprette mappe",
"delete_success": "Elementet er slettet",
"delete_error": "Kunne ikke slette elementet",
"trash": "Papirkurv",
"restore": "Gjenopprett",
"restored": "{count, plural, one {Elementet er gjenopprettet} other {# elementer er gjenopprettet}}",
"restore_error": "Kunne ikke gjenopprette",
"moved_to_trash": "{count, plural, one {Flyttet til papirkurven} other {# elementer flyttet til papirkurven}}",
"delete_forever": "Slett permanent",
"delete_forever_title": "{count, plural, one {Vil du slette permanent?} other {Vil du slette # elementer permanent?}}",
"delete_forever_message": "«{name}» blir slettet. Dette kan ikke angres.",
"delete_forever_message_count": "{count, plural, one {1 element blir slettet} other {# elementer blir slettet}}. Dette kan ikke angres.",
"deleted_forever": "{count, plural, one {Slettet permanent} other {# elementer slettet permanent}}",
"empty_trash": "Tøm papirkurven",
"empty_trash_title": "Vil du tømme papirkurven?",
"empty_trash_message": "{count, plural, one {Elementet i papirkurven} other {Alle # elementer i papirkurven}} blir slettet permanent. Dette kan ikke angres.",
"trash_emptied": "Papirkurven er tømt",
"empty_trash_error": "Kunne ikke tømme papirkurven",
"trash_empty_title": "Papirkurven er tom",
"trash_empty_hint": "Slettede filer blir liggende her til du tømmer papirkurven.",
"deleted": "Slettet",
"deleted_from": "Slettet fra {folder}",
"shortcuts": {
"list": "Liste",
"actions": "Handlinger",
"first_last": "Første eller siste",
"extend": "Utvid markeringen",
"toggle": "Velg eller fjern valg",
"select_all": "Velg alle",
"clear": "Fjern markering eller søk",
"delete": "Slett",
"search": "Søk",
"move": "Neste eller forrige element",
"move_grid": "Elementet ved siden av i rutenettet",
"open": "Åpne",
"up": "En mappe opp",
"rename": "Gi nytt navn",
"copy": "Kopier",
"cut": "Klipp ut",
"paste": "Lim inn"
},
"rename_success": "Navnet er endret",
"rename_error": "Kunne ikke endre navnet",
"download_error": "Kunne ikke laste ned",
"download_zip": "Last ned som ZIP",
"zipping": "Forbereder {name}",
"download_canceled": "Nedlasting avbrutt",
"zip_error": "Kunne ikke laste ned {name}",
"not_available": "Fillagring er ikke tilgjengelig på denne serveren",
"cancel": "Avbryt",
"create": "Opprett",
"save": "Lagre",
"no_results": "Ingen filer passer med søket ditt",
"batch_delete_confirm_message": "Er du sikker på at du vil slette {count, plural, one {1 element} other {# elementer}}? Dette kan ikke angres.",
"batch_delete_success": "{count, plural, one {1 element slettet} other {# elementer slettet}}",
"grid_view": "Rutenettvisning",
"list_view": "Listevisning",
"details": "Detaljer",
@@ -3996,6 +4073,9 @@
"paste": "Lim inn",
"move_success": "{count, plural, one {1 element flyttet} other {# elementer flyttet}}",
"move_error": "Kunne ikke flytte elementet",
"move_to": "Flytt til…",
"move_to_title": "Flytt til",
"move_here": "Flytt hit",
"paste_success": "Limt inn",
"paste_error": "Kunne ikke lime inn",
"new_text_file": "Ny tekstfil",
@@ -4075,7 +4155,10 @@
"zoom_out": "Zoom ut",
"zoom_reset": "Tilpass til skjermen",
"rotate": "Roter",
"close_preview": "Lukk"
"close_preview": "Lukk",
"previous_file": "Forrige fil",
"next_file": "Neste fil",
"loading": "Laster inn…"
},
"smime": {
"your_certificates": "Dine sertifikater",
+95 -12
View File
@@ -2915,7 +2915,7 @@
"add_cc": "Toevoegen aan Cc",
"add_bcc": "Toevoegen aan Bcc",
"select_hint": "Selecteer iemand om de details te zien",
"write_email": "E-mail schrijven",
"write_email": "E-mail verzenden",
"add_to_contacts": "Toevoegen aan contacten",
"email": "E-mail",
"read_only_note": "Deze gegevens komen van je mailserver en kunnen hier niet worden bewerkt.",
@@ -2928,6 +2928,11 @@
}
},
"title": "Contacten",
"disabled_title": "Contacten zijn uitgeschakeld door je beheerder",
"disabled_description": "Vraag je beheerder als je ze nodig hebt.",
"drag_count": "{count, plural, one {1 contact} other {# contacten}}",
"select_contact": "{name} selecteren",
"new": "Nieuw",
"search_placeholder": "Contacten zoeken...",
"create_new": "Nieuw contact",
"no_category": "Geen categorie",
@@ -2935,11 +2940,17 @@
"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",
"empty_state_title": "Geen contacten",
"empty_state_subtitle": "Maak uw eerste contact aan of importeer vanuit een vCard-bestand",
"empty_view_title": "Geen contacten hier",
"empty_view_hint": "Voeg een contact toe of importeer een vCard-bestand.",
"empty_search": "Geen contacten gevonden",
"empty_search_hint": "Probeer een andere zoekterm",
"empty_filtered": "Geen contacten voldoen aan uw filters",
@@ -2960,6 +2971,7 @@
},
"address_books": {
"create": "Nieuw adresboek",
"create_confirm": "Aanmaken",
"created": "Adresboek aangemaakt",
"create_failed": "Kan adresboek niet aanmaken",
"title": "Mijn Adresboeken",
@@ -2982,7 +2994,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",
@@ -2999,7 +3014,7 @@
"related_contacts": "Gerelateerde contacten",
"crypto_keys": "Cryptografische sleutels",
"no_contact_selected": "Selecteer een contact om details te bekijken",
"compose_email": "E-mail schrijven",
"compose_email": "E-mail verzenden",
"copy_email": "E-mail kopiëren",
"copy_phone": "Telefoonnummer kopiëren",
"copy_url": "URL kopiëren",
@@ -3066,7 +3081,7 @@
"create_title": "Nieuw contact",
"edit_title": "Contact bewerken",
"section_address_book": "Adresboek",
"select_address_book": "Selecteer een adresboek...",
"select_address_book": "Adresboek",
"section_identity": "Naam en identiteit",
"contact_type": "Contacttype",
"type_person": "Persoon",
@@ -3153,6 +3168,13 @@
"add_phone": "Telefoon toevoegen",
"save": "Opslaan",
"cancel": "Annuleren",
"discard_title": "Wijzigingen verwerpen?",
"discard_message": "Je wijzigingen zijn niet opgeslagen.",
"discard": "Verwerpen",
"keep_editing": "Doorgaan met bewerken",
"remove_entry": "Verwijderen",
"close": "Sluiten",
"remove_category": "{name} verwijderen",
"creating": "Aanmaken...",
"updating": "Bijwerken...",
"name_required": "Voer een voornaam, achternaam of organisatie in",
@@ -3169,6 +3191,8 @@
},
"groups": {
"create": "Nieuwe groep",
"delete": "Groep verwijderen",
"remove_member": "{name} uit de groep verwijderen",
"edit": "Groep bewerken",
"empty": "Geen groepen",
"delete_confirm_title": "Groep verwijderen",
@@ -3181,7 +3205,7 @@
"search_members": "Contacten zoeken om toe te voegen...",
"no_members": "Geen leden in deze groep",
"member_count": "{count, plural, =0 {Geen leden} one {1 lid} other {# leden}}",
"send_email": "Send email to group",
"send_email": "E-mail verzenden",
"send_email_to": "To",
"send_email_cc": "Cc",
"send_email_bcc": "Bcc",
@@ -3219,6 +3243,7 @@
"deleted": "{count, plural, one {1 contact verwijderd} other {# contacten verwijderd}}",
"add_to_group": "Aan groep toevoegen",
"choose_group": "Kies een groep",
"new_group": "Nieuwe groep…",
"adding_contacts": "{count, plural, one {1 contact} other {# contacten}} toevoegen",
"added_to_group": "Contacten aan groep toegevoegd",
"export": "Exporteren",
@@ -3266,7 +3291,22 @@
"sort_first_name": "Voornaam",
"sort_last_name": "Achternaam"
},
"open_categories": "Categorieën openen"
"shortcuts": {
"list": "Lijst",
"actions": "Acties",
"first_last": "Eerste of laatste",
"extend": "Selectie uitbreiden",
"toggle": "Selecteren of deselecteren",
"select_all": "Alles selecteren",
"clear": "Selectie of zoekopdracht wissen",
"delete": "Verwijderen",
"search": "Zoeken",
"move": "Volgend of vorig contact",
"open": "Contact openen",
"menu": "Contactmenu openen"
},
"open_categories": "Categorieën openen",
"row_actions": "Acties voor {name}"
},
"calendar": {
"title": "Agenda",
@@ -3900,8 +3940,6 @@
"name_control_chars": "Namen mogen geen besturingstekens bevatten.",
"name_too_long": "Deze naam is te lang voor de server.",
"delete": "Verwijderen",
"delete_confirm_title": "Bron verwijderen",
"delete_confirm_message": "Weet u zeker dat u \"{name}\" wilt verwijderen? Dit kan niet ongedaan worden gemaakt.",
"download": "Downloaden",
"name": "Naam",
"size": "Grootte",
@@ -3926,18 +3964,57 @@
"error_partial_copy": "Een deel van de kopie is blijven staan.",
"create_folder_success": "Map aangemaakt",
"create_folder_error": "Map aanmaken mislukt",
"delete_success": "Succesvol verwijderd",
"delete_error": "Verwijderen mislukt",
"trash": "Prullenbak",
"restore": "Herstellen",
"restored": "{count, plural, one {Item hersteld} other {# items hersteld}}",
"restore_error": "Kan niet herstellen",
"moved_to_trash": "{count, plural, one {Naar de prullenbak verplaatst} other {# items naar de prullenbak verplaatst}}",
"delete_forever": "Definitief verwijderen",
"delete_forever_title": "{count, plural, one {Definitief verwijderen?} other {# items definitief verwijderen?}}",
"delete_forever_message": "'{name}' wordt verwijderd. Dit kan niet ongedaan worden gemaakt.",
"delete_forever_message_count": "{count, plural, one {1 item wordt verwijderd} other {# items worden verwijderd}}. Dit kan niet ongedaan worden gemaakt.",
"deleted_forever": "{count, plural, one {Definitief verwijderd} other {# items definitief verwijderd}}",
"empty_trash": "Prullenbak legen",
"empty_trash_title": "Prullenbak legen?",
"empty_trash_message": "{count, plural, one {Het item in de prullenbak wordt} other {Alle # items in de prullenbak worden}} definitief verwijderd. Dit kan niet ongedaan worden gemaakt.",
"trash_emptied": "Prullenbak geleegd",
"empty_trash_error": "Kan de prullenbak niet legen",
"trash_empty_title": "De prullenbak is leeg",
"trash_empty_hint": "Verwijderde bestanden blijven hier tot je de prullenbak leegt.",
"deleted": "Verwijderd",
"deleted_from": "Verwijderd uit {folder}",
"shortcuts": {
"list": "Lijst",
"actions": "Acties",
"first_last": "Eerste of laatste",
"extend": "Selectie uitbreiden",
"toggle": "Selecteren of deselecteren",
"select_all": "Alles selecteren",
"clear": "Selectie of zoekopdracht wissen",
"delete": "Verwijderen",
"search": "Zoeken",
"move": "Volgend of vorig item",
"move_grid": "Item ernaast in het raster",
"open": "Openen",
"up": "Een map omhoog",
"rename": "Naam wijzigen",
"copy": "Kopiëren",
"cut": "Knippen",
"paste": "Plakken"
},
"rename_success": "Succesvol hernoemd",
"rename_error": "Hernoemen mislukt",
"download_error": "Downloaden mislukt",
"download_zip": "Downloaden als zip",
"zipping": "{name} voorbereiden",
"download_canceled": "Download geannuleerd",
"zip_error": "Kan {name} niet downloaden",
"not_available": "Bestandsopslag is niet beschikbaar op deze server",
"cancel": "Annuleren",
"create": "Aanmaken",
"save": "Opslaan",
"no_results": "Geen bestanden komen overeen met uw zoekopdracht",
"batch_delete_confirm_message": "Weet u zeker dat u {count, plural, one {1 item} other {# items}} wilt verwijderen? Dit kan niet ongedaan worden gemaakt.",
"batch_delete_success": "{count, plural, one {1 item verwijderd} other {# items verwijderd}}",
"grid_view": "Rasterweergave",
"list_view": "Lijstweergave",
"details": "Details",
@@ -3949,6 +4026,9 @@
"paste": "Plakken",
"move_success": "{count, plural, one {1 item verplaatst} other {# items verplaatst}}",
"move_error": "Verplaatsen mislukt",
"move_to": "Verplaatsen naar…",
"move_to_title": "Verplaatsen naar",
"move_here": "Hierheen verplaatsen",
"paste_success": "Succesvol geplakt",
"paste_error": "Plakken mislukt",
"new_text_file": "Nieuw tekstbestand",
@@ -4029,7 +4109,10 @@
"zoom_out": "Uitzoomen",
"zoom_reset": "Aanpassen aan scherm",
"rotate": "Draaien",
"close_preview": "Sluiten"
"close_preview": "Sluiten",
"previous_file": "Vorig bestand",
"next_file": "Volgend bestand",
"loading": "Laden…"
},
"smime": {
"your_certificates": "Uw certificaten",
+96 -13
View File
@@ -2915,7 +2915,7 @@
"add_cc": "Dodaj do DW",
"add_bcc": "Dodaj do UDW",
"select_hint": "Wybierz osobę, aby zobaczyć szczegóły",
"write_email": "Napisz e-mail",
"write_email": "Wyślij e-mail",
"add_to_contacts": "Dodaj do kontaktów",
"email": "E-mail",
"read_only_note": "Wpisy katalogu pochodzą z Twojego serwera poczty i nie można ich tu edytować.",
@@ -2928,6 +2928,11 @@
}
},
"title": "Kontakty",
"disabled_title": "Administrator wyłączył kontakty",
"disabled_description": "Jeśli ich potrzebujesz, skontaktuj się z administratorem.",
"drag_count": "{count, plural, one {1 kontakt} few {# kontakty} other {# kontaktów}}",
"select_contact": "Zaznacz {name}",
"new": "Nowy",
"search_placeholder": "Szukaj kontaktów...",
"create_new": "Nowy kontakt",
"no_category": "Brak kategorii",
@@ -2935,11 +2940,17 @@
"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",
"empty_state_title": "Brak kontaktów",
"empty_state_subtitle": "Utwórz pierwszy kontakt lub zaimportuj z pliku vCard",
"empty_view_title": "Brak kontaktów",
"empty_view_hint": "Dodaj kontakt lub zaimportuj plik vCard.",
"empty_search": "Żadne kontakty nie pasują do wyszukiwania",
"empty_search_hint": "Spróbuj użyć innego wyszukiwanego hasła",
"empty_filtered": "Żadne kontakty nie pasują do filtrów",
@@ -2960,6 +2971,7 @@
},
"address_books": {
"create": "Nowa książka adresowa",
"create_confirm": "Utwórz",
"created": "Utworzono książkę adresową",
"create_failed": "Nie udało się utworzyć książki adresowej",
"title": "Moje książki adresowe",
@@ -2982,7 +2994,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",
@@ -3008,7 +3023,7 @@
"cert_imported": "Certyfikat zaimportowano do magazynu S/MIME",
"cert_import_failed": "Nie udało się zaimportować certyfikatu",
"no_contact_selected": "Wybierz kontakt, aby wyświetlić szczegóły",
"compose_email": "Napisz e-mail",
"compose_email": "Wyślij e-mail",
"copy_email": "Kopiuj e-mail",
"copy_phone": "Kopiuj numer telefonu",
"copy_url": "Kopiuj URL",
@@ -3065,8 +3080,8 @@
"form": {
"create_title": "Nowy kontakt",
"edit_title": "Edytuj kontakt",
"section_address_book": "Katalog",
"select_address_book": "Wybierz katalog...",
"section_address_book": "Książka adresowa",
"select_address_book": "Książka adresowa",
"section_identity": "Imię i tożsamość",
"contact_type": "Typ kontaktu",
"type_person": "Osoba",
@@ -3153,6 +3168,13 @@
"add_phone": "Dodaj telefon",
"save": "Zapisz",
"cancel": "Anuluj",
"discard_title": "Odrzucić zmiany?",
"discard_message": "Twoje zmiany nie zostały zapisane.",
"discard": "Odrzuć",
"keep_editing": "Kontynuuj edycję",
"remove_entry": "Usuń",
"close": "Zamknij",
"remove_category": "Usuń {name}",
"creating": "Tworzenie...",
"updating": "Aktualizowanie...",
"name_required": "Podaj imię, nazwisko lub organizację",
@@ -3169,6 +3191,8 @@
},
"groups": {
"create": "Nowa grupa",
"delete": "Usuń grupę",
"remove_member": "Usuń {name} z grupy",
"edit": "Edytuj grupę",
"empty": "Brak grup",
"delete_confirm_title": "Usuń grupę",
@@ -3181,7 +3205,7 @@
"search_members": "Szukaj kontaktów do dodania...",
"no_members": "Brak członków w tej grupie",
"member_count": "{count, plural, =0 {Brak członków} one {1 członek} other {# członków}}",
"send_email": "Send email to group",
"send_email": "Wyślij e-mail",
"send_email_to": "To",
"send_email_cc": "Cc",
"send_email_bcc": "Bcc",
@@ -3219,6 +3243,7 @@
"deleted": "{count, plural, one {Usunięto 1 kontakt} other {Usunięto # kontaktów}}",
"add_to_group": "Dodaj do grupy",
"choose_group": "Wybierz grupę",
"new_group": "Nowa grupa…",
"adding_contacts": "Dodawanie {count, plural, one {1 kontaktu} other {# kontaktów}}",
"added_to_group": "Kontakty dodano do grupy",
"export": "Eksportuj",
@@ -3266,7 +3291,22 @@
"sort_first_name": "Imię",
"sort_last_name": "Nazwisko"
},
"open_categories": "Otwórz kategorie"
"shortcuts": {
"list": "Lista",
"actions": "Działania",
"first_last": "Pierwszy lub ostatni",
"extend": "Rozszerz zaznaczenie",
"toggle": "Zaznacz lub odznacz",
"select_all": "Zaznacz wszystko",
"clear": "Wyczyść zaznaczenie lub wyszukiwanie",
"delete": "Usuń",
"search": "Szukaj",
"move": "Następny lub poprzedni kontakt",
"open": "Otwórz kontakt",
"menu": "Otwórz menu kontaktu"
},
"open_categories": "Otwórz kategorie",
"row_actions": "Działania dla {name}"
},
"calendar": {
"title": "Kalendarz",
@@ -3900,8 +3940,6 @@
"name_control_chars": "Nazwy nie mogą zawierać znaków sterujących.",
"name_too_long": "Ta nazwa jest zbyt długa dla serwera.",
"delete": "Usuń",
"delete_confirm_title": "Usuń zasób",
"delete_confirm_message": "Czy na pewno chcesz usunąć \"{name}\"? Tej operacji nie można cofnąć.",
"download": "Pobierz",
"name": "Nazwa",
"size": "Rozmiar",
@@ -3926,18 +3964,57 @@
"error_partial_copy": "Część kopii pozostała na miejscu.",
"create_folder_success": "Folder utworzono",
"create_folder_error": "Nie udało się utworzyć folderu",
"delete_success": "Usunięto pomyślnie",
"delete_error": "Nie udało się usunąć",
"trash": "Kosz",
"restore": "Przywróć",
"restored": "{count, plural, one {Przywrócono element} few {Przywrócono # elementy} other {Przywrócono # elementów}}",
"restore_error": "Nie udało się przywrócić",
"moved_to_trash": "{count, plural, one {Przeniesiono do kosza} few {Przeniesiono # elementy do kosza} other {Przeniesiono # elementów do kosza}}",
"delete_forever": "Usuń trwale",
"delete_forever_title": "{count, plural, one {Usunąć trwale?} few {Usunąć trwale # elementy?} other {Usunąć trwale # elementów?}}",
"delete_forever_message": "„{name}” zostanie usunięty. Tej operacji nie można cofnąć.",
"delete_forever_message_count": "{count, plural, one {Zostanie usunięty 1 element} few {Zostaną usunięte # elementy} other {Zostanie usuniętych # elementów}}. Tej operacji nie można cofnąć.",
"deleted_forever": "{count, plural, one {Usunięto trwale} few {Usunięto trwale # elementy} other {Usunięto trwale # elementów}}",
"empty_trash": "Opróżnij kosz",
"empty_trash_title": "Opróżnić kosz?",
"empty_trash_message": "{count, plural, one {Element w koszu zostanie usunięty} few {Wszystkie # elementy w koszu zostaną usunięte} other {Wszystkie # elementów w koszu zostanie usuniętych}} trwale. Tej operacji nie można cofnąć.",
"trash_emptied": "Kosz opróżniony",
"empty_trash_error": "Nie udało się opróżnić kosza",
"trash_empty_title": "Kosz jest pusty",
"trash_empty_hint": "Usunięte pliki pozostają tutaj, dopóki nie opróżnisz kosza.",
"deleted": "Usunięto",
"deleted_from": "Usunięto z {folder}",
"shortcuts": {
"list": "Lista",
"actions": "Działania",
"first_last": "Pierwszy lub ostatni",
"extend": "Rozszerz zaznaczenie",
"toggle": "Zaznacz lub odznacz",
"select_all": "Zaznacz wszystko",
"clear": "Wyczyść zaznaczenie lub wyszukiwanie",
"delete": "Usuń",
"search": "Szukaj",
"move": "Następny lub poprzedni element",
"move_grid": "Sąsiedni element w siatce",
"open": "Otwórz",
"up": "Folder wyżej",
"rename": "Zmień nazwę",
"copy": "Kopiuj",
"cut": "Wytnij",
"paste": "Wklej"
},
"rename_success": "Nazwę zmieniono pomyślnie",
"rename_error": "Nie udało się zmienić nazwy",
"download_error": "Nie udało się pobrać",
"download_zip": "Pobierz jako ZIP",
"zipping": "Przygotowywanie {name}",
"download_canceled": "Anulowano pobieranie",
"zip_error": "Nie udało się pobrać {name}",
"not_available": "Przechowywanie plików nie jest dostępne na tym serwerze",
"cancel": "Anuluj",
"create": "Utwórz",
"save": "Zapisz",
"no_results": "Żadne pliki nie pasują do wyszukiwania",
"batch_delete_confirm_message": "Czy na pewno chcesz usunąć {count, plural, one {1 element} other {# elementów}}? Tej operacji nie można cofnąć.",
"batch_delete_success": "{count, plural, one {Usunięto 1 element} other {Usunięto # elementów}}",
"grid_view": "Widok siatki",
"list_view": "Widok listy",
"details": "Szczegóły",
@@ -3949,6 +4026,9 @@
"paste": "Wklej",
"move_success": "{count, plural, one {Przeniesiono 1 element} other {Przeniesiono # elementów}}",
"move_error": "Nie udało się przenieść",
"move_to": "Przenieś do…",
"move_to_title": "Przenieś do",
"move_here": "Przenieś tutaj",
"paste_success": "Wklejono pomyślnie",
"paste_error": "Nie udało się wkleić",
"new_text_file": "Nowy plik tekstowy",
@@ -4029,7 +4109,10 @@
"zoom_out": "Pomniejsz",
"zoom_reset": "Dopasuj do ekranu",
"rotate": "Obróć",
"close_preview": "Zamknij"
"close_preview": "Zamknij",
"previous_file": "Poprzedni plik",
"next_file": "Następny plik",
"loading": "Wczytywanie…"
},
"smime": {
"your_certificates": "Twoje certyfikaty",
+96 -13
View File
@@ -2915,7 +2915,7 @@
"add_cc": "Adicionar em Cc",
"add_bcc": "Adicionar em Cco",
"select_hint": "Selecione alguém para ver os detalhes",
"write_email": "Escrever e-mail",
"write_email": "Enviar e-mail",
"add_to_contacts": "Adicionar aos contatos",
"email": "E-mail",
"read_only_note": "As entradas do diretório vêm do seu servidor de e-mail e não podem ser editadas aqui.",
@@ -2928,6 +2928,11 @@
}
},
"title": "Contatos",
"disabled_title": "Os contatos foram desativados pelo administrador",
"disabled_description": "Peça ao administrador se precisar deles.",
"drag_count": "{count, plural, one {1 contato} other {# contatos}}",
"select_contact": "Selecionar {name}",
"new": "Novo",
"search_placeholder": "Pesquisar contatos...",
"create_new": "Novo contato",
"no_category": "Sem categoria",
@@ -2935,11 +2940,17 @@
"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",
"empty_state_title": "Sem contatos",
"empty_state_subtitle": "Crie seu primeiro contato ou importe de um arquivo vCard",
"empty_view_title": "Nenhum contato aqui",
"empty_view_hint": "Adicione um contato ou importe um arquivo vCard.",
"empty_search": "Nenhum contato encontrado",
"empty_search_hint": "Tente outro termo de pesquisa",
"empty_filtered": "Nenhum contato corresponde aos seus filtros",
@@ -2960,6 +2971,7 @@
},
"address_books": {
"create": "Novo livro de endereços",
"create_confirm": "Criar",
"created": "Livro de endereços criado",
"create_failed": "Falha ao criar o livro de endereços",
"title": "Meus Catálogos de Endereços",
@@ -2982,7 +2994,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",
@@ -2999,7 +3014,7 @@
"related_contacts": "Contatos relacionados",
"crypto_keys": "Chaves criptográficas",
"no_contact_selected": "Selecione um contato para ver os detalhes",
"compose_email": "Escrever e-mail",
"compose_email": "Enviar e-mail",
"copy_email": "Copiar e-mail",
"copy_phone": "Copiar telefone",
"copy_url": "Copiar URL",
@@ -3065,8 +3080,8 @@
"form": {
"create_title": "Novo contato",
"edit_title": "Editar contato",
"section_address_book": "Diretório",
"select_address_book": "Selecionar um diretório...",
"section_address_book": "Catálogo de endereços",
"select_address_book": "Catálogo de endereços",
"section_identity": "Nome e identidade",
"contact_type": "Tipo de contacto",
"type_person": "Pessoa",
@@ -3153,6 +3168,13 @@
"add_phone": "Adicionar telefone",
"save": "Salvar",
"cancel": "Cancelar",
"discard_title": "Descartar alterações?",
"discard_message": "Suas alterações não foram salvas.",
"discard": "Descartar",
"keep_editing": "Continuar editando",
"remove_entry": "Remover",
"close": "Fechar",
"remove_category": "Remover {name}",
"creating": "Criando...",
"updating": "Atualizando...",
"name_required": "Introduza um nome próprio, apelido ou organização",
@@ -3169,6 +3191,8 @@
},
"groups": {
"create": "Novo grupo",
"delete": "Excluir grupo",
"remove_member": "Remover {name} do grupo",
"edit": "Editar grupo",
"empty": "Nenhum grupo",
"delete_confirm_title": "Excluir grupo",
@@ -3181,7 +3205,7 @@
"search_members": "Pesquisar contatos para adicionar...",
"no_members": "Nenhum membro neste grupo",
"member_count": "{count, plural, =0 {Nenhum membro} one {1 membro} other {# membros}}",
"send_email": "Enviar e-mail para o grupo",
"send_email": "Enviar e-mail",
"send_email_to": "Para",
"send_email_cc": "Cc",
"send_email_bcc": "Cco",
@@ -3219,6 +3243,7 @@
"deleted": "{count, plural, one {1 contato excluído} other {# contatos excluídos}}",
"add_to_group": "Adicionar ao grupo",
"choose_group": "Escolher um grupo",
"new_group": "Novo grupo…",
"adding_contacts": "Adicionando {count, plural, one {1 contato} other {# contatos}}",
"added_to_group": "Contatos adicionados ao grupo",
"export": "Exportar",
@@ -3266,7 +3291,22 @@
"sort_first_name": "Nome",
"sort_last_name": "Sobrenome"
},
"open_categories": "Abrir categorias"
"shortcuts": {
"list": "Lista",
"actions": "Ações",
"first_last": "Primeiro ou último",
"extend": "Ampliar a seleção",
"toggle": "Selecionar ou desmarcar",
"select_all": "Selecionar tudo",
"clear": "Limpar a seleção ou a pesquisa",
"delete": "Excluir",
"search": "Pesquisar",
"move": "Contato seguinte ou anterior",
"open": "Abrir o contato",
"menu": "Abrir o menu do contato"
},
"open_categories": "Abrir categorias",
"row_actions": "Ações para {name}"
},
"calendar": {
"title": "Calendário",
@@ -3900,8 +3940,6 @@
"name_control_chars": "Os nomes não podem conter caracteres de controle.",
"name_too_long": "Este nome é longo demais para o servidor.",
"delete": "Eliminar",
"delete_confirm_title": "Eliminar recurso",
"delete_confirm_message": "Tem a certeza de que deseja eliminar \"{name}\"? Esta ação não pode ser desfeita.",
"download": "Transferir",
"name": "Nome",
"size": "Tamanho",
@@ -3926,18 +3964,57 @@
"error_partial_copy": "Parte da cópia ficou no lugar.",
"create_folder_success": "Pasta criada",
"create_folder_error": "Falha ao criar a pasta",
"delete_success": "Eliminado com sucesso",
"delete_error": "Falha ao eliminar",
"trash": "Lixeira",
"restore": "Restaurar",
"restored": "{count, plural, one {Item restaurado} other {# itens restaurados}}",
"restore_error": "Não foi possível restaurar",
"moved_to_trash": "{count, plural, one {Movido para a lixeira} other {# itens movidos para a lixeira}}",
"delete_forever": "Excluir permanentemente",
"delete_forever_title": "{count, plural, one {Excluir permanentemente?} other {Excluir # itens permanentemente?}}",
"delete_forever_message": "\"{name}\" será excluído. Não é possível desfazer.",
"delete_forever_message_count": "{count, plural, one {1 item será excluído} other {# itens serão excluídos}}. Não é possível desfazer.",
"deleted_forever": "{count, plural, one {Excluído permanentemente} other {# itens excluídos permanentemente}}",
"empty_trash": "Esvaziar lixeira",
"empty_trash_title": "Esvaziar a lixeira?",
"empty_trash_message": "{count, plural, one {O item da lixeira será excluído} other {Todos os # itens da lixeira serão excluídos}} permanentemente. Não é possível desfazer.",
"trash_emptied": "Lixeira esvaziada",
"empty_trash_error": "Não foi possível esvaziar a lixeira",
"trash_empty_title": "A lixeira está vazia",
"trash_empty_hint": "Os arquivos excluídos ficam aqui até você esvaziar a lixeira.",
"deleted": "Excluído",
"deleted_from": "Excluído de {folder}",
"shortcuts": {
"list": "Lista",
"actions": "Ações",
"first_last": "Primeiro ou último",
"extend": "Ampliar a seleção",
"toggle": "Selecionar ou desmarcar",
"select_all": "Selecionar tudo",
"clear": "Limpar a seleção ou a pesquisa",
"delete": "Excluir",
"search": "Pesquisar",
"move": "Item seguinte ou anterior",
"move_grid": "Item ao lado na grade",
"open": "Abrir",
"up": "Subir uma pasta",
"rename": "Renomear",
"copy": "Copiar",
"cut": "Recortar",
"paste": "Colar"
},
"rename_success": "Renomeado com sucesso",
"rename_error": "Falha ao renomear",
"download_error": "Falha ao transferir",
"download_zip": "Baixar como ZIP",
"zipping": "Preparando {name}",
"download_canceled": "Download cancelado",
"zip_error": "Não foi possível baixar {name}",
"not_available": "O armazenamento de arquivos não está disponível neste servidor",
"cancel": "Cancelar",
"create": "Criar",
"save": "Guardar",
"no_results": "Nenhum arquivo corresponde à sua pesquisa",
"batch_delete_confirm_message": "Tem a certeza de que deseja eliminar {count, plural, one {1 item} other {# itens}}? Esta ação não pode ser desfeita.",
"batch_delete_success": "{count, plural, one {1 item eliminado} other {# itens eliminados}}",
"grid_view": "Vista em grelha",
"list_view": "Vista em lista",
"details": "Detalhes",
@@ -3949,6 +4026,9 @@
"paste": "Colar",
"move_success": "{count, plural, one {1 item movido} other {# itens movidos}}",
"move_error": "Falha ao mover",
"move_to": "Mover para…",
"move_to_title": "Mover para",
"move_here": "Mover para cá",
"paste_success": "Colado com sucesso",
"paste_error": "Falha ao colar",
"new_text_file": "Novo arquivo de texto",
@@ -4029,7 +4109,10 @@
"zoom_out": "Diminuir zoom",
"zoom_reset": "Ajustar ao ecrã",
"rotate": "Rodar",
"close_preview": "Fechar"
"close_preview": "Fechar",
"previous_file": "Arquivo anterior",
"next_file": "Próximo arquivo",
"loading": "Carregando…"
},
"smime": {
"your_certificates": "Seus certificados",
+95 -12
View File
@@ -2915,7 +2915,7 @@
"add_cc": "Adaugă la Cc",
"add_bcc": "Adaugă la Bcc",
"select_hint": "Selectează o persoană pentru detalii",
"write_email": "Scrie e-mail",
"write_email": "Trimite e-mail",
"add_to_contacts": "Adaugă la contacte",
"email": "E-mail",
"read_only_note": "Intrările din director provin de la serverul de e-mail și nu pot fi editate aici.",
@@ -2928,6 +2928,11 @@
}
},
"title": "Contacte",
"disabled_title": "Contactele au fost dezactivate de administrator",
"disabled_description": "Contactați administratorul dacă aveți nevoie de ele.",
"drag_count": "{count, plural, one {1 contact} other {# contacte}}",
"select_contact": "Selectați {name}",
"new": "Nou",
"search_placeholder": "Căutare contacte...",
"create_new": "Contact nou",
"no_category": "Fără categorie",
@@ -2935,11 +2940,17 @@
"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",
"empty_state_title": "Nu există încă contacte",
"empty_state_subtitle": "Creați primul dvs. contact sau importați-l dintr-un fișier „vCard”",
"empty_view_title": "Nu există contacte aici",
"empty_view_hint": "Adăugați un contact sau importați un fișier vCard.",
"empty_search": "Nu există contacte care să corespundă căutării dvs.",
"empty_search_hint": "Încercați un alt termen de căutare",
"empty_filtered": "Nu există contacte care să corespundă filtrelor dvs.",
@@ -2952,6 +2963,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"
@@ -2961,6 +2973,7 @@
},
"address_books": {
"create": "Agendă nouă",
"create_confirm": "Creați",
"created": "Agendă creată",
"create_failed": "Nu s-a putut crea agenda",
"title": "Agendele mele de adrese",
@@ -2983,7 +2996,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",
@@ -3009,7 +3025,7 @@
"cert_imported": "Certificat importat în magazinul S/MIME",
"cert_import_failed": "Importul certificatului a eșuat",
"no_contact_selected": "Selectați un contact pentru a vizualiza detaliile",
"compose_email": "Redactați un e-mail",
"compose_email": "Trimite e-mail",
"copy_email": "Copiați e-mailul",
"copy_phone": "Copiați numărul de telefon",
"copy_url": "Copiați URL",
@@ -3066,8 +3082,8 @@
"form": {
"create_title": "Contact nou",
"edit_title": "Editează contactul",
"section_address_book": "Director",
"select_address_book": "Selectați un director...",
"section_address_book": "Agendă",
"select_address_book": "Agendă",
"section_identity": "Nume și identitate",
"contact_type": "Tip de contact",
"type_person": "Persoană",
@@ -3154,6 +3170,13 @@
"add_phone": "Adăugați numărul de telefon",
"save": "Salvare",
"cancel": "Anulează",
"discard_title": "Renunțați la modificări?",
"discard_message": "Modificările dvs. nu au fost salvate.",
"discard": "Renunțați",
"keep_editing": "Continuați editarea",
"remove_entry": "Eliminați",
"close": "Închideți",
"remove_category": "Eliminați {name}",
"creating": "Se creează...",
"updating": "Se actualizează...",
"name_required": "Introduceți un prenume, un nume sau o organizație",
@@ -3170,6 +3193,8 @@
},
"groups": {
"create": "Grup nou",
"delete": "Ștergeți grupul",
"remove_member": "Eliminați {name} din grup",
"edit": "Editare grup",
"empty": "Nu există încă grupuri",
"delete_confirm_title": "Șterge grupul",
@@ -3182,7 +3207,7 @@
"search_members": "Căutați contacte pentru a le adăuga...",
"no_members": "Nu există membri în acest grup",
"member_count": "{count, plural, =0 {Nu există membri} one {1 membru} few {# membri} other {# de membri}}",
"send_email": "Trimite e-mail către grup",
"send_email": "Trimite e-mail",
"send_email_to": "Către",
"send_email_cc": "Cc",
"send_email_bcc": "Bcc",
@@ -3220,6 +3245,7 @@
"deleted": "{count, plural, one {1 contact șters} few {# contacte șterse} other {# de contacte șterse}}",
"add_to_group": "Adaugă la grup",
"choose_group": "Alegeți un grup",
"new_group": "Grup nou…",
"adding_contacts": "Adăugare {count, plural, one {1 contact} few {# contacte} other {# de contacte}}",
"added_to_group": "Contacte adăugate la grup",
"export": "Export",
@@ -3266,6 +3292,20 @@
"sort_by": "Sortare după",
"sort_first_name": "Prenume",
"sort_last_name": "Nume de familie"
},
"shortcuts": {
"list": "Listă",
"actions": "Acțiuni",
"first_last": "Primul sau ultimul",
"extend": "Extindeți selecția",
"toggle": "Selectați sau deselectați",
"select_all": "Selectați tot",
"clear": "Ștergeți selecția sau căutarea",
"delete": "Ștergeți",
"search": "Căutați",
"move": "Contactul următor sau anterior",
"open": "Deschideți contactul",
"menu": "Deschideți meniul contactului"
}
},
"calendar": {
@@ -3946,8 +3986,6 @@
"name_control_chars": "Numele nu pot conține caractere de control.",
"name_too_long": "Acest nume este prea lung pentru server.",
"delete": "Șterge",
"delete_confirm_title": "Ștergeți resursa",
"delete_confirm_message": "Sunteți sigur că doriți să ștergeți „{name}”? Această acțiune nu poate fi anulată.",
"download": "Descărcați",
"name": "Nume",
"size": "Dimensiune",
@@ -3973,18 +4011,57 @@
"error_partial_copy": "O parte din copie a rămas pe loc.",
"create_folder_success": "Folder creat",
"create_folder_error": "Nu s-a putut crea folderul",
"delete_success": "Șters cu succes",
"delete_error": "Nu s-a putut șterge",
"trash": "Coș de gunoi",
"restore": "Restaurați",
"restored": "{count, plural, one {Element restaurat} other {# elemente restaurate}}",
"restore_error": "Nu s-a putut restaura",
"moved_to_trash": "{count, plural, one {Mutat în coșul de gunoi} other {# elemente mutate în coșul de gunoi}}",
"delete_forever": "Ștergeți definitiv",
"delete_forever_title": "{count, plural, one {Ștergeți definitiv?} other {Ștergeți definitiv # elemente?}}",
"delete_forever_message": "„{name}” va fi șters. Acțiunea nu poate fi anulată.",
"delete_forever_message_count": "{count, plural, one {Va fi șters 1 element} other {Vor fi șterse # elemente}}. Acțiunea nu poate fi anulată.",
"deleted_forever": "{count, plural, one {Șters definitiv} other {# elemente șterse definitiv}}",
"empty_trash": "Goliți coșul de gunoi",
"empty_trash_title": "Goliți coșul de gunoi?",
"empty_trash_message": "{count, plural, one {Elementul din coșul de gunoi va fi șters} other {Toate cele # elemente din coșul de gunoi vor fi șterse}} definitiv. Acțiunea nu poate fi anulată.",
"trash_emptied": "Coșul de gunoi a fost golit",
"empty_trash_error": "Nu s-a putut goli coșul de gunoi",
"trash_empty_title": "Coșul de gunoi este gol",
"trash_empty_hint": "Fișierele șterse rămân aici până când goliți coșul de gunoi.",
"deleted": "Șters",
"deleted_from": "Șters din {folder}",
"shortcuts": {
"list": "Listă",
"actions": "Acțiuni",
"first_last": "Primul sau ultimul",
"extend": "Extindeți selecția",
"toggle": "Selectați sau deselectați",
"select_all": "Selectați tot",
"clear": "Ștergeți selecția sau căutarea",
"delete": "Ștergeți",
"search": "Căutați",
"move": "Elementul următor sau anterior",
"move_grid": "Elementul alăturat din grilă",
"open": "Deschideți",
"up": "Un dosar mai sus",
"rename": "Redenumiți",
"copy": "Copiați",
"cut": "Decupați",
"paste": "Lipiți"
},
"rename_success": "Redenumit cu succes",
"rename_error": "Nu s-a putut redenumi",
"download_error": "Descărcarea a eșuat",
"download_zip": "Descărcați ca ZIP",
"zipping": "Se pregătește {name}",
"download_canceled": "Descărcare anulată",
"zip_error": "Nu s-a putut descărca {name}",
"not_available": "Stocarea fișierelor nu este disponibilă pe acest server",
"cancel": "Anulează",
"create": "Creați",
"save": "Salvare",
"no_results": "Nu există fișiere care să corespundă căutării dvs.",
"batch_delete_confirm_message": "Sunteți sigur că doriți să ștergeți {count, plural, one {1 element} few {# elemente} other {# de elemente}}? Această acțiune nu poate fi anulată.",
"batch_delete_success": "{count, plural, one {1 element șters} few {# elemente șterse} other {# de elemente șterse}}",
"grid_view": "Vizualizare tabelară",
"list_view": "Vizualizare listă",
"details": "Detalii",
@@ -3996,6 +4073,9 @@
"paste": "Lipiți",
"move_success": "{count, plural, one {1 element mutat} few {# elemente mutate} other {# de elemente mutate}}",
"move_error": "Nu s-a putut muta",
"move_to": "Mutați în…",
"move_to_title": "Mutați în",
"move_here": "Mutați aici",
"paste_success": "S-a lipit cu succes",
"paste_error": "Nu s-a putut insera",
"new_text_file": "Fișier text nou",
@@ -4075,7 +4155,10 @@
"zoom_out": "Micșorează",
"zoom_reset": "Potrivește pe ecran",
"rotate": "Rotește",
"close_preview": "Închide"
"close_preview": "Închide",
"previous_file": "Fișierul anterior",
"next_file": "Fișierul următor",
"loading": "Se încarcă…"
},
"smime": {
"your_certificates": "Certificatele dvs.",
+96 -13
View File
@@ -2915,7 +2915,7 @@
"add_cc": "Добавить в «Копия»",
"add_bcc": "Добавить в «Скрытая копия»",
"select_hint": "Выберите человека, чтобы увидеть подробности",
"write_email": "Написать письмо",
"write_email": "Отправить письмо",
"add_to_contacts": "Добавить в контакты",
"email": "Эл. почта",
"read_only_note": "Записи справочника приходят с вашего почтового сервера, здесь их нельзя изменить.",
@@ -2928,6 +2928,11 @@
}
},
"title": "Контакты",
"disabled_title": "Администратор отключил контакты",
"disabled_description": "Обратитесь к администратору, если они вам нужны.",
"drag_count": "{count, plural, one {# контакт} few {# контакта} other {# контактов}}",
"select_contact": "Выбрать {name}",
"new": "Создать",
"search_placeholder": "Поиск контактов...",
"create_new": "Новый контакт",
"no_category": "Без категории",
@@ -2935,11 +2940,17 @@
"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": "Контактов пока нет",
"empty_state_title": "Контактов пока нет",
"empty_state_subtitle": "Создайте первый контакт или импортируйте из файла vCard",
"empty_view_title": "Здесь нет контактов",
"empty_view_hint": "Добавьте контакт или импортируйте файл vCard.",
"empty_search": "Нет контактов, соответствующих вашему запросу",
"empty_search_hint": "Попробуйте другой поисковый запрос",
"empty_filtered": "Нет контактов, соответствующих фильтрам",
@@ -2960,6 +2971,7 @@
},
"address_books": {
"create": "Новая адресная книга",
"create_confirm": "Создать",
"created": "Адресная книга создана",
"create_failed": "Не удалось создать адресную книгу",
"title": "Мои адресные книги",
@@ -2982,7 +2994,10 @@
"delete": "Удалить адресную книгу",
"confirm_delete": "Удалить «{name}»? Все контакты в этой адресной книге будут удалены.",
"deleted": "Адресная книга удалена",
"delete_failed": "Не удалось удалить адресную книгу"
"delete_failed": "Не удалось удалить адресную книгу",
"move_to": "Переместить в…",
"move_title": "Переместить в адресную книгу",
"move_here": "Переместить сюда"
},
"detail": {
"emails": "Адреса электронной почты",
@@ -3008,7 +3023,7 @@
"cert_imported": "Сертификат импортирован в хранилище S/MIME",
"cert_import_failed": "Не удалось импортировать сертификат",
"no_contact_selected": "Выберите контакт для просмотра деталей",
"compose_email": "Написать письмо",
"compose_email": "Отправить письмо",
"copy_email": "Скопировать email",
"copy_phone": "Скопировать номер телефона",
"copy_url": "Скопировать URL",
@@ -3065,8 +3080,8 @@
"form": {
"create_title": "Новый контакт",
"edit_title": "Редактировать контакт",
"section_address_book": "Каталог",
"select_address_book": "Выберите каталог...",
"section_address_book": "Адресная книга",
"select_address_book": "Адресная книга",
"section_identity": "Имя и личность",
"contact_type": "Тип контакта",
"type_person": "Человек",
@@ -3153,6 +3168,13 @@
"add_phone": "Добавить телефон",
"save": "Сохранить",
"cancel": "Отмена",
"discard_title": "Отменить изменения?",
"discard_message": "Ваши изменения не сохранены.",
"discard": "Отменить",
"keep_editing": "Продолжить редактирование",
"remove_entry": "Убрать",
"close": "Закрыть",
"remove_category": "Убрать {name}",
"creating": "Создание...",
"updating": "Обновление...",
"name_required": "Укажите имя, фамилию или организацию",
@@ -3169,6 +3191,8 @@
},
"groups": {
"create": "Новая группа",
"delete": "Удалить группу",
"remove_member": "Убрать {name} из группы",
"edit": "Редактировать группу",
"empty": "Групп пока нет",
"delete_confirm_title": "Удалить группу",
@@ -3181,7 +3205,7 @@
"search_members": "Поиск контактов для добавления...",
"no_members": "В этой группе нет участников",
"member_count": "{count, plural, =0 {Нет участников} one {1 участник} other {# участников}}",
"send_email": "Send email to group",
"send_email": "Отправить письмо",
"send_email_to": "To",
"send_email_cc": "Cc",
"send_email_bcc": "Bcc",
@@ -3219,6 +3243,7 @@
"deleted": "{count, plural, one {1 контакт удалён} other {# контактов удалено}}",
"add_to_group": "Добавить в группу",
"choose_group": "Выбрать группу",
"new_group": "Новая группа…",
"adding_contacts": "Добавляем {count, plural, one {1 контакт} other {# контактов}}",
"added_to_group": "Контакты добавлены в группу",
"export": "Экспорт",
@@ -3266,7 +3291,22 @@
"sort_first_name": "Имя",
"sort_last_name": "Фамилия"
},
"open_categories": "Открыть категории"
"shortcuts": {
"list": "Список",
"actions": "Действия",
"first_last": "Первый или последний",
"extend": "Расширить выделение",
"toggle": "Выбрать или снять выбор",
"select_all": "Выбрать все",
"clear": "Сбросить выделение или поиск",
"delete": "Удалить",
"search": "Поиск",
"move": "Следующий или предыдущий контакт",
"open": "Открыть контакт",
"menu": "Открыть меню контакта"
},
"open_categories": "Открыть категории",
"row_actions": "Действия для {name}"
},
"calendar": {
"title": "Календарь",
@@ -3900,8 +3940,6 @@
"name_control_chars": "Имена не могут содержать управляющие символы.",
"name_too_long": "Это имя слишком длинное для сервера.",
"delete": "Удалить",
"delete_confirm_title": "Удалить ресурс",
"delete_confirm_message": "Вы уверены, что хотите удалить «{name}»? Это действие нельзя отменить.",
"download": "Скачать",
"name": "Имя",
"size": "Размер",
@@ -3926,18 +3964,57 @@
"error_partial_copy": "Часть копии осталась на месте.",
"create_folder_success": "Папка создана",
"create_folder_error": "Не удалось создать папку",
"delete_success": "Успешно удалено",
"delete_error": "Не удалось удалить",
"trash": "Корзина",
"restore": "Восстановить",
"restored": "{count, plural, one {Элемент восстановлен} few {Восстановлено # элемента} other {Восстановлено # элементов}}",
"restore_error": "Не удалось восстановить",
"moved_to_trash": "{count, plural, one {Перемещено в корзину} few {# элемента перемещено в корзину} other {# элементов перемещено в корзину}}",
"delete_forever": "Удалить навсегда",
"delete_forever_title": "{count, plural, one {Удалить навсегда?} few {Удалить # элемента навсегда?} other {Удалить # элементов навсегда?}}",
"delete_forever_message": "«{name}» будет удалён. Это действие нельзя отменить.",
"delete_forever_message_count": "{count, plural, one {Будет удалён 1 элемент} few {Будут удалены # элемента} other {Будут удалены # элементов}}. Это действие нельзя отменить.",
"deleted_forever": "{count, plural, one {Удалено навсегда} few {# элемента удалено навсегда} other {# элементов удалено навсегда}}",
"empty_trash": "Очистить корзину",
"empty_trash_title": "Очистить корзину?",
"empty_trash_message": "{count, plural, one {Элемент в корзине будет удалён} few {Все # элемента в корзине будут удалены} other {Все # элементов в корзине будут удалены}} навсегда. Это действие нельзя отменить.",
"trash_emptied": "Корзина очищена",
"empty_trash_error": "Не удалось очистить корзину",
"trash_empty_title": "Корзина пуста",
"trash_empty_hint": "Удалённые файлы хранятся здесь, пока вы не очистите корзину.",
"deleted": "Удалено",
"deleted_from": "Удалено из {folder}",
"shortcuts": {
"list": "Список",
"actions": "Действия",
"first_last": "Первый или последний",
"extend": "Расширить выделение",
"toggle": "Выбрать или снять выбор",
"select_all": "Выбрать все",
"clear": "Сбросить выделение или поиск",
"delete": "Удалить",
"search": "Поиск",
"move": "Следующий или предыдущий элемент",
"move_grid": "Соседний элемент в сетке",
"open": "Открыть",
"up": "На папку выше",
"rename": "Переименовать",
"copy": "Копировать",
"cut": "Вырезать",
"paste": "Вставить"
},
"rename_success": "Успешно переименовано",
"rename_error": "Не удалось переименовать",
"download_error": "Не удалось скачать",
"download_zip": "Скачать как ZIP",
"zipping": "Подготовка {name}",
"download_canceled": "Скачивание отменено",
"zip_error": "Не удалось скачать {name}",
"not_available": "Хранилище файлов недоступно на этом сервере",
"cancel": "Отмена",
"create": "Создать",
"save": "Сохранить",
"no_results": "Нет файлов, соответствующих вашему запросу",
"batch_delete_confirm_message": "Вы уверены, что хотите удалить {count, plural, one {1 элемент} other {# элементов}}? Это действие нельзя отменить.",
"batch_delete_success": "{count, plural, one {1 элемент удалён} other {# элементов удалено}}",
"grid_view": "Сеткой",
"list_view": "Списком",
"details": "Подробности",
@@ -3949,6 +4026,9 @@
"paste": "Вставить",
"move_success": "{count, plural, one {1 элемент перемещён} other {# элементов перемещено}}",
"move_error": "Не удалось переместить",
"move_to": "Переместить в…",
"move_to_title": "Переместить в",
"move_here": "Переместить сюда",
"paste_success": "Вставлено успешно",
"paste_error": "Не удалось вставить",
"new_text_file": "Новый текстовый файл",
@@ -4029,7 +4109,10 @@
"zoom_out": "Уменьшить",
"zoom_reset": "По размеру экрана",
"rotate": "Повернуть",
"close_preview": "Закрыть"
"close_preview": "Закрыть",
"previous_file": "Предыдущий файл",
"next_file": "Следующий файл",
"loading": "Загрузка…"
},
"smime": {
"your_certificates": "Ваши сертификаты",
+94 -11
View File
@@ -2915,7 +2915,7 @@
"add_cc": "Pridať do Kópia",
"add_bcc": "Pridať do Skrytá kópia",
"select_hint": "Vyberte osobu a zobrazte podrobnosti",
"write_email": "Napísať e-mail",
"write_email": "Poslať e-mail",
"add_to_contacts": "Pridať do kontaktov",
"email": "E-mail",
"read_only_note": "Položky adresára pochádzajú z vášho poštového servera a nedajú sa tu upraviť.",
@@ -2928,6 +2928,11 @@
}
},
"title": "Kontakty",
"disabled_title": "Kontakty vypol správca",
"disabled_description": "Ak ich potrebujete, obráťte sa na správcu.",
"drag_count": "{count, plural, one {1 kontakt} few {# kontakty} other {# kontaktov}}",
"select_contact": "Vybrať {name}",
"new": "Nový",
"search_placeholder": "Hľadať kontakty...",
"create_new": "Nový kontakt",
"no_category": "Bez kategórie",
@@ -2935,11 +2940,17 @@
"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",
"empty_state_title": "Žiadne kontakty",
"empty_state_subtitle": "Vytvorte prvý kontakt alebo importujte zo súboru vCard",
"empty_view_title": "Nie sú tu žiadne kontakty",
"empty_view_hint": "Pridajte kontakt alebo importujte súbor vCard.",
"empty_search": "Hľadaniu nezodpovedajú žiadne kontakty",
"empty_search_hint": "Skúste iný hľadaný výraz",
"empty_filtered": "Žiadne kontakty nezodpovedajú filtrom",
@@ -2952,6 +2963,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"
@@ -2961,6 +2973,7 @@
},
"address_books": {
"create": "Nový adresár",
"create_confirm": "Vytvoriť",
"created": "Adresár vytvorený",
"create_failed": "Nepodarilo sa vytvoriť adresár",
"title": "Moje adresáre",
@@ -2983,7 +2996,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",
@@ -3009,7 +3025,7 @@
"cert_imported": "Certifikát bol importovaný do úložiska S/MIME",
"cert_import_failed": "Import certifikátu zlyhal",
"no_contact_selected": "Vyberte kontakt na zobrazenie podrobností",
"compose_email": "Napísať e-mail",
"compose_email": "Poslať e-mail",
"copy_email": "Kopírovať e-mail",
"copy_phone": "Kopírovať telefón",
"copy_url": "Kopírovať URL",
@@ -3067,7 +3083,7 @@
"create_title": "Nový kontakt",
"edit_title": "Upraviť kontakt",
"section_address_book": "Adresár",
"select_address_book": "Vyberte adresár...",
"select_address_book": "Adresár",
"section_identity": "Meno a identita",
"contact_type": "Typ kontaktu",
"type_person": "Osoba",
@@ -3154,6 +3170,13 @@
"add_phone": "Pridať telefón",
"save": "Uložiť",
"cancel": "Zrušiť",
"discard_title": "Zahodiť zmeny?",
"discard_message": "Vaše zmeny neboli uložené.",
"discard": "Zahodiť",
"keep_editing": "Pokračovať v úpravách",
"remove_entry": "Odstrániť",
"close": "Zavrieť",
"remove_category": "Odstrániť {name}",
"creating": "Vytváranie...",
"updating": "Aktualizovanie...",
"name_required": "Zadajte meno, priezvisko alebo organizáciu",
@@ -3170,6 +3193,8 @@
},
"groups": {
"create": "Nová skupina",
"delete": "Odstrániť skupinu",
"remove_member": "Odstrániť {name} zo skupiny",
"edit": "Upraviť skupinu",
"empty": "Žiadne skupiny",
"delete_confirm_title": "Odstrániť skupinu",
@@ -3182,7 +3207,7 @@
"search_members": "Hľadať kontakty na pridanie...",
"no_members": "Táto skupina nemá žiadnych členov",
"member_count": "{count, plural, =0 {Žiadni členovia} one {1 člen} other {# členov}}",
"send_email": "Odoslať e-mail skupine",
"send_email": "Poslať e-mail",
"send_email_to": "Komu",
"send_email_cc": "Kópia",
"send_email_bcc": "Skrytá kópia",
@@ -3220,6 +3245,7 @@
"deleted": "{count, plural, one {Odstránený 1 kontakt} other {Odstránených # kontaktov}}",
"add_to_group": "Pridať do skupiny",
"choose_group": "Vyberte skupinu",
"new_group": "Nová skupina…",
"adding_contacts": "Pridávanie {count, plural, one {1 kontaktu} other {# kontaktov}}",
"added_to_group": "Kontakty boli pridané do skupiny",
"export": "Exportovať",
@@ -3266,6 +3292,20 @@
"sort_by": "Zoradiť podľa",
"sort_first_name": "Meno",
"sort_last_name": "Priezvisko"
},
"shortcuts": {
"list": "Zoznam",
"actions": "Akcie",
"first_last": "Prvý alebo posledný",
"extend": "Rozšíriť výber",
"toggle": "Vybrať alebo zrušiť výber",
"select_all": "Vybrať všetko",
"clear": "Zrušiť výber alebo vyhľadávanie",
"delete": "Odstrániť",
"search": "Hľadať",
"move": "Ďalší alebo predchádzajúci kontakt",
"open": "Otvoriť kontakt",
"menu": "Otvoriť ponuku kontaktu"
}
},
"calendar": {
@@ -3946,8 +3986,6 @@
"name_control_chars": "Názvy nesmú obsahovať riadiace znaky.",
"name_too_long": "Tento názov je pre server príliš dlhý.",
"delete": "Odstrániť",
"delete_confirm_title": "Odstrániť položku",
"delete_confirm_message": "Naozaj chcete odstrániť \"{name}\"? Túto akciu nie je možné vrátiť.",
"download": "Stiahnuť",
"name": "Názov",
"size": "Veľkosť",
@@ -3973,18 +4011,57 @@
"error_partial_copy": "Časť kópie zostala na mieste.",
"create_folder_success": "Priečinok bol vytvorený",
"create_folder_error": "Vytvorenie priečinka zlyhalo",
"delete_success": "Úspešne odstránené",
"delete_error": "Odstránenie zlyhalo",
"trash": "Kôš",
"restore": "Obnoviť",
"restored": "{count, plural, one {Položka obnovená} few {Obnovené # položky} other {Obnovených # položiek}}",
"restore_error": "Obnovenie sa nepodarilo",
"moved_to_trash": "{count, plural, one {Presunuté do koša} few {# položky presunuté do koša} other {# položiek presunutých do koša}}",
"delete_forever": "Natrvalo odstrániť",
"delete_forever_title": "{count, plural, one {Natrvalo odstrániť?} few {Natrvalo odstrániť # položky?} other {Natrvalo odstrániť # položiek?}}",
"delete_forever_message": "„{name}“ sa odstráni. Túto akciu nie je možné vrátiť.",
"delete_forever_message_count": "{count, plural, one {Odstráni sa 1 položka} few {Odstránia sa # položky} other {Odstráni sa # položiek}}. Túto akciu nie je možné vrátiť.",
"deleted_forever": "{count, plural, one {Natrvalo odstránené} few {Natrvalo odstránené # položky} other {Natrvalo odstránených # položiek}}",
"empty_trash": "Vyprázdniť kôš",
"empty_trash_title": "Vyprázdniť kôš?",
"empty_trash_message": "{count, plural, one {Položka v koši sa natrvalo odstráni} few {Všetky # položky v koši sa natrvalo odstránia} other {Všetkých # položiek v koši sa natrvalo odstráni}}. Túto akciu nie je možné vrátiť.",
"trash_emptied": "Kôš bol vyprázdnený",
"empty_trash_error": "Kôš sa nepodarilo vyprázdniť",
"trash_empty_title": "Kôš je prázdny",
"trash_empty_hint": "Odstránené súbory tu zostanú, kým kôš nevyprázdnite.",
"deleted": "Odstránené",
"deleted_from": "Odstránené z {folder}",
"shortcuts": {
"list": "Zoznam",
"actions": "Akcie",
"first_last": "Prvý alebo posledný",
"extend": "Rozšíriť výber",
"toggle": "Vybrať alebo zrušiť výber",
"select_all": "Vybrať všetko",
"clear": "Zrušiť výber alebo vyhľadávanie",
"delete": "Odstrániť",
"search": "Hľadať",
"move": "Ďalšia alebo predchádzajúca položka",
"move_grid": "Susedná položka v mriežke",
"open": "Otvoriť",
"up": "O priečinok vyššie",
"rename": "Premenovať",
"copy": "Kopírovať",
"cut": "Vystrihnúť",
"paste": "Prilepiť"
},
"rename_success": "Úspešne premenované",
"rename_error": "Premenovanie zlyhalo",
"download_error": "Sťahovanie zlyhalo",
"download_zip": "Stiahnuť ako ZIP",
"zipping": "Pripravuje sa {name}",
"download_canceled": "Sťahovanie zrušené",
"zip_error": "{name} sa nepodarilo stiahnuť",
"not_available": "Úložisko súborov nie je na tomto serveri k dispozícii",
"cancel": "Zrušiť",
"create": "Vytvoriť",
"save": "Uložiť",
"no_results": "Hľadaniu nezodpovedajú žiadne súbory",
"batch_delete_confirm_message": "Naozaj chcete odstrániť {count, plural, one {1 položku} other {# položiek}}? Túto akciu nie je možné vrátiť.",
"batch_delete_success": "{count, plural, one {Odstránená 1 položka} other {Odstránených # položiek}}",
"grid_view": "Zobrazenie mriežky",
"list_view": "Zobrazenie zoznamu",
"details": "Podrobnosti",
@@ -3996,6 +4073,9 @@
"paste": "Vložiť",
"move_success": "{count, plural, one {1 položka presunutá} other {# položiek presunutých}}",
"move_error": "Presun zlyhal",
"move_to": "Presunúť do…",
"move_to_title": "Presunúť do",
"move_here": "Presunúť sem",
"paste_success": "Úspešne vložené",
"paste_error": "Vloženie zlyhalo",
"new_text_file": "Nový textový súbor",
@@ -4075,7 +4155,10 @@
"zoom_out": "Oddialiť",
"zoom_reset": "Prispôsobiť obrazovke",
"rotate": "Otočiť",
"close_preview": "Zavrieť"
"close_preview": "Zavrieť",
"previous_file": "Predchádzajúci súbor",
"next_file": "Nasledujúci súbor",
"loading": "Načítava sa…"
},
"smime": {
"your_certificates": "Vaše certifikáty",
+93 -10
View File
@@ -1510,8 +1510,6 @@
"settings_section": "เริ่มต้นใช้งาน"
},
"files": {
"batch_delete_confirm_message": "คุณแน่ใจหรือไม่ว่าต้องการลบ {count, plural, other {# รายการ}} ที่เลือกไว้? การดำเนินการนี้ไม่สามารถยกเลิกได้",
"batch_delete_success": "{count, plural, other {ลบแล้ว # รายการ}}",
"breadcrumb_root": "หน้าแรก",
"breadcrumbs": "เส้นทางโฟลเดอร์",
"cancel": "ยกเลิก",
@@ -1520,6 +1518,9 @@
"clear_selection": "ล้างการเลือก",
"close_details": "ปิดรายละเอียด",
"close_preview": "ปิด",
"previous_file": "ไฟล์ก่อนหน้า",
"next_file": "ไฟล์ถัดไป",
"loading": "กำลังโหลด…",
"collapse_folder": "ย่อ {name}",
"context_menu": "การดำเนินการ",
"copy": "คัดลอก",
@@ -1530,11 +1531,46 @@
"create_folder_success": "สร้างโฟลเดอร์เรียบร้อยแล้ว",
"cut": "ตัด",
"delete": "ลบ",
"delete_confirm_message": "คุณแน่ใจหรือไม่ว่าต้องการลบ “{name}”? การดำเนินการนี้ไม่สามารถยกเลิกได้",
"delete_confirm_title": "ลบรายการ",
"delete_count": "{count, plural, other {ลบ # รายการ}}",
"delete_error": "ลบไม่สำเร็จ",
"delete_success": "ลบเรียบร้อยแล้ว",
"trash": "ถังขยะ",
"restore": "กู้คืน",
"restored": "{count, plural, other {กู้คืน # รายการแล้ว}}",
"restore_error": "กู้คืนไม่ได้",
"moved_to_trash": "{count, plural, other {ย้าย # รายการไปที่ถังขยะแล้ว}}",
"delete_forever": "ลบถาวร",
"delete_forever_title": "{count, plural, other {ลบ # รายการถาวรหรือไม่?}}",
"delete_forever_message": "\"{name}\" จะถูกลบ คุณไม่สามารถเลิกทำได้",
"delete_forever_message_count": "{count, plural, other {จะลบ # รายการ}} คุณไม่สามารถเลิกทำได้",
"deleted_forever": "{count, plural, other {ลบ # รายการถาวรแล้ว}}",
"empty_trash": "ล้างถังขยะ",
"empty_trash_title": "ล้างถังขยะหรือไม่?",
"empty_trash_message": "{count, plural, other {ทั้ง # รายการในถังขยะจะถูกลบถาวร}} คุณไม่สามารถเลิกทำได้",
"trash_emptied": "ล้างถังขยะแล้ว",
"empty_trash_error": "ล้างถังขยะไม่ได้",
"trash_empty_title": "ถังขยะว่างเปล่า",
"trash_empty_hint": "ไฟล์ที่ลบจะอยู่ที่นี่จนกว่าคุณจะล้างถังขยะ",
"deleted": "วันที่ลบ",
"deleted_from": "ลบจาก {folder}",
"shortcuts": {
"list": "รายการ",
"actions": "การทำงาน",
"first_last": "แรกสุดหรือท้ายสุด",
"extend": "ขยายการเลือก",
"toggle": "เลือกหรือยกเลิกการเลือก",
"select_all": "เลือกทั้งหมด",
"clear": "ล้างการเลือกหรือการค้นหา",
"delete": "ลบ",
"search": "ค้นหา",
"move": "รายการถัดไปหรือก่อนหน้า",
"move_grid": "รายการข้างเคียงในตาราง",
"open": "เปิด",
"up": "ขึ้นหนึ่งโฟลเดอร์",
"rename": "เปลี่ยนชื่อ",
"copy": "คัดลอก",
"cut": "ตัด",
"paste": "วาง"
},
"details": "รายละเอียด",
"details_hint": "เลือกไฟล์หรือโฟลเดอร์เพื่อดูรายละเอียด",
"disabled_description": "การอัปโหลดไฟล์ขนาดใหญ่ผ่าน WebDAV อาจทำให้ฐานข้อมูล Stalwart/RocksDB ไม่เสถียร รวมถึงการใช้หน่วยความจำเกินและพื้นที่ดิสก์ ฟังก์ชันนี้ไม่แนะนำให้เปิดใช้งานในสภาพแวดล้อม Production",
@@ -1543,6 +1579,10 @@
"download": "ดาวน์โหลด",
"download_count": "{count, plural, other {ดาวน์โหลด # ไฟล์}}",
"download_error": "ดาวน์โหลดไม่สำเร็จ",
"download_zip": "ดาวน์โหลดเป็น ZIP",
"zipping": "กำลังเตรียม {name}",
"download_canceled": "ยกเลิกการดาวน์โหลดแล้ว",
"zip_error": "ดาวน์โหลด {name} ไม่ได้",
"drop_to_upload": "วางไฟล์ที่นี่เพื่ออัปโหลดไปยัง {folder}",
"duplicate": "สร้างสำเนา",
"duplicate_error": "สร้างสำเนาไม่สำเร็จ",
@@ -1575,6 +1615,9 @@
"modified": "แก้ไขล่าสุด",
"more_actions": "การดำเนินการเพิ่มเติมสำหรับ {name}",
"move_error": "ย้ายไม่สำเร็จ",
"move_to": "ย้ายไปที่…",
"move_to_title": "ย้ายไปที่",
"move_here": "ย้ายมาที่นี่",
"move_success": "{count, plural, other {ย้ายแล้ว # รายการ}}",
"my_files": "ไฟล์ของฉัน",
"name": "ชื่อ",
@@ -2244,7 +2287,7 @@
"add_cc": "เพิ่มในช่อง สำเนา (Cc)",
"add_bcc": "เพิ่มในช่อง สำเนาลับ (Bcc)",
"select_hint": "เลือกรายชื่อเพื่อดูรายละเอียด",
"write_email": "เขียนอีเมล",
"write_email": "ส่งอีเมล",
"add_to_contacts": "เพิ่มในรายชื่อผู้ติดต่อ",
"email": "อีเมล",
"read_only_note": "ข้อมูลไดเรกทอรีมาจากเมลเซิร์ฟเวอร์ส่วนกลาง ไม่สามารถแก้ไขได้จากที่นี่",
@@ -2257,6 +2300,11 @@
}
},
"title": "ผู้ติดต่อ",
"disabled_title": "ผู้ดูแลระบบปิดรายชื่อติดต่อ",
"disabled_description": "ติดต่อผู้ดูแลระบบหากคุณต้องการใช้",
"drag_count": "{count, plural, other {ผู้ติดต่อ # รายการ}}",
"select_contact": "เลือก {name}",
"new": "ใหม่",
"search_placeholder": "ค้นหาผู้ติดต่อ...",
"create_new": "ผู้ติดต่อใหม่",
"no_category": "ไม่มีหมวดหมู่",
@@ -2264,11 +2312,17 @@
"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": "ยังไม่มีผู้ติดต่อ",
"empty_state_title": "ยังไม่มีผู้ติดต่อ",
"empty_state_subtitle": "สร้างผู้ติดต่อแรกของคุณ หรือนำเข้าจากไฟล์ vCard",
"empty_view_title": "ไม่มีผู้ติดต่อที่นี่",
"empty_view_hint": "เพิ่มผู้ติดต่อหรือนำเข้าไฟล์ vCard",
"empty_search": "ไม่พบผู้ติดต่อที่ตรงกับการค้นหา",
"empty_search_hint": "ลองค้นหาด้วยคำอื่น",
"empty_filtered": "ไม่พบผู้ติดต่อที่ตรงกับตัวกรอง",
@@ -2281,6 +2335,7 @@
"back_to_contacts": "กลับไปยังผู้ติดต่อ",
"back_to_email": "กลับไปยังอีเมล",
"open_categories": "เปิดหมวดหมู่",
"row_actions": "การทำงานสำหรับ {name}",
"tabs": {
"all": "ทั้งหมด",
"groups": "กลุ่มผู้ติดต่อ"
@@ -2290,6 +2345,7 @@
},
"address_books": {
"create": "สมุดที่อยู่ใหม่",
"create_confirm": "สร้าง",
"created": "สร้างสมุดที่อยู่แล้ว",
"create_failed": "สร้างสมุดที่อยู่ไม่สำเร็จ",
"title": "สมุดที่อยู่ของฉัน",
@@ -2312,7 +2368,10 @@
"delete": "ลบสมุดที่อยู่",
"confirm_delete": "ลบ “{name}” หรือไม่? ผู้ติดต่อทั้งหมดในสมุดที่อยู่นี้จะถูกลบ",
"deleted": "ลบสมุดที่อยู่แล้ว",
"delete_failed": "ลบสมุดที่อยู่ไม่สำเร็จ"
"delete_failed": "ลบสมุดที่อยู่ไม่สำเร็จ",
"move_to": "ย้ายไปที่…",
"move_title": "ย้ายไปยังสมุดที่อยู่",
"move_here": "ย้ายมาที่นี่"
},
"detail": {
"emails": "ที่อยู่อีเมล",
@@ -2338,7 +2397,7 @@
"cert_imported": "นำเข้าใบรับรองไปยังที่จัดเก็บ S/MIME แล้ว",
"cert_import_failed": "นำเข้าใบรับรองไม่สำเร็จ",
"no_contact_selected": "เลือกผู้ติดต่อเพื่อดูรายละเอียด",
"compose_email": "เขียนอีเมล",
"compose_email": "ส่งอีเมล",
"copy_email": "คัดลอกอีเมล",
"copy_phone": "คัดลอกหมายเลขโทรศัพท์",
"copy_url": "คัดลอก URL",
@@ -2396,7 +2455,7 @@
"create_title": "ผู้ติดต่อใหม่",
"edit_title": "แก้ไขผู้ติดต่อ",
"section_address_book": "สมุดที่อยู่",
"select_address_book": "เลือกสมุดที่อยู่...",
"select_address_book": "สมุดที่อยู่",
"section_identity": "ชื่อและข้อมูลประจำตัว",
"contact_type": "ประเภทผู้ติดต่อ",
"type_person": "บุคคล",
@@ -2483,6 +2542,13 @@
"add_phone": "เพิ่มโทรศัพท์",
"save": "บันทึก",
"cancel": "ยกเลิก",
"discard_title": "ละทิ้งการเปลี่ยนแปลงหรือไม่?",
"discard_message": "การเปลี่ยนแปลงของคุณยังไม่ได้บันทึก",
"discard": "ละทิ้ง",
"keep_editing": "แก้ไขต่อ",
"remove_entry": "นำออก",
"close": "ปิด",
"remove_category": "นำ {name} ออก",
"creating": "กำลังสร้าง…",
"updating": "กำลังอัปเดต…",
"name_required": "กรุณาระบุชื่อ นามสกุล หรือชื่อองค์กร",
@@ -2499,6 +2565,8 @@
},
"groups": {
"create": "กลุ่มใหม่",
"delete": "ลบกลุ่ม",
"remove_member": "นำ {name} ออกจากกลุ่ม",
"edit": "แก้ไขกลุ่ม",
"empty": "ยังไม่มีกลุ่มผู้ติดต่อ",
"delete_confirm_title": "ลบกลุ่ม",
@@ -2511,7 +2579,7 @@
"search_members": "ค้นหาผู้ติดต่อเพื่อเพิ่มในกลุ่ม...",
"no_members": "ยังไม่มีสมาชิกในกลุ่มนี้",
"member_count": "{count, plural, =0 {ไม่มีสมาชิก} one {สมาชิก 1 คน} other {สมาชิก # คน}}",
"send_email": "ส่งอีเมลถึงกลุ่ม",
"send_email": "ส่งอีเมล",
"send_email_to": "ถึง (To)",
"send_email_cc": "สำเนา (Cc)",
"send_email_bcc": "สำเนาลับ (Bcc)",
@@ -2549,6 +2617,7 @@
"deleted": "{count, plural, one {ลบผู้ติดต่อแล้ว 1 รายการ} other {ลบผู้ติดต่อแล้ว # รายการ}}",
"add_to_group": "เพิ่มในกลุ่ม",
"choose_group": "เลือกกลุ่ม",
"new_group": "กลุ่มใหม่…",
"adding_contacts": "กำลังเพิ่มผู้ติดต่อ {count, plural, one {1 รายการ} other {# รายการ}}",
"added_to_group": "เพิ่มผู้ติดต่อในกลุ่มแล้ว",
"export": "ส่งออก",
@@ -2595,6 +2664,20 @@
"sort_by": "เรียงลำดับตาม",
"sort_first_name": "ชื่อ",
"sort_last_name": "นามสกุล"
},
"shortcuts": {
"list": "รายการ",
"actions": "การทำงาน",
"first_last": "แรกสุดหรือท้ายสุด",
"extend": "ขยายการเลือก",
"toggle": "เลือกหรือยกเลิกการเลือก",
"select_all": "เลือกทั้งหมด",
"clear": "ล้างการเลือกหรือการค้นหา",
"delete": "ลบ",
"search": "ค้นหา",
"move": "ผู้ติดต่อถัดไปหรือก่อนหน้า",
"open": "เปิดผู้ติดต่อ",
"menu": "เปิดเมนูของผู้ติดต่อ"
}
},
"calendar": {
+96 -13
View File
@@ -2915,7 +2915,7 @@
"add_cc": "Bilgi alanına ekle",
"add_bcc": "Gizli alanına ekle",
"select_hint": "Ayrıntıları görmek için birini seçin",
"write_email": "E-posta yaz",
"write_email": "E-posta gönder",
"add_to_contacts": "Kişilere ekle",
"email": "E-posta",
"read_only_note": "Dizin girdileri posta sunucunuzdan gelir ve burada düzenlenemez.",
@@ -2928,6 +2928,11 @@
}
},
"title": "Kişiler",
"disabled_title": "Kişiler yöneticiniz tarafından kapatıldı",
"disabled_description": "İhtiyacınız varsa yöneticinize başvurun.",
"drag_count": "{count, plural, one {1 kişi} other {# kişi}}",
"select_contact": "{name} öğesini seç",
"new": "Yeni",
"search_placeholder": "Kişilerde ara...",
"create_new": "Yeni Kişi",
"no_category": "Kategori Yok",
@@ -2935,11 +2940,17 @@
"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",
"empty_state_title": "Henüz kişi yok",
"empty_state_subtitle": "İlk kişinizi oluşturun veya vCard dosyasından içe aktarın",
"empty_view_title": "Burada kişi yok",
"empty_view_hint": "Bir kişi ekleyin veya bir vCard dosyası içe aktarın.",
"empty_search": "Aramanızla eşleşen kişi yok",
"empty_search_hint": "Farklı bir arama terimi deneyin",
"empty_filtered": "Filtrelerinizle eşleşen kişi yok",
@@ -2960,6 +2971,7 @@
},
"address_books": {
"create": "Yeni adres defteri",
"create_confirm": "Oluştur",
"created": "Adres defteri oluşturuldu",
"create_failed": "Adres defteri oluşturulamadı",
"title": "Adres Defterlerim",
@@ -2982,7 +2994,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",
@@ -3008,7 +3023,7 @@
"cert_imported": "Sertifika S/MIME deposuna aktarıldı",
"cert_import_failed": "Sertifika içe aktarılamadı",
"no_contact_selected": "Ayrıntıları görüntülemek için bir kişi seçin",
"compose_email": "E-posta oluştur",
"compose_email": "E-posta gönder",
"copy_email": "E-postayı kopyala",
"copy_phone": "Telefon numarasını kopyala",
"copy_url": "URL'yi kopyala",
@@ -3065,8 +3080,8 @@
"form": {
"create_title": "Yeni Kişi",
"edit_title": "Kişiyi Düzenle",
"section_address_book": "Dizin",
"select_address_book": "Bir dizin seçin...",
"section_address_book": "Adres Defteri",
"select_address_book": "Adres Defteri",
"section_identity": "Ad ve Kimlik",
"contact_type": "Kişi türü",
"type_person": "Kişi",
@@ -3153,6 +3168,13 @@
"add_phone": "Telefon ekle",
"save": "Kaydet",
"cancel": "İptal",
"discard_title": "Değişiklikler atılsın mı?",
"discard_message": "Değişiklikleriniz kaydedilmedi.",
"discard": "At",
"keep_editing": "Düzenlemeye devam et",
"remove_entry": "Kaldır",
"close": "Kapat",
"remove_category": "{name} öğesini kaldır",
"creating": "Oluşturuluyor...",
"updating": "Güncelleniyor...",
"name_required": "Bir ad, soyad veya kuruluş girin",
@@ -3169,6 +3191,8 @@
},
"groups": {
"create": "Yeni Grup",
"delete": "Grubu sil",
"remove_member": "{name} kişisini gruptan çıkar",
"edit": "Grubu Düzenle",
"empty": "Henüz grup yok",
"delete_confirm_title": "Grubu sil",
@@ -3181,7 +3205,7 @@
"search_members": "Eklemek için kişileri arayın...",
"no_members": "Bu grupta üye yok",
"member_count": "{count, plural, =0 {Üye yok} one {1 üye} other {# üye}}",
"send_email": "Send email to group",
"send_email": "E-posta gönder",
"send_email_to": "To",
"send_email_cc": "Cc",
"send_email_bcc": "Bcc",
@@ -3219,6 +3243,7 @@
"deleted": "{count, plural, one {1 kişi silindi} other {# kişi silindi}}",
"add_to_group": "Gruba ekle",
"choose_group": "Grup seçin",
"new_group": "Yeni grup…",
"adding_contacts": "{count, plural, one {1 kişi} other {# kişi}} ekleniyor",
"added_to_group": "Kişiler gruba eklendi",
"export": "Dışa Aktar",
@@ -3266,7 +3291,22 @@
"sort_first_name": "Ad",
"sort_last_name": "Soyad"
},
"open_categories": "Kategorileri aç"
"shortcuts": {
"list": "Liste",
"actions": "İşlemler",
"first_last": "İlk veya son",
"extend": "Seçimi genişlet",
"toggle": "Seç veya seçimi kaldır",
"select_all": "Tümünü seç",
"clear": "Seçimi veya aramayı temizle",
"delete": "Sil",
"search": "Ara",
"move": "Sonraki veya önceki kişi",
"open": "Kişiyi aç",
"menu": "Kişi menüsünü aç"
},
"open_categories": "Kategorileri aç",
"row_actions": "{name} için işlemler"
},
"calendar": {
"title": "Takvim",
@@ -3946,8 +3986,6 @@
"name_control_chars": "Adlar denetim karakterleri içeremez.",
"name_too_long": "Bu ad sunucu için çok uzun.",
"delete": "Sil",
"delete_confirm_title": "Kaynağı sil",
"delete_confirm_message": "\"{name}\" silinsin mi? Bu geri alınamaz.",
"download": "İndir",
"name": "Ad",
"size": "Boyut",
@@ -3972,18 +4010,57 @@
"error_partial_copy": "Kopyanın bir kısmı yerinde kaldı.",
"create_folder_success": "Klasör oluşturuldu",
"create_folder_error": "Klasör oluşturulamadı",
"delete_success": "Başarıyla silindi",
"delete_error": "Silinemedi",
"trash": "Çöp kutusu",
"restore": "Geri yükle",
"restored": "{count, plural, one {Öğe geri yüklendi} other {# öğe geri yüklendi}}",
"restore_error": "Geri yüklenemedi",
"moved_to_trash": "{count, plural, one {Çöp kutusuna taşındı} other {# öğe çöp kutusuna taşındı}}",
"delete_forever": "Kalıcı olarak sil",
"delete_forever_title": "{count, plural, one {Kalıcı olarak silinsin mi?} other {# öğe kalıcı olarak silinsin mi?}}",
"delete_forever_message": "\"{name}\" silinecek. Bu işlem geri alınamaz.",
"delete_forever_message_count": "{count, plural, one {1 öğe silinecek} other {# öğe silinecek}}. Bu işlem geri alınamaz.",
"deleted_forever": "{count, plural, one {Kalıcı olarak silindi} other {# öğe kalıcı olarak silindi}}",
"empty_trash": "Çöp kutusunu boşalt",
"empty_trash_title": "Çöp kutusu boşaltılsın mı?",
"empty_trash_message": "{count, plural, one {Çöp kutusundaki öğe} other {Çöp kutusundaki # öğenin tümü}} kalıcı olarak silinecek. Bu işlem geri alınamaz.",
"trash_emptied": "Çöp kutusu boşaltıldı",
"empty_trash_error": "Çöp kutusu boşaltılamadı",
"trash_empty_title": "Çöp kutusu boş",
"trash_empty_hint": "Silinen dosyalar siz çöp kutusunu boşaltana kadar burada kalır.",
"deleted": "Silinme",
"deleted_from": "{folder} konumundan silindi",
"shortcuts": {
"list": "Liste",
"actions": "İşlemler",
"first_last": "İlk veya son",
"extend": "Seçimi genişlet",
"toggle": "Seç veya seçimi kaldır",
"select_all": "Tümünü seç",
"clear": "Seçimi veya aramayı temizle",
"delete": "Sil",
"search": "Ara",
"move": "Sonraki veya önceki öğe",
"move_grid": "Izgarada yanındaki öğe",
"open": "Aç",
"up": "Bir klasör yukarı",
"rename": "Yeniden adlandır",
"copy": "Kopyala",
"cut": "Kes",
"paste": "Yapıştır"
},
"rename_success": "Başarıyla yeniden adlandırıldı",
"rename_error": "Yeniden adlandırılamadı",
"download_error": "İndirilemedi",
"download_zip": "ZIP olarak indir",
"zipping": "{name} hazırlanıyor",
"download_canceled": "İndirme iptal edildi",
"zip_error": "{name} indirilemedi",
"not_available": "Bu sunucuda dosya depolama kullanılamıyor",
"cancel": "İptal",
"create": "Oluştur",
"save": "Kaydet",
"no_results": "Aramanızla eşleşen dosya yok",
"batch_delete_confirm_message": "{count, plural, one {1 öge} other {# öge}} silinsin mi? Bu geri alınamaz.",
"batch_delete_success": "{count, plural, one {1 öge silindi} other {# öge silindi}}",
"grid_view": "Izgara görünümü",
"list_view": "Liste görünümü",
"details": "Ayrıntılar",
@@ -3995,6 +4072,9 @@
"paste": "Yapıştır",
"move_success": "{count, plural, one {1 öge taşındı} other {# öge taşındı}}",
"move_error": "Taşınamadı",
"move_to": "Taşı…",
"move_to_title": "Taşı",
"move_here": "Buraya taşı",
"paste_success": "Başarıyla yapıştırıldı",
"paste_error": "Yapıştırılamadı",
"new_text_file": "Yeni Metin Dosyası",
@@ -4075,7 +4155,10 @@
"zoom_out": "Uzaklaştır",
"zoom_reset": "Ekrana sığdır",
"rotate": "Döndür",
"close_preview": "Kapat"
"close_preview": "Kapat",
"previous_file": "Önceki dosya",
"next_file": "Sonraki dosya",
"loading": "Yükleniyor…"
},
"smime": {
"your_certificates": "Sertifikalarınız",
+96 -13
View File
@@ -2915,7 +2915,7 @@
"add_cc": "Додати в «Копія»",
"add_bcc": "Додати в «Прихована копія»",
"select_hint": "Виберіть людину, щоб побачити подробиці",
"write_email": "Написати листа",
"write_email": "Надіслати лист",
"add_to_contacts": "Додати до контактів",
"email": "Ел. пошта",
"read_only_note": "Записи довідника надходять з вашого поштового сервера, тут їх не можна змінити.",
@@ -2928,6 +2928,11 @@
}
},
"title": "Контакти",
"disabled_title": "Адміністратор вимкнув контакти",
"disabled_description": "Зверніться до адміністратора, якщо вони вам потрібні.",
"drag_count": "{count, plural, one {# контакт} few {# контакти} other {# контактів}}",
"select_contact": "Вибрати {name}",
"new": "Створити",
"search_placeholder": "Пошук контактів...",
"create_new": "Новий контакт",
"no_category": "Без категорії",
@@ -2935,11 +2940,17 @@
"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": "Контактів ще немає",
"empty_state_title": "Контактів ще немає",
"empty_state_subtitle": "Створіть свій перший контакт або імпортуйте його з файлу vCard",
"empty_view_title": "Тут немає контактів",
"empty_view_hint": "Додайте контакт або імпортуйте файл vCard.",
"empty_search": "Жоден контакт не відповідає вашому пошуку",
"empty_search_hint": "Спробуйте інший термін пошуку",
"empty_filtered": "Жоден контакт не відповідає фільтрам",
@@ -2960,6 +2971,7 @@
},
"address_books": {
"create": "Нова адресна книга",
"create_confirm": "Створити",
"created": "Адресну книгу створено",
"create_failed": "Не вдалося створити адресну книгу",
"title": "Мої адресні книги",
@@ -2982,7 +2994,10 @@
"delete": "Видалити адресну книгу",
"confirm_delete": "Видалити «{name}»? Усі контакти в цій адресній книзі будуть видалені.",
"deleted": "Адресну книгу видалено",
"delete_failed": "Не вдалося видалити адресну книгу"
"delete_failed": "Не вдалося видалити адресну книгу",
"move_to": "Перемістити до…",
"move_title": "Перемістити до адресної книги",
"move_here": "Перемістити сюди"
},
"detail": {
"emails": "Адреси електронної пошти",
@@ -3008,7 +3023,7 @@
"cert_imported": "Сертифікат імпортовано до магазину S/MIME",
"cert_import_failed": "Не вдалося імпортувати сертифікат",
"no_contact_selected": "Виберіть контакт, щоб переглянути деталі",
"compose_email": "Написати електронний лист",
"compose_email": "Надіслати лист",
"copy_email": "Копіювати електронну пошту",
"copy_phone": "Копіювати номер телефону",
"copy_url": "Копіювати URL",
@@ -3065,8 +3080,8 @@
"form": {
"create_title": "Новий контакт",
"edit_title": "Редагувати контакт",
"section_address_book": "Довідник",
"select_address_book": "Виберіть каталог...",
"section_address_book": "Адресна книга",
"select_address_book": "Адресна книга",
"section_identity": "Ім'я та ідентифікація",
"contact_type": "Тип контакту",
"type_person": "Людина",
@@ -3153,6 +3168,13 @@
"add_phone": "Додати телефон",
"save": "зберегти",
"cancel": "Скасувати",
"discard_title": "Відкинути зміни?",
"discard_message": "Ваші зміни не збережено.",
"discard": "Відкинути",
"keep_editing": "Продовжити редагування",
"remove_entry": "Прибрати",
"close": "Закрити",
"remove_category": "Прибрати {name}",
"creating": "Створення...",
"updating": "Оновлення...",
"name_required": "Вкажіть ім'я, прізвище або організацію",
@@ -3169,6 +3191,8 @@
},
"groups": {
"create": "Нова група",
"delete": "Видалити групу",
"remove_member": "Прибрати {name} з групи",
"edit": "Редагувати групу",
"empty": "Груп ще немає",
"delete_confirm_title": "Видалити групу",
@@ -3181,7 +3205,7 @@
"search_members": "Пошук контактів для додавання...",
"no_members": "У цій групі немає учасників",
"member_count": "{count, plural, =0 {Немає учасників} one {1 учасник} few {# учасники} many {# учасників} other {# учасників}}",
"send_email": "Send email to group",
"send_email": "Надіслати лист",
"send_email_to": "To",
"send_email_cc": "Cc",
"send_email_bcc": "Bcc",
@@ -3219,6 +3243,7 @@
"deleted": "{count, plural, one {1 контакт видалено} few {# контакти видалено} many {# контактів видалено} other {# контактів видалено}}",
"add_to_group": "Додати до групи",
"choose_group": "Виберіть групу",
"new_group": "Нова група…",
"adding_contacts": "Додавання {count, plural, one {1 contact} other {# contacts}}",
"added_to_group": "Контакти додано до групи",
"export": "Експорт",
@@ -3266,7 +3291,22 @@
"sort_first_name": "Ім'я",
"sort_last_name": "Прізвище"
},
"open_categories": "Відкрити категорії"
"shortcuts": {
"list": "Список",
"actions": "Дії",
"first_last": "Перший або останній",
"extend": "Розширити виділення",
"toggle": "Вибрати або скасувати вибір",
"select_all": "Вибрати все",
"clear": "Скинути виділення або пошук",
"delete": "Видалити",
"search": "Пошук",
"move": "Наступний або попередній контакт",
"open": "Відкрити контакт",
"menu": "Відкрити меню контакту"
},
"open_categories": "Відкрити категорії",
"row_actions": "Дії для {name}"
},
"calendar": {
"title": "Календар",
@@ -3900,8 +3940,6 @@
"name_control_chars": "Назви не можуть містити керівні символи.",
"name_too_long": "Ця назва задовга для сервера.",
"delete": "Видалити",
"delete_confirm_title": "Видалити ресурс",
"delete_confirm_message": "Ви впевнені, що хочете видалити \"{name}\"? Це неможливо скасувати.",
"download": "Завантажити",
"name": "Ім'я",
"size": "Розмір",
@@ -3926,18 +3964,57 @@
"error_partial_copy": "Частина копії залишилася на місці.",
"create_folder_success": "Папка створена",
"create_folder_error": "Не вдалося створити папку",
"delete_success": "Успішно видалено",
"delete_error": "Не вдалося видалити",
"trash": "Кошик",
"restore": "Відновити",
"restored": "{count, plural, one {Елемент відновлено} few {Відновлено # елементи} other {Відновлено # елементів}}",
"restore_error": "Не вдалося відновити",
"moved_to_trash": "{count, plural, one {Переміщено в кошик} few {# елементи переміщено в кошик} other {# елементів переміщено в кошик}}",
"delete_forever": "Видалити назавжди",
"delete_forever_title": "{count, plural, one {Видалити назавжди?} few {Видалити # елементи назавжди?} other {Видалити # елементів назавжди?}}",
"delete_forever_message": "«{name}» буде видалено. Цю дію не можна скасувати.",
"delete_forever_message_count": "{count, plural, one {Буде видалено 1 елемент} few {Буде видалено # елементи} other {Буде видалено # елементів}}. Цю дію не можна скасувати.",
"deleted_forever": "{count, plural, one {Видалено назавжди} few {# елементи видалено назавжди} other {# елементів видалено назавжди}}",
"empty_trash": "Очистити кошик",
"empty_trash_title": "Очистити кошик?",
"empty_trash_message": "{count, plural, one {Елемент у кошику буде видалено} few {Усі # елементи в кошику буде видалено} other {Усі # елементів у кошику буде видалено}} назавжди. Цю дію не можна скасувати.",
"trash_emptied": "Кошик очищено",
"empty_trash_error": "Не вдалося очистити кошик",
"trash_empty_title": "Кошик порожній",
"trash_empty_hint": "Видалені файли зберігаються тут, доки ви не очистите кошик.",
"deleted": "Видалено",
"deleted_from": "Видалено з {folder}",
"shortcuts": {
"list": "Список",
"actions": "Дії",
"first_last": "Перший або останній",
"extend": "Розширити виділення",
"toggle": "Вибрати або скасувати вибір",
"select_all": "Вибрати все",
"clear": "Скинути виділення або пошук",
"delete": "Видалити",
"search": "Пошук",
"move": "Наступний або попередній елемент",
"move_grid": "Сусідній елемент у сітці",
"open": "Відкрити",
"up": "На папку вище",
"rename": "Перейменувати",
"copy": "Копіювати",
"cut": "Вирізати",
"paste": "Вставити"
},
"rename_success": "Успішно перейменовано",
"rename_error": "Не вдалося перейменувати",
"download_error": "Не вдалося завантажити",
"download_zip": "Завантажити як ZIP",
"zipping": "Підготовка {name}",
"download_canceled": "Завантаження скасовано",
"zip_error": "Не вдалося завантажити {name}",
"not_available": "Зберігання файлів недоступне на цьому сервері",
"cancel": "Скасувати",
"create": "Створити",
"save": "зберегти",
"no_results": "Немає файлів, що відповідають вашому пошуку",
"batch_delete_confirm_message": "Ви впевнені, що хочете видалити {count, plural, one {1 item} other {# items}}? Це неможливо скасувати.",
"batch_delete_success": "{count, plural, one {1 елемент видалено} few {# елементи видалено} many {# елементів видалено} other {# елементів видалено}}",
"grid_view": "Вигляд сітки",
"list_view": "Перегляд списку",
"details": "Подробиці",
@@ -3949,6 +4026,9 @@
"paste": "Вставити",
"move_success": "{count, plural, one {1 елемент переміщено} few {# елементи переміщено} many {# елементів переміщено} other {# елементів переміщено}}",
"move_error": "Не вдалося переміститися",
"move_to": "Перемістити до…",
"move_to_title": "Перемістити до",
"move_here": "Перемістити сюди",
"paste_success": "Вставлено успішно",
"paste_error": "Не вдалося вставити",
"new_text_file": "Новий текстовий файл",
@@ -4029,7 +4109,10 @@
"zoom_out": "Зменшити",
"zoom_reset": "За розміром екрана",
"rotate": "Повернути",
"close_preview": "Закрити"
"close_preview": "Закрити",
"previous_file": "Попередній файл",
"next_file": "Наступний файл",
"loading": "Завантаження…"
},
"smime": {
"your_certificates": "Ваші сертифікати",
+94 -11
View File
@@ -2915,7 +2915,7 @@
"add_cc": "加入副本",
"add_bcc": "加入密件副本",
"select_hint": "選取一個人以查看詳細資料",
"write_email": "撰寫郵件",
"write_email": "傳送郵件",
"add_to_contacts": "加入聯絡人",
"email": "電子郵件",
"read_only_note": "目錄項目來自你的郵件伺服器,無法在此編輯。",
@@ -2928,6 +2928,11 @@
}
},
"title": "聯絡人",
"disabled_title": "管理員已關閉聯絡人",
"disabled_description": "如需使用,請聯絡管理員。",
"drag_count": "{count, plural, other {# 位聯絡人}}",
"select_contact": "選取 {name}",
"new": "新增",
"search_placeholder": "搜尋聯絡人…",
"create_new": "新增聯絡人",
"no_category": "無類別",
@@ -2935,11 +2940,17 @@
"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": "尚無聯絡人",
"empty_state_title": "尚無聯絡人",
"empty_state_subtitle": "建立第一位聯絡人,或從 vCard 檔案匯入",
"empty_view_title": "這裡沒有聯絡人",
"empty_view_hint": "新增聯絡人或匯入 vCard 檔案。",
"empty_search": "沒有符合您搜尋條件的聯絡人",
"empty_search_hint": "嘗試其他搜尋字詞",
"empty_filtered": "沒有符合您篩選條件的聯絡人",
@@ -2952,6 +2963,7 @@
"back_to_contacts": "回到聯絡人",
"back_to_email": "回到郵件",
"open_categories": "開啟類別",
"row_actions": "{name} 的動作",
"tabs": {
"all": "全部",
"groups": "群組"
@@ -2961,6 +2973,7 @@
},
"address_books": {
"create": "新增通訊錄",
"create_confirm": "建立",
"created": "通訊錄已建立",
"create_failed": "建立通訊錄失敗",
"title": "我的通訊錄",
@@ -2983,7 +2996,10 @@
"delete": "刪除通訊錄",
"confirm_delete": "要刪除「{name}」嗎?此通訊錄中的所有聯絡人都會移除。",
"deleted": "通訊錄已刪除",
"delete_failed": "刪除通訊錄失敗"
"delete_failed": "刪除通訊錄失敗",
"move_to": "移至…",
"move_title": "移至通訊錄",
"move_here": "移到這裡"
},
"detail": {
"emails": "電子郵件地址",
@@ -3009,7 +3025,7 @@
"cert_imported": "憑證已匯入 S/MIME 憑證庫",
"cert_import_failed": "匯入憑證失敗",
"no_contact_selected": "選取一位聯絡人以檢視詳細資料",
"compose_email": "撰寫郵件",
"compose_email": "傳送郵件",
"copy_email": "複製電子郵件地址",
"copy_phone": "複製電話號碼",
"copy_url": "複製 URL",
@@ -3067,7 +3083,7 @@
"create_title": "新聯絡人",
"edit_title": "編輯聯絡人",
"section_address_book": "通訊錄",
"select_address_book": "選擇通訊錄…",
"select_address_book": "通訊錄",
"section_identity": "姓名與身分",
"contact_type": "聯絡人類型",
"type_person": "個人",
@@ -3154,6 +3170,13 @@
"add_phone": "新增電話",
"save": "儲存",
"cancel": "取消",
"discard_title": "要捨棄變更嗎?",
"discard_message": "您的變更尚未儲存。",
"discard": "捨棄",
"keep_editing": "繼續編輯",
"remove_entry": "移除",
"close": "關閉",
"remove_category": "移除 {name}",
"creating": "正在建立…",
"updating": "正在更新…",
"name_required": "請輸入名、姓或組織名稱",
@@ -3170,6 +3193,8 @@
},
"groups": {
"create": "新增群組",
"delete": "刪除群組",
"remove_member": "將 {name} 移出群組",
"edit": "編輯群組",
"empty": "尚無群組",
"delete_confirm_title": "刪除群組",
@@ -3182,7 +3207,7 @@
"search_members": "搜尋要新增的聯絡人…",
"no_members": "該群組中沒有成員",
"member_count": "{count, plural, =0 {無成員} one {1 位成員} other {# 位成員}}",
"send_email": "傳送郵件給群組",
"send_email": "傳送郵件",
"send_email_to": "收件者",
"send_email_cc": "副本",
"send_email_bcc": "密件副本",
@@ -3220,6 +3245,7 @@
"deleted": "{count, plural, one {已刪除 1 位聯絡人} other {已刪除 # 位聯絡人}}",
"add_to_group": "新增至群組",
"choose_group": "選擇群組",
"new_group": "新增群組…",
"adding_contacts": "正在新增{count, plural, one {1 位聯絡人} other {# 位聯絡人}}",
"added_to_group": "聯絡人已新增至群組",
"export": "匯出",
@@ -3266,6 +3292,20 @@
"sort_by": "排序方式",
"sort_first_name": "名",
"sort_last_name": "姓"
},
"shortcuts": {
"list": "清單",
"actions": "動作",
"first_last": "第一個或最後一個",
"extend": "擴充選取範圍",
"toggle": "選取或取消選取",
"select_all": "全選",
"clear": "清除選取或搜尋",
"delete": "刪除",
"search": "搜尋",
"move": "下一個或上一個聯絡人",
"open": "開啟聯絡人",
"menu": "開啟聯絡人選單"
}
},
"calendar": {
@@ -3946,8 +3986,6 @@
"name_control_chars": "名稱不能包含控制字元。",
"name_too_long": "此名稱對伺服器來說過長。",
"delete": "刪除",
"delete_confirm_title": "刪除項目",
"delete_confirm_message": "您確定要刪除「{name}」嗎?此動作無法復原。",
"download": "下載",
"name": "名稱",
"size": "大小",
@@ -3973,18 +4011,57 @@
"error_partial_copy": "部分副本仍保留在原處。",
"create_folder_success": "資料夾已建立",
"create_folder_error": "建立資料夾失敗",
"delete_success": "刪除成功",
"delete_error": "刪除失敗",
"trash": "垃圾桶",
"restore": "還原",
"restored": "{count, plural, other {已還原 # 個項目}}",
"restore_error": "無法還原",
"moved_to_trash": "{count, plural, other {已將 # 個項目移到垃圾桶}}",
"delete_forever": "永久刪除",
"delete_forever_title": "{count, plural, other {要永久刪除 # 個項目嗎?}}",
"delete_forever_message": "「{name}」將被刪除。此動作無法復原。",
"delete_forever_message_count": "{count, plural, other {將刪除 # 個項目}}。此動作無法復原。",
"deleted_forever": "{count, plural, other {已永久刪除 # 個項目}}",
"empty_trash": "清空垃圾桶",
"empty_trash_title": "要清空垃圾桶嗎?",
"empty_trash_message": "{count, plural, other {垃圾桶中的 # 個項目將被永久刪除}}。此動作無法復原。",
"trash_emptied": "已清空垃圾桶",
"empty_trash_error": "無法清空垃圾桶",
"trash_empty_title": "垃圾桶是空的",
"trash_empty_hint": "刪除的檔案會保留在這裡,直到您清空垃圾桶。",
"deleted": "刪除時間",
"deleted_from": "從 {folder} 刪除",
"shortcuts": {
"list": "清單",
"actions": "動作",
"first_last": "第一個或最後一個",
"extend": "擴充選取範圍",
"toggle": "選取或取消選取",
"select_all": "全選",
"clear": "清除選取或搜尋",
"delete": "刪除",
"search": "搜尋",
"move": "下一個或上一個項目",
"move_grid": "格狀檢視中的相鄰項目",
"open": "開啟",
"up": "上一層資料夾",
"rename": "重新命名",
"copy": "複製",
"cut": "剪下",
"paste": "貼上"
},
"rename_success": "重新命名成功",
"rename_error": "重新命名失敗",
"download_error": "下載失敗",
"download_zip": "下載為 ZIP",
"zipping": "正在準備 {name}",
"download_canceled": "已取消下載",
"zip_error": "無法下載 {name}",
"not_available": "此伺服器無法使用檔案儲存功能",
"cancel": "取消",
"create": "建立",
"save": "儲存",
"no_results": "找不到符合搜尋條件的檔案",
"batch_delete_confirm_message": "您確定要刪除{count, plural, one {1 個項目} other {# 個項目}}嗎?此動作無法復原。",
"batch_delete_success": "{count, plural, one {已刪除 1 個項目} other {已刪除 # 個項目}}",
"grid_view": "網格檢視",
"list_view": "清單檢視",
"details": "詳細資料",
@@ -3996,6 +4073,9 @@
"paste": "貼上",
"move_success": "{count, plural, one {已移動 1 個項目} other {已移動 # 個項目}}",
"move_error": "移動失敗",
"move_to": "移至…",
"move_to_title": "移至",
"move_here": "移到這裡",
"paste_success": "貼上成功",
"paste_error": "貼上失敗",
"new_text_file": "新增文字檔案",
@@ -4075,7 +4155,10 @@
"zoom_out": "縮小",
"zoom_reset": "符合螢幕大小",
"rotate": "旋轉",
"close_preview": "關閉"
"close_preview": "關閉",
"previous_file": "上一個檔案",
"next_file": "下一個檔案",
"loading": "載入中…"
},
"smime": {
"your_certificates": "您的憑證",
+95 -12
View File
@@ -2915,7 +2915,7 @@
"add_cc": "添加到抄送",
"add_bcc": "添加到密送",
"select_hint": "选择一个人以查看详情",
"write_email": "写邮件",
"write_email": "发送邮件",
"add_to_contacts": "添加到联系人",
"email": "邮箱",
"read_only_note": "目录条目来自你的邮件服务器,无法在此编辑。",
@@ -2928,6 +2928,11 @@
}
},
"title": "联系人",
"disabled_title": "管理员已关闭联系人",
"disabled_description": "如需使用,请联系管理员。",
"drag_count": "{count, plural, other {# 个联系人}}",
"select_contact": "选择 {name}",
"new": "新建",
"search_placeholder": "搜索联系人...",
"create_new": "新联系人",
"no_category": "没有类别",
@@ -2935,11 +2940,17 @@
"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": "还没有联系人",
"empty_state_title": "还没有联系人",
"empty_state_subtitle": "创建您的第一个联系人或从 vCard 文件导入",
"empty_view_title": "此处没有联系人",
"empty_view_hint": "添加联系人或导入 vCard 文件。",
"empty_search": "没有符合您搜索条件的联系人",
"empty_search_hint": "尝试不同的搜索词",
"empty_filtered": "没有符合您筛选条件的联系人",
@@ -2960,6 +2971,7 @@
},
"address_books": {
"create": "新建通讯录",
"create_confirm": "创建",
"created": "通讯录已创建",
"create_failed": "创建通讯录失败",
"title": "我的地址簿",
@@ -2982,7 +2994,10 @@
"delete": "删除通讯录",
"confirm_delete": "删除「{name}」?此通讯录中的所有联系人将被移除。",
"deleted": "通讯录已删除",
"delete_failed": "删除通讯录失败"
"delete_failed": "删除通讯录失败",
"move_to": "移动到…",
"move_title": "移动到通讯录",
"move_here": "移到此处"
},
"detail": {
"emails": "邮箱地址",
@@ -3008,7 +3023,7 @@
"cert_imported": "证书导入到 S/MIME 存储",
"cert_import_failed": "导入证书失败",
"no_contact_selected": "选择联系人以查看详细信息",
"compose_email": "撰写邮件",
"compose_email": "发送邮件",
"copy_email": "复制邮箱地址",
"copy_phone": "复制电话号码",
"copy_url": "复制 URL",
@@ -3066,7 +3081,7 @@
"create_title": "新联系人",
"edit_title": "编辑联系人",
"section_address_book": "地址簿",
"select_address_book": "选择地址簿...",
"select_address_book": "地址簿",
"section_identity": "姓名和身份",
"contact_type": "联系人类型",
"type_person": "个人",
@@ -3153,6 +3168,13 @@
"add_phone": "添加电话",
"save": "保存",
"cancel": "取消",
"discard_title": "放弃更改?",
"discard_message": "您的更改尚未保存。",
"discard": "放弃",
"keep_editing": "继续编辑",
"remove_entry": "移除",
"close": "关闭",
"remove_category": "移除 {name}",
"creating": "创建中...",
"updating": "更新中...",
"name_required": "请输入名字、姓氏或组织",
@@ -3169,6 +3191,8 @@
},
"groups": {
"create": "新建群组",
"delete": "删除群组",
"remove_member": "将 {name} 移出群组",
"edit": "编辑群组",
"empty": "还没有群组",
"delete_confirm_title": "删除群组",
@@ -3181,7 +3205,7 @@
"search_members": "搜索联系人以添加...",
"no_members": "该群组中没有成员",
"member_count": "{count, plural, =0 {无成员} one {1 位成员} other {# 位成员}}",
"send_email": "Send email to group",
"send_email": "发送邮件",
"send_email_to": "To",
"send_email_cc": "Cc",
"send_email_bcc": "Bcc",
@@ -3219,6 +3243,7 @@
"deleted": "{count, plural, one {已删除 1 位联系人} other {已删除 # 位联系人}}",
"add_to_group": "添加到群组",
"choose_group": "选择一个组",
"new_group": "新建群组…",
"adding_contacts": "正在添加{count, plural, one {1 位联系人} other {# 位联系人}}",
"added_to_group": "联系人已添加至群组",
"export": "导出",
@@ -3266,7 +3291,22 @@
"sort_first_name": "名",
"sort_last_name": "姓"
},
"open_categories": "打开分类"
"shortcuts": {
"list": "列表",
"actions": "操作",
"first_last": "第一个或最后一个",
"extend": "扩展选择",
"toggle": "选择或取消选择",
"select_all": "全选",
"clear": "清除选择或搜索",
"delete": "删除",
"search": "搜索",
"move": "下一个或上一个联系人",
"open": "打开联系人",
"menu": "打开联系人菜单"
},
"open_categories": "打开分类",
"row_actions": "{name} 的操作"
},
"calendar": {
"title": "日历",
@@ -3900,8 +3940,6 @@
"name_control_chars": "名称不能包含控制字符。",
"name_too_long": "此名称对服务器来说过长。",
"delete": "删除",
"delete_confirm_title": "删除资源",
"delete_confirm_message": "您确定要删除\"{name}\"吗?此操作无法撤消。",
"download": "下载",
"name": "名称",
"size": "大小",
@@ -3926,18 +3964,57 @@
"error_partial_copy": "部分副本被保留在原处。",
"create_folder_success": "文件夹已创建",
"create_folder_error": "创建文件夹失败",
"delete_success": "删除成功",
"delete_error": "删除失败",
"trash": "回收站",
"restore": "恢复",
"restored": "{count, plural, other {已恢复 # 个项目}}",
"restore_error": "无法恢复",
"moved_to_trash": "{count, plural, other {已将 # 个项目移到回收站}}",
"delete_forever": "永久删除",
"delete_forever_title": "{count, plural, other {永久删除 # 个项目?}}",
"delete_forever_message": "“{name}”将被删除。此操作无法撤销。",
"delete_forever_message_count": "{count, plural, other {将删除 # 个项目}}。此操作无法撤销。",
"deleted_forever": "{count, plural, other {已永久删除 # 个项目}}",
"empty_trash": "清空回收站",
"empty_trash_title": "清空回收站?",
"empty_trash_message": "{count, plural, other {回收站中的 # 个项目将被永久删除}}。此操作无法撤销。",
"trash_emptied": "已清空回收站",
"empty_trash_error": "无法清空回收站",
"trash_empty_title": "回收站为空",
"trash_empty_hint": "删除的文件会保留在这里,直到您清空回收站。",
"deleted": "删除时间",
"deleted_from": "从 {folder} 删除",
"shortcuts": {
"list": "列表",
"actions": "操作",
"first_last": "第一个或最后一个",
"extend": "扩展选择",
"toggle": "选择或取消选择",
"select_all": "全选",
"clear": "清除选择或搜索",
"delete": "删除",
"search": "搜索",
"move": "下一个或上一个项目",
"move_grid": "网格中的相邻项目",
"open": "打开",
"up": "上一级文件夹",
"rename": "重命名",
"copy": "复制",
"cut": "剪切",
"paste": "粘贴"
},
"rename_success": "重命名成功",
"rename_error": "重命名失败",
"download_error": "下载失败",
"download_zip": "下载为 ZIP",
"zipping": "正在准备 {name}",
"download_canceled": "已取消下载",
"zip_error": "无法下载 {name}",
"not_available": "文件存储在此服务器上不可用",
"cancel": "取消",
"create": "创建",
"save": "保存",
"no_results": "没有与您的搜索匹配的文件",
"batch_delete_confirm_message": "确定要删除{count, plural, one {1 个项目} other {# 个项目}}吗?此操作无法撤销。",
"batch_delete_success": "{count, plural, one {已删除 1 个项目} other {已删除 # 个项目}}",
"grid_view": "网格视图",
"list_view": "列表视图",
"details": "详情",
@@ -3949,6 +4026,9 @@
"paste": "粘贴",
"move_success": "{count, plural, one {已移动 1 个项目} other {已移动 # 个项目}}",
"move_error": "移动失败",
"move_to": "移动到…",
"move_to_title": "移动到",
"move_here": "移到此处",
"paste_success": "粘贴成功",
"paste_error": "粘贴失败",
"new_text_file": "新建文本文件",
@@ -4029,7 +4109,10 @@
"zoom_out": "缩小",
"zoom_reset": "适应屏幕",
"rotate": "旋转",
"close_preview": "关闭"
"close_preview": "关闭",
"previous_file": "上一个文件",
"next_file": "下一个文件",
"loading": "正在加载…"
},
"smime": {
"your_certificates": "您的证书",
+10
View File
@@ -1434,3 +1434,13 @@ describe('contact-store', () => {
});
});
});
describe('contact display name', () => {
it('shows the name an imported card gives itself (FN), not only first and last name', async () => {
const { getContactDisplayName } = await import('../contact-store');
expect(getContactDisplayName({
id: 'x', addressBookIds: {},
name: { full: 'UX-cf Jürgen Müller', components: [{ kind: 'given', value: 'Jürgen' }, { kind: 'surname', value: 'Müller' }] },
} as never)).toBe('UX-cf Jürgen Müller');
});
});
+176
View File
@@ -0,0 +1,176 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useFileStore, deletesToTrash } from '../file-store';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { FileNode } from '@/lib/jmap/types';
import { TRASH_LOCATION, parseTrashBatchName, trashBatchName } from '@/lib/files-trash';
// An in-memory FileNode server: a folder is a node without a blob, and
// destroying a node destroys what is below it, as on Stalwart.
function makeServer(initial: FileNode[]) {
let nodes = initial.map(n => ({ ...n }));
let seq = 0;
const now = () => new Date().toISOString();
const client = {
getCapabilities: () => ({}),
async listAllFileNodes() { return nodes.map(n => ({ ...n })); },
async listAllFileNodesAcrossAccounts() { return nodes.map(n => ({ ...n })); },
async createFileDirectory(name: string, parentId: string | null) {
const node: FileNode = { id: `n${++seq}`, parentId, name, type: '', blobId: null, size: 0, created: now(), modified: now() };
nodes.push(node);
return { ...node };
},
async updateFileNode(id: string, patch: Partial<FileNode>) {
Object.assign(nodes.find(n => n.id === id)!, patch);
},
async updateFileNodes(updates: Record<string, Partial<FileNode>>) {
const updated: string[] = [];
for (const [id, patch] of Object.entries(updates)) {
const node = nodes.find(n => n.id === id);
if (!node) continue;
const clash = nodes.some(n => n.id !== id && (n.parentId ?? null) === (patch.parentId !== undefined ? patch.parentId : node.parentId) && n.name === (patch.name ?? node.name));
if (clash) continue;
Object.assign(node, patch);
updated.push(id);
}
return { updated, notUpdated: {} };
},
async destroyFileNodes(ids: string[]) {
const gone = new Set(ids);
let grew = true;
while (grew) {
grew = false;
for (const n of nodes) {
if (n.parentId && gone.has(n.parentId) && !gone.has(n.id)) { gone.add(n.id); grew = true; }
}
}
nodes = nodes.filter(n => !gone.has(n.id));
return { destroyed: [...gone], notDestroyed: [] };
},
nodes: () => nodes,
};
return client as unknown as IJMAPClient & { nodes: () => FileNode[] };
}
const dir = (id: string, name: string, parentId: string | null): FileNode => ({
id, parentId, name, type: '', blobId: null, size: 0, created: '', modified: '',
});
const file = (id: string, name: string, parentId: string | null): FileNode => ({
id, parentId, name, type: 'text/plain', blobId: `b-${id}`, size: 3, created: '', modified: '',
});
const names = () => useFileStore.getState().resources.map(r => r.name);
describe('file-store trash', () => {
beforeEach(() => {
localStorage.clear();
useFileStore.setState({
client: null, currentParentId: null, currentPath: '/', pathStack: [{ id: null, name: '' }],
resources: [], selectedResources: new Set(), clipboard: null, lastAction: null,
favorites: [], recentFiles: [], trashCount: 0,
});
});
async function open(nodes: FileNode[], folder: string | null = null) {
const server = makeServer(nodes);
useFileStore.getState().initClient(server);
await useFileStore.getState().navigate(folder, folder ?? undefined);
return server;
}
it('moves deleted items into the trash instead of destroying them, and hides the trash', async () => {
const server = await open([dir('docs', 'Documents', null), file('a', 'a.txt', 'docs'), file('b', 'b.txt', 'docs')], 'docs');
expect(await useFileStore.getState().trashResources(['a.txt'])).toBe(1);
expect(server.nodes().find(n => n.id === 'a')).toBeDefined();
expect(names()).toEqual(['b.txt']);
expect(useFileStore.getState().trashCount).toBe(1);
await useFileStore.getState().navigate(null);
expect(names()).toEqual(['Documents']);
expect(useFileStore.getState().allFolders.map(f => f.name)).toEqual(['Documents']);
});
it('records where an item came from in the name of its delete', () => {
expect(parseTrashBatchName(trashBatchName('docs', 1730112000000))).toEqual({ deletedAt: 1730112000000, parentId: 'docs' });
expect(parseTrashBatchName(trashBatchName(null, 1730112000000))).toEqual({ deletedAt: 1730112000000, parentId: null });
expect(parseTrashBatchName('Holiday photos')).toBeNull();
});
it('puts the items back on undo and leaves no empty folder in the trash', async () => {
const server = await open([dir('docs', 'Documents', null), file('a', 'a.txt', 'docs')], 'docs');
await useFileStore.getState().trashResources(['a.txt']);
await useFileStore.getState().undoLastAction();
expect(names()).toEqual(['a.txt']);
expect(useFileStore.getState().trashCount).toBe(0);
const trash = server.nodes().find(n => n.name === '.Trash')!;
expect(server.nodes().filter(n => n.parentId === trash.id)).toEqual([]);
});
it('lists the trash with where each item came from, and restores it there', async () => {
const server = await open([dir('docs', 'Documents', null), file('a', 'a.txt', 'docs')], 'docs');
await useFileStore.getState().trashResources(['a.txt']);
await useFileStore.getState().navigate(TRASH_LOCATION);
const [item] = useFileStore.getState().resources;
expect(item.name).toBe('a.txt');
expect(item.trash?.fromParentId).toBe('docs');
expect(useFileStore.getState().currentPath).toBe('/.Trash');
expect(await useFileStore.getState().restoreResources(['a.txt'])).toBe(1);
expect(server.nodes().find(n => n.id === 'a')?.parentId).toBe('docs');
expect(names()).toEqual([]);
});
it('restores to the root when the folder is gone, under a new name when the old one is taken', async () => {
const server = await open([dir('docs', 'Documents', null), file('a', 'a.txt', 'docs'), file('top', 'a.txt', null)], 'docs');
await useFileStore.getState().trashResources(['a.txt']);
await server.destroyFileNodes(['docs']);
await useFileStore.getState().navigate(TRASH_LOCATION);
await useFileStore.getState().restoreResources(['a.txt']);
const restored = server.nodes().find(n => n.id === 'a')!;
expect(restored.parentId).toBeNull();
expect(restored.name).toBe('a (1).txt');
});
it('deletes items for good from the trash, and empties it', async () => {
const server = await open([file('a', 'a.txt', null), file('b', 'b.txt', null)]);
await useFileStore.getState().trashResources(['a.txt']);
await useFileStore.getState().trashResources(['b.txt']);
await useFileStore.getState().navigate(TRASH_LOCATION);
expect(await useFileStore.getState().purgeResources(['a.txt'])).toBe(1);
expect(server.nodes().find(n => n.id === 'a')).toBeUndefined();
expect(names()).toEqual(['b.txt']);
await useFileStore.getState().emptyTrash();
expect(server.nodes()).toEqual([]);
expect(useFileStore.getState().trashCount).toBe(0);
});
it('trashes a folder from the tree and steps out of it', async () => {
const server = await open([dir('docs', 'Documents', null), dir('sub', 'Sub', 'docs')], 'docs');
await useFileStore.getState().navigate('sub', 'Sub');
expect(await useFileStore.getState().trashNode('docs')).toBe(true);
expect(useFileStore.getState().currentParentId).toBeNull();
expect(server.nodes().find(n => n.id === 'docs')).toBeDefined();
expect(names()).toEqual([]);
});
it('has no trash in a drive someone shared', () => {
expect(deletesToTrash(null)).toBe(true);
expect(deletesToTrash('docs')).toBe(true);
expect(deletesToTrash('e:team')).toBe(false);
expect(deletesToTrash(TRASH_LOCATION)).toBe(false);
});
it('moves a folder of the tree by id, and not into itself', async () => {
const server = await open([dir('docs', 'Documents', null), dir('sub', 'Sub', 'docs'), dir('arch', 'Archive', null)]);
expect(await useFileStore.getState().moveNode('docs', 'sub')).toBe(false);
expect(await useFileStore.getState().moveNode('docs', 'arch')).toBe(true);
expect(server.nodes().find(n => n.id === 'docs')?.parentId).toBe('arch');
await useFileStore.getState().undoLastAction();
expect(server.nodes().find(n => n.id === 'docs')?.parentId).toBeNull();
});
});
+17
View File
@@ -1019,3 +1019,20 @@ describe('file-store actions by id', () => {
expect(useFileStore.getState().recentFiles.map(r => r.id)).toEqual(['2']);
});
});
describe('file-store preview content', () => {
it('reads a file through the client, so the demo, which has no download URL, can preview it', async () => {
const client = makeMockClient([file('a', 'notes.txt', null)]);
Object.assign(client, {
getBlobDownloadUrl: () => 'data:application/octet-stream;demo-blob=b-a',
getAuthHeader: () => '',
fetchBlob: async (blobId: string) => new Blob([`content of ${blobId}`], { type: 'text/plain' }),
});
useFileStore.getState().initClient(client);
await useFileStore.getState().navigate(null);
const { blob, contentType } = await useFileStore.getState().getFileContent('notes.txt');
expect(await blob.text()).toBe('content of b-a');
expect(contentType).toBe('text/plain');
});
});
+50 -3
View File
@@ -178,15 +178,16 @@ function resolveRawAddressBookIds(
export function getContactDisplayName(contact: ContactCard): string {
if (contact.name) {
// Try given + surname from components first
// The card's own display name (RFC 9553 name.full, vCard FN) first: an
// imported "Dr. Jürgen Müller" stays that, and this app's form keeps it
// in step with the parts it writes.
if (contact.name.full?.trim()) return contact.name.full.trim();
if (contact.name.components && contact.name.components.length > 0) {
const given = contact.name.components.find(c => c.kind === 'given')?.value || '';
const surname = contact.name.components.find(c => c.kind === 'surname')?.value || '';
const full = [given, surname].filter(Boolean).join(' ');
if (full) return full;
}
// Fall back to name.full (RFC 9553 - used by Stalwart and other JMAP servers)
if (contact.name.full) return contact.name.full;
}
if (contact.nicknames) {
const nick = Object.values(contact.nicknames)[0];
@@ -463,6 +464,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 +1410,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;
+310 -18
View File
@@ -9,6 +9,11 @@ import { imageBlobUrl } from '@/lib/file-preview';
import { fileHooks } from '@/lib/plugin-hooks';
import { fileToView } from '@/lib/plugin-projection';
import type { FileResourceView } from '@/lib/plugin-types';
import {
TRASH_FOLDER_NAME, TRASH_LOCATION, TRASH_PATH, findTrashFolder, isTrashLocation, trashBatchName,
trashSubtreeIds, trashedItems,
} from '@/lib/files-trash';
import { buildZip, planZip, saveBlob, type ZipProgress } from '@/lib/files-zip';
export interface FileResource {
id: string;
@@ -31,6 +36,8 @@ export interface FileResource {
isShared?: boolean;
ownerAccountId?: string;
ownerName?: string;
/** Set for an item in the trash: when it was deleted and where from (null: the root). */
trash?: { deletedAt: string | null; fromParentId: string | null; batchId: string | null };
}
interface UploadProgress {
@@ -73,6 +80,8 @@ interface UndoAction {
type: 'rename' | 'move';
entries: { id: string; from: Partial<Pick<FileNode, 'name' | 'parentId'>>; to: Partial<Pick<FileNode, 'name' | 'parentId'>> }[];
sourceParentId: string | null;
/** A delete into the trash: the trash folder its items went into, removed on undo. */
trashBatchId?: string;
}
interface FileState {
@@ -105,6 +114,8 @@ interface FileState {
allFolders: FileResource[];
/** False until the first listing for the attached account has finished. */
hasListed: boolean;
/** How many items the trash holds, from the last listing. */
trashCount: number;
// Actions
initClient: (client: IJMAPClient, accountId?: string | null) => void;
@@ -134,6 +145,11 @@ interface FileState {
renameResource: (oldName: string, newName: string) => Promise<boolean>;
downloadResource: (name: string) => Promise<void>;
downloadResources: (names: string[]) => Promise<void>;
/**
* Download items by id as one zip built in the browser: files as they are,
* folders with everything in them. Rejects with an AbortError when canceled.
*/
downloadZip: (ids: string[], zipName: string, options?: { signal?: AbortSignal; onProgress?: (p: ZipProgress) => void }) => Promise<void>;
getImageUrl: (name: string) => Promise<string>;
getFileContent: (name: string) => Promise<{ blob: Blob; contentType: string }>;
createTextFile: (name: string) => Promise<void>;
@@ -142,6 +158,8 @@ interface FileState {
moveToParent: (names: string[]) => Promise<boolean>;
/** Move listed items into any folder by id (null = the root), e.g. a tree or breadcrumb drop. */
moveToFolderId: (names: string[], targetId: string | null) => Promise<boolean>;
/** Move any node by id into a folder (null = the root), e.g. a folder of the tree. */
moveNode: (id: string, targetId: string | null) => Promise<boolean>;
cutResources: (names: string[]) => void;
copyResources: (names: string[]) => void;
pasteResources: () => Promise<boolean>;
@@ -160,6 +178,19 @@ interface FileState {
renameNode: (id: string, newName: string) => Promise<boolean>;
/** Delete any node by id; leaves the folder on screen if it was inside it. */
deleteNode: (id: string) => Promise<boolean>;
/**
* Move items of the folder on screen into the trash; how many went (0 when
* a plugin stopped it). Undo puts them back.
*/
trashResources: (names: string[]) => Promise<number>;
/** Move any node by id into the trash, like deleteNode. */
trashNode: (id: string) => Promise<boolean>;
/** Put items listed in the trash back where they came from (the root when that is gone). */
restoreResources: (names: string[]) => Promise<number>;
/** Delete items listed in the trash for good. */
purgeResources: (names: string[]) => Promise<number>;
/** Delete everything in the trash for good. */
emptyTrash: () => Promise<void>;
/** Download a file by id, wherever it lives. */
downloadNode: (id: string) => Promise<void>;
removeRecentFile: (id: string) => void;
@@ -284,11 +315,45 @@ export function linkedPreviewResource(
: resources.find(r => r.name === link.name);
}
// A shared-files page lists files; nothing can be put into it.
// A shared-files page lists files; nothing can be put into it, nor into the
// trash other than by deleting.
function assertCanAddTo(parentId: string | null): void {
if (sharedFilesOwner(parentId) !== null) {
throw jmapError('Nothing can be added to a list of shared files', { type: 'forbidden' });
}
if (isTrashLocation(parentId)) {
throw jmapError('Nothing can be added to the trash', { type: 'forbidden' });
}
}
/**
* Whether what is deleted in a folder goes to the trash: in the user's own
* drive. A drive someone shared has no trash of the user's, and what is
* deleted there is gone.
*/
export function deletesToTrash(parentId: string | null): boolean {
return !isCrossAccountId(parentId) && !isTrashLocation(parentId);
}
/** The trash folder, created on the first delete. */
async function ensureTrashFolder(client: IJMAPClient, nodes: FileNode[]): Promise<FileNode> {
return findTrashFolder(nodes) ?? client.createFileDirectory(TRASH_FOLDER_NAME, null);
}
/** Remove the trash folders of deletes whose items have all left the trash. */
async function removeEmptyTrashBatches(client: IJMAPClient, gone: FileResource[]): Promise<void> {
const batchIds = [...new Set(gone.map(r => r.trash?.batchId).filter((id): id is string => !!id))];
if (batchIds.length === 0) return;
const nodes = await client.listAllFileNodes();
const empty = batchIds.filter(id => !nodes.some(n => n.parentId === id));
if (empty.length > 0) await client.destroyFileNodes(empty);
}
/** Throw the first refusal when nothing of an update went through. */
function assertUpdated(result: { updated: string[]; notUpdated: Record<string, string> }): void {
if (result.updated.length > 0) return;
const [reason] = Object.values(result.notUpdated);
throw jmapError(reason || 'The server refused the change', { type: 'forbidden' });
}
// The top of what other accounts share with the user: shared nodes whose
@@ -535,6 +600,7 @@ export const useFileStore = create<FileState>((set, get) => ({
sharedRoots: [],
allFolders: [],
hasListed: false,
trashCount: 0,
favorites: (() => {
try { return JSON.parse(localStorage.getItem('files-favorites') || '[]'); } catch { return []; }
})(),
@@ -790,6 +856,8 @@ export const useFileStore = create<FileState>((set, get) => ({
} else if (sharedFilesOwner(parentId) !== null) {
// A shared-files page sits right below Home, whatever was open before.
newStack = [{ id: null, name: '' }, { id: parentId, name: name || parentId }];
} else if (isTrashLocation(parentId)) {
newStack = [{ id: null, name: '' }, { id: TRASH_LOCATION, name: TRASH_FOLDER_NAME }];
} else {
// Check if navigating to a parent in the stack
const existingIdx = pathStack.findIndex(s => s.id === parentId);
@@ -816,25 +884,39 @@ export const useFileStore = create<FileState>((set, get) => ({
? await client.listAllFileNodesAcrossAccounts()
: await client.listAllFileNodes();
const owner = sharedFilesOwner(parentId);
const resources = owner !== null
? withUniqueNames(sortResources(
sharedRootsOf(allNodes).filter(n => n.accountId === owner && !isFolder(n)).map(nodeToResource),
))
// The owner's folders on the way down to a shared node aren't shown
// (see sharedRootsOf); the user can do nothing in them.
: sortResources(childrenOf(allNodes, parentId).filter(n => !n.discoverableOnly).map(nodeToResource));
// The trash and what is in it stay out of every listing but its own.
const trash = findTrashFolder(allNodes);
const inTrash = trashSubtreeIds(allNodes, trash?.id);
const trashed = trashedItems(allNodes, trash);
const resources = isTrashLocation(parentId)
? withUniqueNames(sortResources(trashed.map(item => ({
...nodeToResource(item.node),
trash: {
deletedAt: item.deletedAt !== null ? new Date(item.deletedAt).toISOString() : null,
fromParentId: item.fromParentId,
batchId: item.batchId,
},
}))))
: owner !== null
? withUniqueNames(sortResources(
sharedRootsOf(allNodes).filter(n => n.accountId === owner && !isFolder(n)).map(nodeToResource),
))
// The owner's folders on the way down to a shared node aren't shown
// (see sharedRootsOf); the user can do nothing in them.
: sortResources(childrenOf(allNodes, parentId).filter(n => !n.discoverableOnly && !inTrash.has(n.id)).map(nodeToResource));
// The owner's folders above a shared one stay out of the tree as well.
const allFolders = allNodes.filter(n => isFolder(n) && !n.discoverableOnly).map(nodeToResource);
const allFolders = allNodes.filter(n => isFolder(n) && !n.discoverableOnly && !inTrash.has(n.id)).map(nodeToResource);
// Prune recent files whose backing node no longer exists on the server
// or is in the trash.
const { recentFiles } = get();
const existingIds = new Set(allNodes.map(n => n.id));
const existingIds = new Set(allNodes.filter(n => !inTrash.has(n.id)).map(n => n.id));
const prunedRecent = recentFiles.filter(r => existingIds.has(r.id));
if (prunedRecent.length !== recentFiles.length) {
try { localStorage.setItem('files-recent-files', JSON.stringify(prunedRecent)); } catch { /* ignore */ }
set({ resources, allFolders, recentFiles: prunedRecent, isLoading: false, hasListed: true });
set({ resources, allFolders, trashCount: trashed.length, recentFiles: prunedRecent, isLoading: false, hasListed: true });
} else {
set({ resources, allFolders, isLoading: false, hasListed: true });
set({ resources, allFolders, trashCount: trashed.length, isLoading: false, hasListed: true });
}
if (opensFolder) void fileHooks.onFileNavigate.emit({ path: newPath });
} catch (error) {
@@ -853,6 +935,10 @@ export const useFileStore = create<FileState>((set, get) => ({
await navigate(null);
return;
}
if (path === TRASH_PATH) {
await navigate(TRASH_LOCATION, TRASH_FOLDER_NAME);
return;
}
// Try to match the path against the current pathStack
const segments = path.split('/').filter(Boolean);
const targetDepth = segments.length;
@@ -1210,6 +1296,18 @@ export const useFileStore = create<FileState>((set, get) => ({
}
},
downloadZip: async (ids, zipName, options = {}) => {
const { client } = get();
if (!client) return;
const nodes = ids.some(isCrossAccountId)
? await client.listAllFileNodesAcrossAccounts()
: await client.listAllFileNodes();
const plan = planZip(nodes, ids);
// A node of a drive shared with the user is fetched from its owner's account.
const blob = await buildZip(plan, (node) => client.fetchBlob(node.blobId!, node.name, node.type, node.accountId), options);
saveBlob(blob, zipName);
},
getImageUrl: async (name: string) => {
const { client, resources } = get();
if (!client) throw new Error('No client');
@@ -1227,12 +1325,8 @@ export const useFileStore = create<FileState>((set, get) => ({
const resource = resources.find(r => r.name === name);
if (!resource?.blobId) throw new Error('No blob');
const url = client.getBlobDownloadUrl(resource.blobId, resource.name, resource.contentType);
const response = await fetch(url, {
headers: { 'Authorization': client.getAuthHeader() },
});
if (!response.ok) throw new Error(`Failed to fetch file: ${response.status}`);
const blob = await response.blob();
// Through the client, like getImageUrl: the demo has no download URL to fetch.
const blob = await client.fetchBlob(resource.blobId, resource.name, resource.contentType);
return { blob, contentType: resource.contentType || 'application/octet-stream' };
},
@@ -1351,6 +1445,28 @@ export const useFileStore = create<FileState>((set, get) => ({
return true;
},
moveNode: async (id: string, targetId: string | null) => {
const { client, allFolders, resources, refresh } = get();
if (!client) return false;
assertCanAddTo(targetId);
// A folder must not move into itself or anything below it.
const parentOf = new Map(allFolders.map(f => [f.id, f.parentId ?? null]));
for (let cur = targetId; cur != null; cur = parentOf.get(cur) ?? null) {
if (cur === id) return false;
}
const known = allFolders.find(f => f.id === id) ?? resources.find(r => r.id === id);
const from = known?.parentId ?? null;
if (known && from === targetId) return false;
const view = known ? resourceToView(known, parentPath(allFolders, from)) : unknownNodeView(id);
const toPath = parentPath(allFolders, targetId);
if (!(await fileHooks.onBeforeFileMove.intercept({ files: [view], toPath }))) return false;
await client.updateFileNode(id, { parentId: targetId });
void fileHooks.onFileMove.emit({ files: [view], toPath });
set({ lastAction: { type: 'move', entries: [{ id, from: { parentId: from }, to: { parentId: targetId } }], sourceParentId: null } });
await refresh();
return true;
},
cutResources: (names: string[]) => {
const { currentPath, currentParentId, resources } = get();
const picked = names
@@ -1615,6 +1731,180 @@ export const useFileStore = create<FileState>((set, get) => ({
return true;
},
trashResources: async (names: string[]) => {
const { client, resources, currentParentId, currentPath, recentFiles, favorites, refresh } = get();
if (!client) return 0;
const items = names
.map(name => resources.find(r => r.name === name))
.filter((r): r is FileResource => !!r);
if (items.length === 0) return 0;
const files = items.map(r => resourceToView(r, currentPath));
if (!(await fileHooks.onBeforeFileDelete.intercept({ files }))) return 0;
const trash = await ensureTrashFolder(client, await client.listAllFileNodes());
const batch = await client.createFileDirectory(trashBatchName(currentParentId), trash.id);
let result: { updated: string[]; notUpdated: Record<string, string> };
try {
result = await client.updateFileNodes(Object.fromEntries(items.map(r => [r.id, { parentId: batch.id }])));
assertUpdated(result);
} catch (err) {
await client.destroyFileNodes([batch.id]).catch(() => undefined);
throw err;
}
const moved = new Set(result.updated);
const trashed = items.filter(r => moved.has(r.id));
void fileHooks.onAfterFileDelete.emit({ files: files.filter(f => moved.has(f.id)) });
// Like a delete: recent files and stars of what went drop off.
const gonePaths = trashed.filter(r => r.isDirectory).map(r => joinPath(currentPath, r.name));
const nextFavorites = favorites.filter(f => !gonePaths.some(path => isUnder(f, path)));
const nextRecent = recentFiles.filter(r => !moved.has(r.id));
set({
favorites: nextFavorites,
recentFiles: nextRecent,
selectedResources: new Set(),
lastAction: {
type: 'move',
entries: trashed.map(r => ({ id: r.id, from: { parentId: r.parentId ?? null }, to: { parentId: batch.id } })),
sourceParentId: currentParentId,
trashBatchId: batch.id,
},
});
try {
localStorage.setItem('files-favorites', JSON.stringify(nextFavorites));
localStorage.setItem('files-recent-files', JSON.stringify(nextRecent));
} catch { /* ignore */ }
await refresh();
return trashed.length;
},
trashNode: async (id: string) => {
const { client, allFolders, resources, favorites, recentFiles, pathStack, navigate, refresh } = get();
if (!client) return false;
const nodes = await client.listAllFileNodes();
const node = nodes.find(n => n.id === id);
if (!node) return false;
const path = folderPath(allFolders, id);
const known = allFolders.find(f => f.id === id) ?? resources.find(r => r.id === id);
const files = [known ? resourceToView(known, parentPath(allFolders, known.parentId)) : unknownNodeView(id)];
if (!(await fileHooks.onBeforeFileDelete.intercept({ files }))) return false;
const trash = await ensureTrashFolder(client, nodes);
const batch = await client.createFileDirectory(trashBatchName(node.parentId ?? null), trash.id);
try {
assertUpdated(await client.updateFileNodes({ [id]: { parentId: batch.id } }));
} catch (err) {
await client.destroyFileNodes([batch.id]).catch(() => undefined);
throw err;
}
void fileHooks.onAfterFileDelete.emit({ files });
const nextFavorites = path ? favorites.filter(f => !isUnder(f, path)) : favorites;
const nextRecent = recentFiles.filter(r => r.id !== id);
set({
favorites: nextFavorites,
recentFiles: nextRecent,
lastAction: {
type: 'move',
entries: [{ id, from: { parentId: node.parentId ?? null }, to: { parentId: batch.id } }],
sourceParentId: node.parentId ?? null,
trashBatchId: batch.id,
},
});
try {
localStorage.setItem('files-favorites', JSON.stringify(nextFavorites));
localStorage.setItem('files-recent-files', JSON.stringify(nextRecent));
} catch { /* ignore */ }
// Standing in the deleted folder or below it: step out to its parent.
const idx = pathStack.findIndex(s => s.id === id);
if (idx > 0) {
const parent = pathStack[idx - 1];
set({ pathStack: pathStack.slice(0, idx) });
await navigate(parent.id, parent.name);
return true;
}
await refresh();
return true;
},
restoreResources: async (names: string[]) => {
const { client, resources, refresh } = get();
if (!client) return 0;
const items = names
.map(name => resources.find(r => r.name === name && r.trash))
.filter((r): r is FileResource => !!r);
if (items.length === 0) return 0;
const nodes = await client.listAllFileNodes();
const inTrash = trashSubtreeIds(nodes, findTrashFolder(nodes)?.id);
const folderIds = new Set(nodes.filter(n => isFolder(n) && !inTrash.has(n.id)).map(n => n.id));
// Names taken in each folder, so a restored item never clashes.
const taken = new Map<string | null, Set<string>>();
const namesIn = (parentId: string | null) => {
let set = taken.get(parentId);
if (!set) {
set = new Set(childrenOf(nodes, parentId).filter(n => !inTrash.has(n.id)).map(n => n.name));
taken.set(parentId, set);
}
return set;
};
const updates: Record<string, Partial<Pick<FileNode, 'name' | 'parentId'>>> = {};
const targets = new Map<string, string | null>();
for (const item of items) {
const from = item.trash?.fromParentId ?? null;
// A folder that is gone (or itself in the trash) gives the item back to the root.
const target = from !== null && folderIds.has(from) ? from : null;
const used = namesIn(target);
const name = getUniqueName(item.serverName, used);
used.add(name);
updates[item.id] = name === item.serverName ? { parentId: target } : { parentId: target, name };
targets.set(item.id, target);
}
const result = await client.updateFileNodes(updates);
assertUpdated(result);
const restored = new Set(result.updated);
const allFolders = nodes.filter(n => isFolder(n) && !inTrash.has(n.id)).map(nodeToResource);
const byTarget = new Map<string, FileResourceView[]>();
for (const item of items.filter(r => restored.has(r.id))) {
const toPath = parentPath(allFolders, targets.get(item.id) ?? null);
const list = byTarget.get(toPath) ?? [];
list.push(resourceToView(item, toPath));
byTarget.set(toPath, list);
}
for (const [toPath, files] of byTarget) void fileHooks.onFileMove.emit({ files, toPath });
await removeEmptyTrashBatches(client, items.filter(r => restored.has(r.id)));
set({ selectedResources: new Set() });
await refresh();
return restored.size;
},
purgeResources: async (names: string[]) => {
const { client, resources, refresh } = get();
if (!client) return 0;
const items = names
.map(name => resources.find(r => r.name === name && r.trash))
.filter((r): r is FileResource => !!r);
if (items.length === 0) return 0;
// The server removes what is inside a folder with it.
const result = await client.destroyFileNodes(items.map(r => r.id));
const destroyed = new Set(result.destroyed);
await removeEmptyTrashBatches(client, items.filter(r => destroyed.has(r.id)));
set({ selectedResources: new Set() });
await refresh();
return items.filter(r => destroyed.has(r.id)).length;
},
emptyTrash: async () => {
const { client, refresh } = get();
if (!client) return;
const trash = findTrashFolder(await client.listAllFileNodes());
// The folder goes with everything in it; the next delete makes a new one.
if (trash) await client.destroyFileNodes([trash.id]);
set({ selectedResources: new Set() });
await refresh();
},
downloadNode: async (id: string) => {
const { client } = get();
if (!client) return;
@@ -1641,6 +1931,8 @@ export const useFileStore = create<FileState>((set, get) => ({
for (const entry of lastAction.entries) {
await client.updateFileNode(entry.id, entry.from);
}
// A delete's folder in the trash is empty again.
if (lastAction.trashBatchId) await client.destroyFileNodes([lastAction.trashBatchId]);
set({ lastAction: null });
void fileHooks.onFileUndo.emit({
action: {
+12
View File
@@ -13,6 +13,7 @@ import { settingsHooks } from '@/lib/plugin-hooks';
import { generateAccountId } from '@/lib/account-utils';
import { subscriptionOwnerFor } from '@/lib/calendar-subscription-sync';
import type { FolderCountMode } from '@/lib/folder-counts';
import { DEFAULT_FILES_SETTINGS, normalizeFilesSettings, type FilesSettings } from '@/lib/files-settings';
import { orderForMailbox, sanitizeSortLevels, type MessageListOrderScope, type SortLevel } from '@/lib/message-list-order';
import {
DEFAULT_SUB_ADDRESS_DELIMITER,
@@ -587,6 +588,9 @@ interface SettingsState {
// family members sit together (#963).
sortContactsByLastName: boolean;
// Files: view, sort and icons (Settings > Files)
filesSettings: FilesSettings;
// Email Notifications
emailNotificationsEnabled: boolean;
emailNotificationSound: boolean;
@@ -886,6 +890,9 @@ const DEFAULT_SETTINGS = {
groupContactsByLetter: true,
sortContactsByLastName: false,
// Files
filesSettings: DEFAULT_FILES_SETTINGS,
// Email Notifications
emailNotificationsEnabled: true,
emailNotificationSound: true,
@@ -1238,6 +1245,7 @@ export const useSettingsStore = create<SettingsState>()(
sharedCalendarColors: state.sharedCalendarColors,
groupContactsByLetter: state.groupContactsByLetter,
sortContactsByLastName: state.sortContactsByLastName,
filesSettings: state.filesSettings,
expandedFilterView: state.expandedFilterView,
showTimeInMonthView: state.showTimeInMonthView,
showWeekNumbers: state.showWeekNumbers,
@@ -1418,6 +1426,10 @@ export const useSettingsStore = create<SettingsState>()(
put(key, { notificationMutes: normalizeNotificationMutes(settings[key]) });
return;
}
if (key === 'filesSettings') {
put(key, { filesSettings: normalizeFilesSettings(settings[key]) });
return;
}
// The list order is sent to the server as a sort array, so an
// unknown criterion from a newer/older client must never get
// through (an unsupportedSort refusal empties the folder).