diff --git a/app/(main)/admin/__tests__/branding-placeholders.test.tsx b/app/(main)/admin/__tests__/branding-placeholders.test.tsx new file mode 100644 index 000000000..9d75a6828 --- /dev/null +++ b/app/(main)/admin/__tests__/branding-placeholders.test.tsx @@ -0,0 +1,50 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useAdminUnsavedStore } from '@/stores/admin-unsaved-store'; + +// Branding per domain: the image fields said "Enter URL (uploads only for +// default scope)", cut off in the field and wrong for a single domain, which +// can upload. Only a wildcard domain takes a URL alone. + +vi.mock('@/lib/browser-navigation', () => ({ + apiFetch: vi.fn(async () => new Response(JSON.stringify({ + domainBranding: { source: 'admin', value: [{ host: 'mail.example.com' }, { host: '*.example.com' }] }, + }), { status: 200, headers: { 'content-type': 'application/json' } })), + withBasePath: (p: string) => p, +})); +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() })); +vi.mock('@/stores/toast-store', () => ({ toast, useToastStore: () => [] })); +vi.mock('next/link', () => ({ default: ({ children }: { children: React.ReactNode }) => children })); + +import { BrandingPage } from '../_pages/branding'; + +afterEach(() => { + useAdminUnsavedStore.setState({ dirty: {}, pendingLeave: null, pendingScope: null }); +}); + +describe('admin branding image fields', () => { + it('offers upload for a single domain and a URL only for a wildcard, with the full hint as a title', async () => { + render(); + + fireEvent.click(await screen.findByRole('button', { name: 'mail.example.com' })); + const favicon = screen.getByRole('textbox', { name: 'Favicon' }); + expect(favicon.getAttribute('placeholder')).toBe('Enter URL or upload a file'); + expect(favicon.getAttribute('title')).toBe('Enter URL or upload a file'); + + fireEvent.click(screen.getByRole('button', { name: '*.example.com' })); + const wildcardFavicon = screen.getByRole('textbox', { name: 'Favicon' }); + expect(wildcardFavicon.getAttribute('placeholder')).toBe('Enter URL'); + expect(wildcardFavicon.getAttribute('title')).toBe('Enter URL'); + }); + + // R9: the result in the past tense, without "successfully". + it('reports an upload without "successfully"', async () => { + const { container } = render(); + await screen.findByRole('button', { name: 'mail.example.com' }); + const input = container.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(input, { target: { files: [new File(['x'], 'logo.png', { type: 'image/png' })] } }); + + await waitFor(() => expect(toast.success).toHaveBeenCalled()); + expect(toast.success.mock.calls[0][0]).toBe('logo.png uploaded'); + }); +}); diff --git a/app/(main)/admin/__tests__/save-bar-switches.test.tsx b/app/(main)/admin/__tests__/save-bar-switches.test.tsx index 23a5d4ed2..b0d8d63ee 100644 --- a/app/(main)/admin/__tests__/save-bar-switches.test.tsx +++ b/app/(main)/admin/__tests__/save-bar-switches.test.tsx @@ -81,4 +81,30 @@ describe('admin telemetry page', () => { expect.objectContaining({ url: '/api/admin/telemetry', body: { action: 'set-consent', consent: 'on' } }), ])); }); + + it('shows no next send and no consent date while heartbeats are off', async () => { + routes['/api/admin/telemetry'] = { + ...(routes['/api/admin/telemetry'] as object), + consent: 'off', consentedAt: '2026-10-01T10:00:00Z', lastSentAt: '2026-10-02T10:00:00Z', + nextScheduledAt: new Date(Date.now() - 7 * 3600_000).toISOString(), + }; + render(); + await screen.findByRole('switch', { name: 'Send anonymous heartbeats' }); + expect(screen.queryByText('Next scheduled')).toBeNull(); + expect(screen.queryByText('Consented at')).toBeNull(); + expect(screen.queryByText(/hours ago/)).toBeNull(); + expect(screen.getByText('Last sent')).toBeTruthy(); + }); + + it('shows a past next send as due, not as "hours ago", while heartbeats are on', async () => { + routes['/api/admin/telemetry'] = { + ...(routes['/api/admin/telemetry'] as object), + consent: 'on', consentedAt: '2026-10-01T10:00:00Z', + nextScheduledAt: new Date(Date.now() - 7 * 3600_000).toISOString(), + }; + render(); + await screen.findByText('Next scheduled'); + expect(screen.getByText('Due now')).toBeTruthy(); + expect(screen.getByText('Consented at')).toBeTruthy(); + }); }); diff --git a/app/(main)/admin/__tests__/unsaved-changes-guard.test.tsx b/app/(main)/admin/__tests__/unsaved-changes-guard.test.tsx index 7e5a92e95..160bd435a 100644 --- a/app/(main)/admin/__tests__/unsaved-changes-guard.test.tsx +++ b/app/(main)/admin/__tests__/unsaved-changes-guard.test.tsx @@ -21,27 +21,27 @@ describe('admin unsaved changes guard (#1255)', () => { expect(screen.queryByRole('alertdialog')).toBeNull(); }); - it('asks before leaving with unsaved edits; Stay keeps the admin on the page', () => { + it('asks before leaving with unsaved edits; Keep editing keeps the admin on the page', () => { render(<>); const action = vi.fn(); act(() => useAdminUnsavedStore.getState().requestLeave(action)); - expect(screen.getByRole('alertdialog')).toBeTruthy(); - fireEvent.click(screen.getByRole('button', { name: 'Stay' })); + expect(screen.getByRole('alertdialog', { name: 'Discard changes?' })).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Keep editing' })); expect(action).not.toHaveBeenCalled(); expect(screen.queryByRole('alertdialog')).toBeNull(); expect(useAdminUnsavedStore.getState().dirty).toEqual({ settings: true }); }); - it('Leave runs the action and drops the unsaved flag', () => { + it('Discard runs the action and drops the unsaved flag', () => { render(<>); const action = vi.fn(); act(() => useAdminUnsavedStore.getState().requestLeave(action)); - fireEvent.click(screen.getByRole('button', { name: 'Leave' })); + fireEvent.click(screen.getByRole('button', { name: 'Discard' })); expect(action).toHaveBeenCalledTimes(1); expect(useAdminUnsavedStore.getState().dirty).toEqual({}); }); - it('Escape closes the prompt like Stay', () => { + it('Escape closes the prompt like Keep editing', () => { render(<>); const action = vi.fn(); act(() => useAdminUnsavedStore.getState().requestLeave(action)); @@ -58,12 +58,12 @@ describe('admin unsaved changes guard (#1255)', () => { act(() => useAdminUnsavedStore.getState().setDirty('plugin-config', true)); act(() => useAdminUnsavedStore.getState().requestLeave(back, 'plugin-config')); - fireEvent.click(screen.getByRole('button', { name: 'Leave' })); + fireEvent.click(screen.getByRole('button', { name: 'Discard' })); expect(back).toHaveBeenCalledTimes(2); expect(useAdminUnsavedStore.getState().dirty).toEqual({ plugins: true }); }); - it('holds a link click until the admin picks Leave', () => { + it('holds a link click until the admin picks Discard', () => { const onLinkClick = vi.fn((e: React.MouseEvent) => e.preventDefault()); render( <> @@ -76,7 +76,7 @@ describe('admin unsaved changes guard (#1255)', () => { expect(onLinkClick).not.toHaveBeenCalled(); expect(screen.getByRole('alertdialog')).toBeTruthy(); - fireEvent.click(screen.getByRole('button', { name: 'Leave' })); + fireEvent.click(screen.getByRole('button', { name: 'Discard' })); expect(onLinkClick).toHaveBeenCalledTimes(1); }); @@ -103,7 +103,7 @@ describe('admin unsaved changes guard on Back', () => { await act(async () => { await done; }); }; - it('keeps the page and asks when Back is pressed with unsaved edits; Stay keeps guarding', async () => { + it('keeps the page and asks when Back is pressed with unsaved edits; Keep editing keeps guarding', async () => { window.history.pushState(null, '', '/admin/policy'); window.history.pushState(null, '', '/admin/telemetry'); render(<>); @@ -112,7 +112,7 @@ describe('admin unsaved changes guard on Back', () => { expect(window.location.pathname).toBe('/admin/telemetry'); expect(screen.getByRole('alertdialog')).toBeTruthy(); - fireEvent.click(screen.getByRole('button', { name: 'Stay' })); + fireEvent.click(screen.getByRole('button', { name: 'Keep editing' })); expect(useAdminUnsavedStore.getState().dirty).toEqual({ settings: true }); // Back asks again: the page is still guarded. @@ -121,14 +121,14 @@ describe('admin unsaved changes guard on Back', () => { expect(screen.getByRole('alertdialog')).toBeTruthy(); }); - it('Leave goes back to the previous page', async () => { + it('Discard goes back to the previous page', async () => { window.history.pushState(null, '', '/admin/policy'); window.history.pushState(null, '', '/admin/telemetry'); render(<>); await back(); const done = popped(); - fireEvent.click(screen.getByRole('button', { name: 'Leave' })); + fireEvent.click(screen.getByRole('button', { name: 'Discard' })); await act(async () => { await done; }); expect(window.location.pathname).toBe('/admin/policy'); expect(useAdminUnsavedStore.getState().dirty).toEqual({}); diff --git a/app/(main)/admin/_pages/branding.tsx b/app/(main)/admin/_pages/branding.tsx index 70446999e..7d0495b8d 100644 --- a/app/(main)/admin/_pages/branding.tsx +++ b/app/(main)/admin/_pages/branding.tsx @@ -216,7 +216,7 @@ export function BrandingPage() { if (res.ok) { const data = await res.json(); - setMessage({ type: 'success', text: `Uploaded ${file.name} successfully.` }); + setMessage({ type: 'success', text: `${file.name} uploaded` }); setEdits(prev => { const next = { ...prev }; delete next[slot]; @@ -396,7 +396,9 @@ export function BrandingPage() { accept={field.accept} value={currentValue(field.key)} badge={overrideBadge(field.key)} - placeholder={selectedHost ? 'Enter URL (uploads only for default scope)' : 'Enter URL or upload a file'} + // Files upload to the default scope and to exact hosts; a wildcard has + // no single host to serve them from (the scope line says so). + placeholder={wildcardScope ? 'Enter URL' : 'Enter URL or upload a file'} uploading={uploading === field.key} uploadDisabled={wildcardScope} uploaded={isUploadedFile(field.key)} @@ -417,6 +419,7 @@ export function BrandingPage() { value={currentValue(field.key)} onChange={(e) => handleChange(field.key, e.target.value)} placeholder={placeholder} + title={placeholder} aria-label={field.label} autoComplete="off" spellCheck={false} @@ -592,6 +595,7 @@ function ImageItem({ label, accept, value, badge, placeholder, uploading, upload value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} + title={placeholder} aria-label={label} autoComplete="off" spellCheck={false} diff --git a/app/(main)/admin/_pages/telemetry.tsx b/app/(main)/admin/_pages/telemetry.tsx index 73cdae984..7d03ca985 100644 --- a/app/(main)/admin/_pages/telemetry.tsx +++ b/app/(main)/admin/_pages/telemetry.tsx @@ -40,6 +40,13 @@ function timeAgo(iso: string | null): string { return `${days} days ago`; } +/** When the next heartbeat goes out; a time already past means it is due. */ +function nextRun(iso: string | null): string { + if (!iso) return '-'; + const at = new Date(iso); + return at.getTime() <= Date.now() ? 'Due now' : at.toLocaleString(); +} + export function TelemetryPage() { const [status, setStatus] = useState(null); const [loading, setLoading] = useState(true); @@ -177,12 +184,18 @@ export function TelemetryPage() { {timeAgo(status.lastSentAt)} - - {timeAgo(status.nextScheduledAt)} - - - {status.consentedAt ? new Date(status.consentedAt).toLocaleString() : '-'} - + {/* Only a running schedule has a next send and a consent date; with + heartbeats off they were stale ("Next scheduled: 7 hours ago"). */} + {isOn && ( + <> + + {nextRun(status.nextScheduledAt)} + + + {status.consentedAt ? new Date(status.consentedAt).toLocaleString() : '-'} + + + )} (null); useEffect(() => { @@ -124,10 +124,10 @@ export function UnsavedChangesGuard() { isOpen={isOpen} onClose={cancelLeave} onConfirm={confirmLeave} - title="Unsaved changes" - message="You have changes that haven't been saved. If you leave now, they will be lost." - cancelText="Stay" - confirmText="Leave" + title="Discard changes?" + message="Your changes haven't been saved." + cancelText="Keep editing" + confirmText="Discard" variant="destructive" /> ); diff --git a/components/admin/__tests__/admin-shell.test.tsx b/components/admin/__tests__/admin-shell.test.tsx index 6c4027212..a6f41684c 100644 --- a/components/admin/__tests__/admin-shell.test.tsx +++ b/components/admin/__tests__/admin-shell.test.tsx @@ -39,13 +39,13 @@ describe('AdminShell', () => { it('does not server-render the phone layout, which desktop would show until hydration', () => { const html = renderToString(shell({ authenticated: false, isStalwartAutoLogin: false })); - expect(html).not.toContain('Back to mail'); + expect(html).not.toContain('back_to_mail'); expect(html).not.toContain('Search admin'); }); it('draws the desktop sidebar once on the client', () => { render(shell({ authenticated: true, isStalwartAutoLogin: false })); - expect(screen.getByText('Back to Mail')).toBeTruthy(); + expect(screen.getByText('back_to_mail')).toBeTruthy(); }); it('hides the session links until the check says which kind of admin this is', () => { @@ -66,7 +66,17 @@ describe('AdminShell', () => { startPolling: () => {}, } as never); render(shell({ authenticated: true, isStalwartAutoLogin: false })); - const dot = screen.getAllByRole('img', { name: 'Update available' })[0]; - expect(dot.getAttribute('title')).toBe('Update available'); + const dot = screen.getAllByRole('img', { name: 'update_available' })[0]; + expect(dot.getAttribute('title')).toBe('update_available'); + }); + + // Translated like the same dot on the navigation rail (shell namespace). + it('names an important update in the language of the user', () => { + useUpdateStore.setState({ + status: { updateAvailable: true, severity: 'security', current: process.env.NEXT_PUBLIC_APP_VERSION ?? '', latest: '9.9.9' }, + startPolling: () => {}, + } as never); + render(shell({ authenticated: true, isStalwartAutoLogin: false })); + expect(screen.getAllByRole('img', { name: 'update_important' })[0].getAttribute('title')).toBe('update_important'); }); }); diff --git a/components/admin/admin-shell.tsx b/components/admin/admin-shell.tsx index e3a1e5b38..e0f6f0286 100644 --- a/components/admin/admin-shell.tsx +++ b/components/admin/admin-shell.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from 'react'; import { usePathname } from 'next/navigation'; +import { useTranslations } from 'next-intl'; import { ArrowLeft, ArrowUpCircle, @@ -80,6 +81,8 @@ const subscribeNothing = () => () => {}; */ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly, onLogout, children }: AdminShellProps) { const pathname = usePathname(); + const tShell = useTranslations('shell'); + const tSettings = useTranslations('settings'); const location = useAdminLocation(); const isDesktop = useIsDesktop(); // The server can't know the window width, so its HTML would be the phone @@ -183,7 +186,8 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly, if (id === 'version' && hasUpdate) { // A dot only: role="img" makes screen readers announce its label, which // they skip on a plain span; the title shows it on hover. - const label = updateImportant ? 'Important update available' : 'Update available'; + // The same words as the dot on the rail's Settings entry. + const label = tShell(updateImportant ? 'update_important' : 'update_available'); return ( @@ -384,7 +389,7 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly, className="flex h-9 w-full items-center rounded-md px-3 text-sm font-medium text-foreground transition-colors hover:bg-accent hover:text-accent-foreground" > - Back to Mail + {tSettings('back_to_mail')} diff --git a/components/calendar/__tests__/calendar-app.test.tsx b/components/calendar/__tests__/calendar-app.test.tsx index 843e003d9..4b36fb2f2 100644 --- a/components/calendar/__tests__/calendar-app.test.tsx +++ b/components/calendar/__tests__/calendar-app.test.tsx @@ -126,6 +126,18 @@ describe('CalendarApp keyboard shortcuts', () => { fireEvent.keyDown(document.body, { key: 'm', code: 'KeyM' }); expect(useCalendarStore.getState().viewMode).toBe('month'); }); + + // New 6: the preview opened on top of the open editor. + it('opens no hover preview while an editor is open', async () => { + setUp([makeEvent(), makeEvent({ id: 'e2', uid: 'u2', title: 'Retro', start: '2026-10-13T14:00:00' })]); + renderApp(); + fireEvent.click(screen.getAllByRole('button', { name: /Standup/ })[0]); + fireEvent.click(await screen.findByRole('button', { name: 'events.edit' })); + await screen.findByRole('dialog', { name: 'events.edit' }); + fireEvent.mouseEnter(screen.getAllByRole('button', { name: /Retro/ })[0]); + await new Promise((r) => setTimeout(r, 50)); + expect(screen.queryByRole('dialog', { name: 'Retro' })).toBeNull(); + }); }); @@ -340,6 +352,18 @@ describe('CalendarApp event search (CAL-16)', () => { expect(await screen.findByText('search.no_results', {}, { timeout: 3000 })).toBeInTheDocument(); }); + it('offers Clear search when nothing matches, back to the calendar with the cursor in the box', async () => { + renderApp(); + const box = screen.getByRole('searchbox', { name: 'search.title' }); + fireEvent.change(box, { target: { value: 'zzz' } }); + await screen.findByText('search.no_results', {}, { timeout: 3000 }); + const results = screen.getByTestId('calendar-search-results'); + fireEvent.click(within(results).getByRole('button', { name: 'search.clear' })); + expect(box).toHaveValue(''); + expect(document.activeElement).toBe(box); + await waitFor(() => expect(screen.queryByTestId('calendar-search-results')).toBeNull()); + }); + it('is in the command palette', () => { render(<>); const items = screen.getByTestId('palette').textContent ?? ''; @@ -378,7 +402,7 @@ describe('CalendarApp deleting', () => { renderApp(); fireEvent.click(screen.getAllByRole('button', { name: /Standup/ })[0]); fireEvent.click(within(screen.getByRole('dialog', { name: 'Standup' })).getByRole('button', { name: 'events.delete' })); - expect(await screen.findByRole('dialog', { name: 'delete_title' })).toBeInTheDocument(); + expect(await screen.findByRole('dialog', { name: 'delete_question' })).toBeInTheDocument(); expect(screen.queryByRole('alertdialog')).toBeNull(); }); diff --git a/components/calendar/__tests__/calendar-day-view-phone.test.tsx b/components/calendar/__tests__/calendar-day-view-phone.test.tsx new file mode 100644 index 000000000..c7365c4c4 --- /dev/null +++ b/components/calendar/__tests__/calendar-day-view-phone.test.tsx @@ -0,0 +1,66 @@ +import { render, screen, within } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { CalendarDayView } from '../calendar-day-view'; +import type { Calendar, CalendarEvent, CalendarTask } from '@/lib/jmap/types'; + +// CAL-27 follow-up: the phone day view drew tasks as 10px chips with a +// circle too small to tap, and a busy neighbouring day left an empty +// all-day block of ~150px above the hours of a day with none. + +const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[]; + +const holiday = { + id: 'ev-1', calendarIds: { 'cal-1': true }, title: 'Holiday', start: '2026-09-10T00:00:00', + duration: 'P1D', showWithoutTime: true, +} as unknown as CalendarEvent; + +const task = { + id: 't1', '@type': 'Task', title: 'Buy milk', calendarIds: { 'cal-1': true }, + progress: 'needs-action', priority: 0, due: '2026-09-09', showWithoutTime: true, +} as unknown as CalendarTask; + +function renderDay(isMobile: boolean, tasks: CalendarTask[] = [task]) { + return render( + , + ); +} + +describe('day view on a phone', () => { + it('gives each task a full row with a 44px target for the circle', () => { + renderDay(true); + const chip = document.querySelector('[data-calendar-task="t1"]')!; + expect(chip).toHaveClass('min-h-11', 'text-xs'); + expect(within(chip).getByRole('button', { name: /mark_complete|Mark as complete/i })).toHaveClass('w-11', 'self-stretch'); + }); + + it('keeps the small shared chip on a wide screen', () => { + renderDay(false); + const chip = document.querySelector('[data-calendar-task="t1"]')!; + expect(chip).toHaveClass('h-full', 'text-[10px]'); + }); + + it('sizes the all-day row by the day in view, not by a busy neighbour', () => { + renderDay(true, []); + const today = document.querySelector('[data-day="2026-09-09"]')!; + const next = document.querySelector('[data-day="2026-09-10"]')!; + // The day in view has nothing all-day: no empty block under its date. + expect(within(today).queryByText(/all_day|All day/i)).toBeNull(); + expect(today).not.toHaveClass('h-0'); + // The neighbour's all-day event takes no height of its own. + expect(within(next).getByRole('button', { name: /Holiday/ })).toBeInTheDocument(); + expect(next).toHaveClass('h-0', 'min-h-full', 'overflow-hidden'); + expect(screen.getAllByRole('columnheader')).toHaveLength(3); + }); +}); diff --git a/components/calendar/__tests__/calendar-dialogs.test.tsx b/components/calendar/__tests__/calendar-dialogs.test.tsx index bc6b39924..4b408c769 100644 --- a/components/calendar/__tests__/calendar-dialogs.test.tsx +++ b/components/calendar/__tests__/calendar-dialogs.test.tsx @@ -61,6 +61,16 @@ describe('import dialog from the keyboard', () => { render(); expect(screen.getByRole('button', { name: /select_file/ })).toBeInTheDocument(); }); + + // It focused the hidden file input, which can't take the focus, so the + // focus stayed on the page behind and Tab walked the calendar. + it('opens with the focus on the drop zone, or on the address field for a link', () => { + const { unmount } = render(); + expect(document.activeElement).toBe(screen.getByRole('button', { name: /select_file/ })); + unmount(); + render(); + expect(document.activeElement).toBe(screen.getByLabelText('tab_url')); + }); }); describe('import preview', () => { diff --git a/components/calendar/__tests__/calendar-rtl-text.test.tsx b/components/calendar/__tests__/calendar-rtl-text.test.tsx new file mode 100644 index 000000000..9b728c1f6 --- /dev/null +++ b/components/calendar/__tests__/calendar-rtl-text.test.tsx @@ -0,0 +1,64 @@ +import { render, screen, within } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { EventCard } from '../event-card'; +import { CalendarSearchResults } from '../calendar-search'; +import { CalendarAgendaView } from '../calendar-agenda-view'; +import type { Calendar, CalendarEvent } from '@/lib/jmap/types'; + +// In Arabic, an English title took the page's direction (".Dinner with +// Carlos and co", cut at the start) and "09:15 – 09:30" read "09:30 – 09:15". + +vi.mock('@/lib/timezone', async (importOriginal) => ({ + ...(await importOriginal()), + displayNow: () => new Date(2026, 9, 28, 8, 0), +})); + +const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[]; +const dinner = { + id: 'e1', '@type': 'Event', uid: 'e1', title: 'Dinner with Carlos and co.', start: '2026-10-28T09:15:00', + duration: 'PT1H', showWithoutTime: false, status: 'confirmed', calendarIds: { 'cal-1': true }, +} as unknown as CalendarEvent; + +const isolatedLtr = (el: HTMLElement) => el.closest('bdi')?.getAttribute('dir') === 'ltr'; + +describe('user text and times in a right-to-left layout', () => { + it.each(['chip', 'span', 'block'] as const)('gives the event title its own direction (%s)', (variant) => { + render(); + expect(screen.getByText('Dinner with Carlos and co.')).toHaveAttribute('dir', 'auto'); + }); + + it('keeps start before end in a block', () => { + render(); + expect(isolatedLtr(screen.getByText('09:15 – 10:15'))).toBe(true); + }); + + it('does the same in search results', () => { + render( + , + ); + const row = screen.getByRole('button', { name: /Dinner/ }); + expect(within(row).getByText('Dinner with Carlos and co.')).toHaveAttribute('dir', 'auto'); + expect(isolatedLtr(within(row).getByText('09:15 – 10:15'))).toBe(true); + }); + + it('does the same in the agenda', () => { + render( + , + ); + expect(screen.getByText('Dinner with Carlos and co.')).toHaveAttribute('dir', 'auto'); + expect(isolatedLtr(screen.getByText('09:15 – 10:15'))).toBe(true); + }); +}); diff --git a/components/calendar/__tests__/calendar-toolbar-phone.test.tsx b/components/calendar/__tests__/calendar-toolbar-phone.test.tsx new file mode 100644 index 000000000..dc9171553 --- /dev/null +++ b/components/calendar/__tests__/calendar-toolbar-phone.test.tsx @@ -0,0 +1,44 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { CalendarToolbar } from '../calendar-toolbar'; + +// The phone day view's title was cut to "Th…": the long date beside the +// menu, the back arrow, both chevrons, Today and the header buttons. + +vi.mock('@/components/layout/phone-header-actions', () => ({ PhoneHeaderActions: () => null })); + +function renderToolbar(overrides: Partial> = {}) { + render( + , + ); +} + +describe('phone toolbar in the day view', () => { + it('names the day short, with the full date as its tooltip', () => { + renderToolbar(); + const title = screen.getByText('Oct 28'); + expect(title).toHaveAttribute('title', 'Wednesday, October 28, 2026'); + }); + + it('puts the way back to the month in the menu button\'s place', () => { + renderToolbar({ onNavigateBack: vi.fn() }); + expect(screen.getByRole('button', { name: 'back_to_month' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'nav_open_menu' })).toBeNull(); + }); + + it('keeps the menu when there is no way back', () => { + renderToolbar(); + expect(screen.getByRole('button', { name: 'nav_open_menu' })).toBeInTheDocument(); + }); +}); diff --git a/components/calendar/__tests__/calendar-week-view-tab-order.test.tsx b/components/calendar/__tests__/calendar-week-view-tab-order.test.tsx new file mode 100644 index 000000000..a188afdf6 --- /dev/null +++ b/components/calendar/__tests__/calendar-week-view-tab-order.test.tsx @@ -0,0 +1,90 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { CalendarWeekView } from '../calendar-week-view'; +import { CalendarDayView } from '../calendar-day-view'; +import type { Calendar, CalendarEvent } from '@/lib/jmap/types'; + +// Tab in the week view walked into the events of the weeks loaded on +// either side and scrolled the grid off its week. + +const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[]; + +function makeEvent(id: string, title: string, start: string, extra: Partial = {}): CalendarEvent { + return { + id, uid: id, '@type': 'Event', title, start, duration: 'PT1H', showWithoutTime: false, + calendarIds: { 'cal-1': true }, ...extra, + } as unknown as CalendarEvent; +} + +const events = [ + makeEvent('before', 'Last week', '2026-09-02T10:00:00'), + makeEvent('now', 'This week', '2026-09-09T10:00:00'), + makeEvent('after', 'Next week', '2026-09-16T10:00:00'), + makeEvent('trip', 'Trip', '2026-09-03T00:00:00', { showWithoutTime: true, duration: 'P1D' }), + makeEvent('fair', 'Fair', '2026-09-08T00:00:00', { showWithoutTime: true, duration: 'P1D' }), +]; + +const tabbable = (name: RegExp) => screen.getByRole('button', { name }).tabIndex >= 0; + +describe('tab order of the week view', () => { + it('leaves the events of the weeks on either side out of the tab order', () => { + render( + , + ); + expect(tabbable(/^This week/)).toBe(true); + expect(tabbable(/^Fair/)).toBe(true); + expect(tabbable(/^Last week/)).toBe(false); + expect(tabbable(/^Next week/)).toBe(false); + expect(tabbable(/^Trip/)).toBe(false); + }); + + it('keeps the grid tab stop on a day in view when the selected day is scrolled away', () => { + render( + , + ); + const stops = screen.getAllByRole('gridcell').filter((cell) => cell.tabIndex === 0); + expect(stops).toHaveLength(1); + expect(stops[0].getAttribute('data-slot')).toMatch(/^2026-09-07T/); + }); + + it('does the same in the day view', () => { + render( + , + ); + expect(tabbable(/^Tuesday/)).toBe(true); + expect(tabbable(/^Monday/)).toBe(false); + }); +}); diff --git a/components/calendar/__tests__/event-detail-popover-focus.test.tsx b/components/calendar/__tests__/event-detail-popover-focus.test.tsx new file mode 100644 index 000000000..787cc35c8 --- /dev/null +++ b/components/calendar/__tests__/event-detail-popover-focus.test.tsx @@ -0,0 +1,70 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { EventDetailPopover } from '../event-detail-popover'; +import type { Calendar, CalendarEvent } from '@/lib/jmap/types'; + +// CAL-13: Enter on an event opened the card but the focus stayed on the +// event: the card focused its first button while it was still hidden, +// before it had been placed. + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => 'en', + useFormatter: () => ({ + dateTime: (d: Date | string) => String(d), + relativeTime: (d: Date | string) => String(d), + number: (n: number) => String(n), + }), +})); + +const calendar = { + id: 'cal-1', + name: 'Work', + myRights: { mayWriteAll: true, mayWriteOwn: true }, +} as unknown as Calendar; + +const event = { + id: 'evt-1', + calendarIds: { 'cal-1': true }, + title: 'Review', + start: '2026-11-20T10:00:00', + duration: 'PT1H', + timeZone: 'Europe/Berlin', + showWithoutTime: false, + participants: null, + alerts: null, +} as unknown as CalendarEvent; + +describe('EventDetailPopover focus', () => { + it('takes the focus once it is visible, and gives it back to the event on close', () => { + const opener = document.createElement('button'); + document.body.appendChild(opener); + opener.focus(); + const visibilityAtFocus: string[] = []; + const onFocusIn = (e: FocusEvent) => { + const card = (e.target as HTMLElement).closest('[role="dialog"]'); + if (card) visibilityAtFocus.push(card.style.visibility); + }; + document.addEventListener('focusin', onFocusIn); + const { unmount } = render( + , + ); + const card = screen.getByRole('dialog', { name: 'Review' }); + expect(card.contains(document.activeElement)).toBe(true); + expect(visibilityAtFocus).toEqual(['visible']); + unmount(); + document.removeEventListener('focusin', onFocusIn); + expect(document.activeElement).toBe(opener); + opener.remove(); + }); +}); diff --git a/components/calendar/__tests__/recurrence-scope-dialog.test.tsx b/components/calendar/__tests__/recurrence-scope-dialog.test.tsx new file mode 100644 index 000000000..7f93c7c81 --- /dev/null +++ b/components/calendar/__tests__/recurrence-scope-dialog.test.tsx @@ -0,0 +1,35 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { RecurrenceScopeDialog } from '../recurrence-scope-dialog'; + +// The delete scope dialog asked "Which events would you like to modify?": +// no word of what is lost (R3), and a title without a question mark. + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, values?: Record) => + values ? `${key}(${Object.values(values).join(',')})` : key, +})); + +describe('RecurrenceScopeDialog', () => { + it('asks to delete with a question and says what goes for the chosen scope', () => { + render( + , + ); + const dialog = screen.getByRole('dialog', { name: 'delete_question' }); + expect(dialog).toHaveAccessibleDescription('delete_this_message(Yoga)'); + fireEvent.click(screen.getByLabelText('all_events')); + expect(dialog).toHaveAccessibleDescription('delete_all_message(Yoga)'); + fireEvent.click(screen.getByLabelText('this_and_future')); + expect(dialog).toHaveAccessibleDescription('delete_following_message(Yoga)'); + // The cancellation note for an organizer's event with participants + expect(screen.getByText('cancel_notification')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'delete' }).className).toMatch(/destructive/); + }); + + it('keeps the edit wording for an edit', () => { + render(); + const dialog = screen.getByRole('dialog', { name: 'edit_title' }); + expect(dialog).toHaveAccessibleDescription('description'); + expect(screen.getByRole('button', { name: 'save' })).toBeInTheDocument(); + }); +}); diff --git a/components/calendar/__tests__/task-modal-calendars.test.tsx b/components/calendar/__tests__/task-modal-calendars.test.tsx new file mode 100644 index 000000000..15eb01375 --- /dev/null +++ b/components/calendar/__tests__/task-modal-calendars.test.tsx @@ -0,0 +1,49 @@ +import { render, screen, within } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { TaskModal } from '../task-modal'; +import type { Calendar, CalendarTask } from '@/lib/jmap/types'; + +// The task editor's Calendar select offered the read-only Birthdays +// calendar, which the event editor leaves out. + +const writable = { mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayDelete: true }; +const readOnly = { mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayDelete: false }; +const calendars = [ + { id: 'work', name: 'Work', isShared: false, myRights: writable }, + { id: 'personal', name: 'Personal', isShared: false, myRights: writable }, + { id: 'birthdays', name: 'Birthdays', isShared: false, myRights: readOnly }, + { id: 'feed', name: 'Holidays feed', isShared: false, myRights: writable }, +] as unknown as Calendar[]; +const isSubscriptionCalendar = (id: string) => id === 'feed'; + +describe('task editor calendar select', () => { + it('lists only the calendars a task can go into', () => { + render(); + const select = screen.getByLabelText('tasks.calendar'); + const names = within(select).getAllByRole('option').map((o) => o.textContent); + expect(names).toEqual(['Work', 'Personal']); + }); + + it('keeps the calendar of a task that is already in a read-only one', () => { + const task = { + id: 't1', '@type': 'Task', title: 'Card for Ann', calendarIds: { birthdays: true }, + progress: 'needs-action', priority: 0, due: null, showWithoutTime: true, alerts: null, + } as unknown as CalendarTask; + render(); + const select = screen.getByLabelText('tasks.calendar') as HTMLSelectElement; + expect(select.value).toBe('birthdays'); + }); +}); + +// The desktop task panel had no dialog role, unlike the event editor. +describe('task editor panel', () => { + it('is a dialog named by its title on wide screens', () => { + render(); + expect(screen.getByRole('dialog', { name: 'tasks.create' })).toBeInTheDocument(); + }); + + it('leaves the role to the full-screen wrapper on a phone', () => { + render(); + expect(screen.queryByRole('dialog')).toBeNull(); + }); +}); diff --git a/components/calendar/calendar-agenda-view.tsx b/components/calendar/calendar-agenda-view.tsx index 850321a93..4dd69715a 100644 --- a/components/calendar/calendar-agenda-view.tsx +++ b/components/calendar/calendar-agenda-view.tsx @@ -263,7 +263,7 @@ export function CalendarAgendaView({ style={{ paddingBlock: 'var(--density-item-py)' }} > - {ev.showWithoutTime ? t("events.all_day") : `${formatTime(start)} – ${formatTime(end)}`} + {ev.showWithoutTime ? t("events.all_day") : {`${formatTime(start)} – ${formatTime(end)}`}} {/* Calendar colour as a dot on the title line; hollow when @@ -275,13 +275,17 @@ export function CalendarAgendaView({ />
-
- {ev.title || t("events.no_title")} + {/* Sized to the title, which keeps its own direction: it sits at + the start of the row and is cut at its own end. */} +
+ + {ev.title || t("events.no_title")} +
{(meta || getParticipantCount(ev) > 0) && (
{locationName && } - {meta && {meta}} + {meta && {meta}} {getParticipantCount(ev) > 0 && ( diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index db516779c..6b29b5ea0 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -91,6 +91,7 @@ import { useCalendarEventSearch } from "@/hooks/use-calendar-event-search"; import { CalendarSearchField, CalendarSearchResults } from "@/components/calendar/calendar-search"; import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect"; import { useCalendarLocale } from "@/hooks/use-calendar-locale"; +import { useHoverPreviewGate } from "@/hooks/use-hover-preview-gate"; import { computeScrollWindow, fixedScrollWindowState, freshScrollWindowState, growScrollWindow, normalizeScrollWindowState, scrollWindowContains, type CalendarFocus, type ScrollViewMode, type ScrollWindowOptions, @@ -842,16 +843,20 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = [closeDetail, openEmptyContextMenu], ); + // No preview over an open editor, card or dialog, nor after one closed + // until the pointer moves. + const canHoverPreview = useHoverPreviewGate(showEventModal || showTaskModal || detailPinned || quickDraft !== null); + const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => { if (isMobile) return; if (detailPinnedRef.current) return; if (calendarHoverPreview === 'off') return; if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; } - // Don't show hover popover if the sidebar is already open for this event - if (showEventModal && editEvent?.id === event.id) return; + if (!canHoverPreview()) return; if (calendarHoverPreview === 'delay-500ms' || calendarHoverPreview === 'delay-1s' || calendarHoverPreview === 'delay-2s') { const ms = calendarHoverPreview === 'delay-500ms' ? 500 : calendarHoverPreview === 'delay-1s' ? 1000 : 2000; hoverTimerRef.current = setTimeout(() => { + if (!canHoverPreview()) return; setDetailEvent(event); setDetailAnchorRect(anchorRect); }, ms); @@ -859,7 +864,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = setDetailEvent(event); setDetailAnchorRect(anchorRect); } - }, [isMobile, calendarHoverPreview, showEventModal, editEvent]); + }, [isMobile, calendarHoverPreview, canHoverPreview]); const handleHoverLeave = useCallback(() => { if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; } @@ -1790,6 +1795,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = currentUserEmails={currentUserEmails} onSelectEvent={handleSelectEvent} onContextMenuEvent={handleContextMenuEvent} + onClearSearch={() => { setSearchQuery(""); searchInputRef.current?.focus(); }} /> ); } @@ -2213,6 +2219,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = key={editTask?.id ?? 'new-task'} task={editTask} calendars={displayCalendars} + isSubscriptionCalendar={isSubscriptionCalendar} onSave={handleSaveTask} onDelete={handleDeleteTask} onDirtyChange={setTaskEditorDirty} @@ -2228,6 +2235,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = onClick={openCreateForView} className="absolute bottom-4 end-4 z-40 h-14 w-14 rounded-full shadow-lg" aria-label={createLabel} + data-tour="create-event-button" > @@ -2374,6 +2382,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = key={editTask?.id ?? 'new-task'} task={editTask} calendars={displayCalendars} + isSubscriptionCalendar={isSubscriptionCalendar} onSave={handleSaveTask} onDelete={handleDeleteTask} onDirtyChange={setTaskEditorDirty} @@ -2429,6 +2438,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = && userOrganizes(pendingScopeAction.event, currentUserEmails) ? t("participants.cancel_notification") : undefined} + eventTitle={pendingScopeAction?.event.title || t("events.no_title")} onSelect={handleScopeSelect} onClose={() => setPendingScopeAction(null)} /> diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx index d927ee602..db225598a 100644 --- a/components/calendar/calendar-day-view.tsx +++ b/components/calendar/calendar-day-view.tsx @@ -197,14 +197,6 @@ export function CalendarDayView({ return map; }, [days, eventsByDay, hours]); - const hasAllDayArea = useMemo( - () => days.some((day) => { - const key = format(day, "yyyy-MM-dd"); - return (eventsByDay.get(key)?.allDay.length ?? 0) > 0 || (tasksByDay.get(key)?.length ?? 0) > 0; - }), - [days, eventsByDay, tasksByDay], - ); - const scrollToFocus = useCallback(() => { const root = rootRef.current; if (!root) return; @@ -232,6 +224,10 @@ export function CalendarDayView({ // not used: browsers re-snap on their own when columns are prepended, // which would double the scroll correction.) const visibleKeyRef = useRef(null); + // The day in view: its all-day events and tasks size the row under the + // dates, so a busy neighbour doesn't leave an empty block above the hours. + const [visibleKey, setVisibleKey] = useState(() => dayKey(focus.date)); + useEffect(() => { setVisibleKey(dayKey(focus.date)); }, [focus.date, focus.nonce]); const scrollFrameRef = useRef(null); const snapTimerRef = useRef | null>(null); const handleStripScroll = useCallback(() => { @@ -255,6 +251,7 @@ export function CalendarDayView({ const key = dayKey(day); if (key === visibleKeyRef.current) return; visibleKeyRef.current = key; + setVisibleKey(key); onVisibleDateChange?.(day); }); }, [colWidth, colCount, days, onVisibleDateChange]); @@ -298,6 +295,10 @@ export function CalendarDayView({ onMoveEvent, }); + // Tab stays in the day in view: the loaded days on either side are out of + // the tab order, or Tab walked into them and scrolled the strip. + const isInView = useCallback((key: string) => key === visibleKey, [visibleKey]); + const { slotProps } = useTimeGridFocus({ rootRef, headerRef, @@ -306,6 +307,7 @@ export function CalendarDayView({ hours, selectedDate, onCreate: onCreateAtTime, + isInView, }); const formatHour = (h: number): string => { @@ -352,7 +354,16 @@ export function CalendarDayView({ const allDayEvents = eventsByDay.get(key)?.allDay ?? []; const dayTasks = tasksByDay.get(key) ?? []; return ( -
+
- {hasAllDayArea && ( + {(allDayEvents.length > 0 || dayTasks.length > 0) && (
{ @@ -402,6 +413,7 @@ export function CalendarDayView({ onMouseLeave={onHoverLeave} onContextMenu={onContextMenuEvent} currentUserEmails={currentUserEmails} + tabbable={key === visibleKey} /> ); })} @@ -414,12 +426,14 @@ export function CalendarDayView({
{/* The same chip as the month and week grids. */} {dayTasks.map((task) => ( -
+
task.calendarIds[c.id])} onToggleComplete={onToggleTaskComplete} onSelect={onSelectTask} + touch={isMobile} + tabbable={key === visibleKey} />
))} @@ -487,7 +501,7 @@ export function CalendarDayView({ onClick={() => handleSlotClick(day, h)} onDoubleClick={() => handleSlotDoubleClick(day, h)} onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, h, false) : undefined} - className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary" + className="relative border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors outline-none focus-visible:after:absolute focus-visible:after:inset-0 focus-visible:after:z-[15] focus-visible:after:pointer-events-none focus-visible:after:ring-2 focus-visible:after:ring-inset focus-visible:after:ring-primary" style={{ height: hourHeight }} /> ))} @@ -497,6 +511,7 @@ export function CalendarDayView({ count={dayLayout.before} direction="before" onReveal={() => revealMinutes(dayLayout.firstBeforeMinutes ?? 0)} + tabbable={key === visibleKey} /> )} {dayLayout && dayLayout.after > 0 && ( @@ -504,6 +519,7 @@ export function CalendarDayView({ count={dayLayout.after} direction="after" onReveal={() => revealMinutes(dayLayout.firstAfterMinutes ?? hours.endMinutes)} + tabbable={key === visibleKey} /> )} @@ -541,6 +557,7 @@ export function CalendarDayView({ onMouseLeave={onHoverLeave} onContextMenu={onContextMenuEvent} currentUserEmails={currentUserEmails} + tabbable={key === visibleKey} draggable={editable} continuesBefore={clip.clippedStart} continuesAfter={clip.clippedEnd} @@ -603,7 +620,7 @@ export function CalendarDayView({ }} >
- {formatSnapTime(dragCreate.startMinutes, timeFormat)} – {formatSnapTime(dragCreate.endMinutes, timeFormat)} + {formatSnapTime(dragCreate.startMinutes, timeFormat)} – {formatSnapTime(dragCreate.endMinutes, timeFormat)}
)} @@ -649,7 +666,7 @@ export function CalendarDayView({ {pendingPreview.title}
- {formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)} + {formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)}
); diff --git a/components/calendar/calendar-search.tsx b/components/calendar/calendar-search.tsx index 283cb8551..b12ef8aef 100644 --- a/components/calendar/calendar-search.tsx +++ b/components/calendar/calendar-search.tsx @@ -6,6 +6,7 @@ import { format } from "date-fns"; import { Search, X, MapPin, Repeat } from "@/components/icons"; import { Input } from "@/components/ui/input"; import { IconButton } from "@/components/ui/icon-button"; +import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; import { Spinner } from "@/components/ui/spinner"; import { cn } from "@/lib/utils"; @@ -88,11 +89,13 @@ interface CalendarSearchResultsProps { currentUserEmails?: string[]; onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void; onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void; + /** Empties the search box, back to the calendar (R9). */ + onClearSearch?: () => void; } /** The events a calendar search found, upcoming first, then past ones. */ export function CalendarSearchResults({ - query, results, calendars, timeFormat = "24h", currentUserEmails, onSelectEvent, onContextMenuEvent, + query, results, calendars, timeFormat = "24h", currentUserEmails, onSelectEvent, onContextMenuEvent, onClearSearch, }: CalendarSearchResultsProps) { const t = useTranslations("calendar"); const intlFormatter = useDisplayDateFormatter(); @@ -148,7 +151,7 @@ export function CalendarSearchResults({ : { month: "short", day: "numeric", year: "numeric" })} - {ev.showWithoutTime ? t("events.all_day") : `${formatTime(start)} – ${formatTime(end)}`} + {ev.showWithoutTime ? t("events.all_day") : {`${formatTime(start)} – ${formatTime(end)}`}}
- {formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)} + {formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)}
); diff --git a/components/calendar/display-hours-controls.tsx b/components/calendar/display-hours-controls.tsx index 01cd9ab6a..09ddbe575 100644 --- a/components/calendar/display-hours-controls.tsx +++ b/components/calendar/display-hours-controls.tsx @@ -9,13 +9,15 @@ interface HiddenEventsIndicatorProps { count: number; direction: "before" | "after"; onReveal: () => void; + /** False for a day scrolled out of view: Tab skips it. */ + tabbable?: boolean; } /** * Marks a day column that has events above or below the visible hours * (#1164); clicking it shows the whole day at the first of them. */ -export function HiddenEventsIndicator({ count, direction, onReveal }: HiddenEventsIndicatorProps) { +export function HiddenEventsIndicator({ count, direction, onReveal, tabbable = true }: HiddenEventsIndicatorProps) { const t = useTranslations("calendar.events"); const label = direction === "before" ? t("hidden_before", { count }) : t("hidden_after", { count }); const Icon = direction === "before" ? ChevronUp : ChevronDown; @@ -23,6 +25,7 @@ export function HiddenEventsIndicator({ count, direction, onReveal }: HiddenEven ); } @@ -208,6 +211,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM onContextMenu={handleContextMenu} {...touchProps} aria-label={ariaLabel} + tabIndex={tabbable ? undefined : -1} {...dragProps} className={cn( "w-full h-full text-start rounded-xs px-1.5 py-0.5 text-xs overflow-hidden", @@ -223,7 +227,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM {showTimeInMonthView && !event.showWithoutTime && ( {format(startDate, timeFmt)} )} - {event.title || t("events.no_title")} + {event.title || t("events.no_title")}
); @@ -237,6 +241,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM onContextMenu={handleContextMenu} {...touchProps} aria-label={ariaLabel} + tabIndex={tabbable ? undefined : -1} {...dragProps} data-calendar-event data-continues-before={continuesBefore || undefined} @@ -263,7 +268,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM block is wide enough to keep the title readable. */}
- {event.title || t("events.no_title")} + {event.title || t("events.no_title")} {!event.showWithoutTime && durationMinutes < 45 && ( {safeFormat(startDate, timeFmt)} )} @@ -271,7 +276,8 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
{!event.showWithoutTime && durationMinutes >= 45 && (
- {timeString} + {/* Start before end in a right-to-left layout too. */} + {timeString}
)} {getParticipantCount(event) > 0 && ( diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index b99da1688..5402d6dbb 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -253,17 +253,40 @@ export function EventDetailPopover({ return () => observer.disconnect(); }, [anchorRect, isMobile]); + // A card opened by click or Enter takes the focus, and gives it back to + // the event when it closes. useEffect(() => { if (!pinned) return; const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null; const node = popoverRef.current; - node?.querySelector("button")?.focus({ preventScroll: true }); return () => { const active = document.activeElement; if (opener?.isConnected && (!active || active === document.body || node?.contains(active))) opener.focus({ preventScroll: true }); }; }, [pinned]); + // The focus goes in once the card is placed: before, it is hidden and + // can't take the focus, which stayed on the event. A card that just + // turned visible can still count as hidden for a frame, so it tries again + // on the next ones, unless the focus moved somewhere else meanwhile. + const placed = position !== null; + useEffect(() => { + const node = popoverRef.current; + if (!pinned || !placed || !node) return; + const from = document.activeElement; + let frame = 0; + let tries = 0; + const attempt = () => { + const active = document.activeElement; + if (node.contains(active) || active !== from) return; + const first = node.querySelector("button"); + first?.focus({ preventScroll: true }); + if (first && document.activeElement !== first && ++tries < 30) frame = requestAnimationFrame(attempt); + }; + attempt(); + return () => cancelAnimationFrame(frame); + }, [pinned, placed]); + useEffect(() => { const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); @@ -400,7 +423,8 @@ export function EventDetailPopover({ "text-lg font-semibold leading-snug break-words text-foreground", event.status === "cancelled" && "line-through text-muted-foreground" )}> - {event.title || t("events.no_title")} + {/* Its own direction, next to the colour square. */} + {event.title || t("events.no_title")} {event.status === "tentative" && ( @@ -463,7 +487,7 @@ export function EventDetailPopover({ {t("events.all_day")} ) : (
- {formatTime(startDate)} – {formatTime(endDate)} + {formatTime(startDate)} – {formatTime(endDate)} ({formatDurationDisplay(durationMinutes)})
)} @@ -481,6 +505,7 @@ export function EventDetailPopover({ target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline min-w-0 break-words" + dir="auto" title={locationName} > {/^https?:\/\//i.test(locationName) @@ -534,7 +559,7 @@ export function EventDetailPopover({
{participants.slice(0, 5).map((p) => (
- + {p.name || p.email} {p.isOrganizer && ( @@ -575,7 +600,7 @@ export function EventDetailPopover({ {calendar && (
- {calendar.name} + {calendar.name}
)} @@ -583,7 +608,7 @@ export function EventDetailPopover({ {event.description && (
-

+

diff --git a/components/calendar/ical-import-modal.tsx b/components/calendar/ical-import-modal.tsx index 29967fc5f..645848086 100644 --- a/components/calendar/ical-import-modal.tsx +++ b/components/calendar/ical-import-modal.tsx @@ -7,7 +7,6 @@ import { Button } from "@/components/ui/button"; import { Upload, Check, RefreshCw, Globe } from "@/components/icons"; import { Dialog } from "@/components/ui/dialog"; import { Spinner } from "@/components/ui/spinner"; -import { format } from "date-fns"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; import type { IJMAPClient } from '@/lib/jmap/client-interface'; import { getEventStartDate } from "@/lib/calendar-utils"; @@ -53,6 +52,9 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal const [urlInput, setUrlInput] = useState(initialUrl || ""); const [isFetchingUrl, setIsFetchingUrl] = useState(false); const fileInputRef = useRef(null); + // The dialog opens on the drop zone, or on the address field for a link. + const dropZoneRef = useRef(null); + const urlInputRef = useRef(null); const targetId = useId(); const validateFile = useCallback((file: File): string | null => { @@ -209,10 +211,15 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal if (!event.start) return ""; try { const date = getEventStartDate(event as CalendarEvent); - const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm"; - return event.showWithoutTime - ? format(date, "MMM d, yyyy") - : format(date, `MMM d, yyyy ${timeFmt}`); + // The locale's own order and month names, not an English pattern. + return date.toLocaleString(locale === "en" ? "en-US" : locale, { + month: "short", + day: "numeric", + year: "numeric", + ...(event.showWithoutTime ? {} : timeFormat === "12h" + ? { hour: "numeric", minute: "2-digit", hour12: true } + : { hour: "2-digit", minute: "2-digit", hour12: false }), + }); } catch { return event.start; } @@ -229,6 +236,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal onClose={close} title={t("title")} size="full-on-phone" + initialFocus={importMode === "url" ? urlInputRef : dropZoneRef} dirty={step !== "select" || urlInput.trim() !== (initialUrl ?? "")} footer={step !== "importing" ? ( <> @@ -281,6 +289,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal {importMode === "file" && ( <> diff --git a/components/calendar/task-modal.tsx b/components/calendar/task-modal.tsx index 6d3069d4a..fb89b9505 100644 --- a/components/calendar/task-modal.tsx +++ b/components/calendar/task-modal.tsx @@ -14,6 +14,7 @@ import { PluginSlot } from "@/components/plugins/plugin-slot"; import { taskToView } from "@/lib/plugin-projection"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; +import { canCreateEventsIn } from "@/lib/calendar-editability"; interface TaskModalProps { task?: CalendarTask | null; @@ -25,6 +26,8 @@ interface TaskModalProps { /** Told whether the form holds changes that aren't saved, so the page can ask before it drops them. */ onDirtyChange?: (dirty: boolean) => void; isMobile?: boolean; + /** Subscribed feeds are read-only: no tasks go there. */ + isSubscriptionCalendar?: (calendarId: string) => boolean; } type PriorityLevel = "none" | "high" | "medium" | "low"; @@ -53,7 +56,8 @@ export function TaskModal({ onDelete, onClose, onDirtyChange, - isMobile: _isMobile, + isMobile = false, + isSubscriptionCalendar, }: TaskModalProps) { const t = useTranslations("calendar"); const tCommon = useTranslations("common"); @@ -63,7 +67,9 @@ export function TaskModal({ // task-detail-section: the saved task, for an existing task only. const pluginTaskProps = useMemo(() => (task ? { task: taskToView(task) } : null), [task]); - const writableCalendars = calendars.filter(c => !c.isShared || c.myRights?.mayWriteAll || c.myRights?.mayWriteOwn); + // The calendars a task can go into: not read-only ones (Birthdays, + // feeds), the same rule as the event editor. + const writableCalendars = calendars.filter((c) => canCreateEventsIn(c, isSubscriptionCalendar)); const defaultCalendarId = writableCalendars[0]?.id ?? calendars[0]?.id ?? ""; const [title, setTitle] = useState(task?.title ?? ""); @@ -80,6 +86,8 @@ export function TaskModal({ } return defaultCalendarId; }); + // A task already in a read-only calendar keeps it listed. + const calendarOptions = calendars.filter((c) => c.id === calendarId || writableCalendars.includes(c)); const [alertOption, setAlertOption] = useState(() => { if (!task?.alerts) return "none"; const first = Object.values(task.alerts)[0]; @@ -205,10 +213,17 @@ export function TaskModal({ }, [requestClose, handleSave]); return ( -
+ // The desktop side panel is a dialog named by its title, like the event + // editor; on a phone the full-screen wrapper around it is. +
{/* Header */}
-

+

{isEdit ? t("tasks.edit") : t("tasks.create")}

@@ -313,7 +328,7 @@ export function TaskModal({
{/* Calendar */} - {writableCalendars.length > 1 && ( + {calendarOptions.length > 1 && (
-
+
{error && (
{error} @@ -816,10 +824,12 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
) : ( <> -
+ {/* Two columns in a narrow pane (title and first name, last name and + suffix), one row once there is room, so no name gets cut. */} +
- setPrefix(e.target.value)} placeholder={t("prefix_placeholder")} className="w-20" /> + setPrefix(e.target.value)} placeholder={t("prefix_placeholder")} className="@lg:w-20" />
@@ -831,7 +841,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
- setSuffix(e.target.value)} placeholder={t("suffix_placeholder")} className="w-20" /> + setSuffix(e.target.value)} placeholder={t("suffix_placeholder")} className="@lg:w-20" />
{/* Either name is enough (or an organization), so neither is starred. */} @@ -1126,6 +1136,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
-
+ {/* Toasts sit above Save and Cancel, not on them. */} +
@@ -1201,6 +1214,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress function CategoryComboBox({ keywordsStr, onChange, + inputValue, + onInputChange: setInputValue, allKeywords, placeholder, hint, @@ -1208,6 +1223,9 @@ function CategoryComboBox({ }: { keywordsStr: string; onChange: (value: string) => void; + /** The text typed but not added yet; the form adds it on Save. */ + inputValue: string; + onInputChange: (value: string) => void; allKeywords: string[]; placeholder: string; hint: string; @@ -1215,7 +1233,6 @@ function CategoryComboBox({ }) { const t = useTranslations("contacts.form"); const [isOpen, setIsOpen] = useState(false); - const [inputValue, setInputValue] = useState(""); const inputRef = useRef(null); // Parse current keywords from comma-separated string @@ -1243,7 +1260,7 @@ function CategoryComboBox({ const next = [...currentKeywords, trimmed].join(", "); onChange(next); setInputValue(""); - }, [currentKeywords, onChange]); + }, [currentKeywords, onChange, setInputValue]); const removeKeyword = useCallback((keyword: string) => { const next = currentKeywords.filter(k => k !== keyword).join(", "); diff --git a/components/contacts/contact-group-form.tsx b/components/contacts/contact-group-form.tsx index faf41feff..21389ed69 100644 --- a/components/contacts/contact-group-form.tsx +++ b/components/contacts/contact-group-form.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Avatar } from "@/components/ui/avatar"; import { cn } from "@/lib/utils"; +import { useBottomControls } from "@/hooks/use-bottom-controls"; import type { ContactCard } from "@/lib/jmap/types"; import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; @@ -44,6 +45,7 @@ export function ContactGroupForm({ const [initial] = useState(() => ({ name, members: [...selectedIds].sort().join(",") })); const dirty = name !== initial.name || [...selectedIds].sort().join(",") !== initial.members; useEffect(() => { onDirtyChange?.(dirty); }, [dirty, onDirtyChange]); + const footerRef = useBottomControls(); const filteredIndividuals = useMemo(() => { if (!memberSearch) return individuals; @@ -195,7 +197,8 @@ export function ContactGroupForm({ )}
-
+ {/* Toasts sit above Save and Cancel, not on them. */} +
diff --git a/components/contacts/contact-list-item.tsx b/components/contacts/contact-list-item.tsx index 28e90ad8e..08a95ab67 100644 --- a/components/contacts/contact-list-item.tsx +++ b/components/contacts/contact-list-item.tsx @@ -67,7 +67,7 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection, return (
{ + // The open contact counts as selected, as a clicked file does + // in Files: Ctrl/⌘+click and Shift+click build on it. + const startFromOpen = !hasSelection && !!selectedContactId + && selectedContactId !== contact.id && sortedIds.includes(selectedContactId); if (e.ctrlKey || e.metaKey) { e.preventDefault(); + if (startFromOpen) onToggleSelection(selectedContactId); onToggleSelection(contact.id); } else if (e.shiftKey) { e.preventDefault(); + if (startFromOpen) onToggleSelection(selectedContactId); onSelectRangeContacts(contact.id, sortedIds); } else { if (hasSelection) onClearSelection(); diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx index 6af969db5..7aeb48de7 100644 --- a/components/contacts/contacts-app.tsx +++ b/components/contacts/contacts-app.tsx @@ -52,6 +52,7 @@ 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 { useLeaveGuard } from "@/lib/leave-guard"; import { useDeleteCategory } from "@/hooks/use-delete-category"; import { MvTopBar } from "@/components/layout/mv-top-bar"; import { useMountainViewShell } from "@/hooks/use-mountain-view-shell"; @@ -172,18 +173,23 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = useEffect(() => { if (view !== "create" && view !== "edit" && view !== "group-create" && view !== "group-edit") setEditorDirty(false); }, [view, setEditorDirty]); + const askDiscard = useCallback(() => confirmDialog({ + title: t("form.discard_title"), + message: t("form.discard_message"), + confirmText: t("form.discard"), + cancelText: t("form.keep_editing"), + variant: "destructive", + }), [confirmDialog, t]); 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", - }); + const discard = await askDiscard(); if (discard) setEditorDirty(false); return discard; - }, [confirmDialog, t, setEditorDirty]); + }, [askDiscard, setEditorDirty]); + // The rail and the phone tab bar ask too, before they leave Contacts. Only + // ask there: leaving unmounts the form, and clearing it first would let the + // Back guard below take its history step back over the navigation. + useLeaveGuard(() => editorDirtyRef.current, askDiscard); const guard = (action: (...args: A) => unknown) => (...args: A): void => { if (!editorDirtyRef.current) { action(...args); @@ -529,14 +535,31 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = setView("edit"); }; - const deleteContactById = useCallback(async (contactId: string) => { - const confirmed = await confirmDialog({ - title: t("delete_confirm_title"), - message: t("delete_confirm"), + // R3: the confirm names what goes ("Delete Alice Smith?", "Delete 3 + // contacts?") and says what is lost with it. + const confirmDeleteContacts = useCallback((ids: string[]) => { + const only = ids.length === 1 ? contacts.find((c) => c.id === ids[0]) : undefined; + const name = only ? getContactDisplayName(only).trim() : ""; + return confirmDialog({ + title: name ? t("delete_named_title", { name }) : t("bulk.delete_confirm", { count: ids.length }), + message: t("delete_details_message"), confirmText: t("form.delete"), variant: "destructive", }); - if (!confirmed) return; + }, [contacts, confirmDialog, t]); + const confirmDeleteGroup = useCallback((groupId: string) => { + const group = contacts.find((c) => c.id === groupId); + const name = group ? getContactDisplayName(group).trim() : ""; + return confirmDialog({ + title: name ? t("delete_named_title", { name }) : t("groups.delete_confirm_title"), + message: t("groups.delete_message"), + confirmText: t("form.delete"), + variant: "destructive", + }); + }, [contacts, confirmDialog, t]); + + const deleteContactById = useCallback(async (contactId: string) => { + if (!(await confirmDeleteContacts([contactId]))) return; try { const deleted = supportsSync && client @@ -550,7 +573,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = console.error('Failed to delete contact:', error); toast.error(t("toast.error_delete")); } - }, [confirmDialog, t, supportsSync, client, deleteContact, deleteLocalContact, selectedContactId]); + }, [confirmDeleteContacts, t, supportsSync, client, deleteContact, deleteLocalContact, selectedContactId]); const handleDelete = async () => { if (!selectedContact) return; @@ -773,13 +796,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = }, [openComposeInApp]); const handleDeleteGroupFromSidebar = useCallback(async (groupId: string) => { - const confirmed = await confirmDialog({ - title: t("groups.delete_confirm_title"), - message: t("groups.delete_confirm"), - confirmText: t("form.delete"), - variant: "destructive", - }); - if (!confirmed) return; + if (!(await confirmDeleteGroup(groupId))) return; try { await deleteGroup(supportsSync && client ? client : null, groupId); @@ -793,18 +810,11 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = console.error('Failed to delete group:', error); toast.error(t("toast.error_delete")); } - }, [confirmDialog, deleteGroup, supportsSync, client, selectedGroupId, t]); + }, [confirmDeleteGroup, deleteGroup, supportsSync, client, selectedGroupId, t]); const handleDeleteGroup = async () => { if (!selectedGroup) return; - - const confirmed = await confirmDialog({ - title: t("groups.delete_confirm_title"), - message: t("groups.delete_confirm"), - confirmText: t("form.delete"), - variant: "destructive", - }); - if (!confirmed) return; + if (!(await confirmDeleteGroup(selectedGroup.id))) return; try { await deleteGroup(supportsSync && client ? client : null, selectedGroup.id); @@ -855,13 +865,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = const handleBulkDelete = async () => { if (selectedContactIds.size === 0) return; - const confirmed = await confirmDialog({ - title: t("bulk.delete_confirm_title"), - message: t("bulk.delete_confirm", { count: selectedContactIds.size }), - confirmText: t("bulk.delete"), - variant: "destructive", - }); - if (!confirmed) return; + if (!(await confirmDeleteContacts(Array.from(selectedContactIds)))) return; try { if (!(await bulkDeleteContacts( @@ -1193,7 +1197,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = }))} onDeleteAddressBook={client ? fromDrawer(async (book: AddressBook) => { const ok = await confirmDialog({ - title: t("address_books.delete"), + title: t("address_books.delete_title"), message: t("address_books.confirm_delete", { name: book.name }), variant: "destructive", confirmText: t("address_books.delete"), diff --git a/components/contacts/contacts-sidebar.tsx b/components/contacts/contacts-sidebar.tsx index bb48dab75..961393afc 100644 --- a/components/contacts/contacts-sidebar.tsx +++ b/components/contacts/contacts-sidebar.tsx @@ -738,7 +738,7 @@ export function ContactsSidebar({ { closeGroupContextMenu(); onDeleteGroup?.(groupContextMenu.data!.id); @@ -788,7 +788,7 @@ function SidebarRow({ ref={rowRef} {...rowProps} {...dropProps} - {...(onOpenMenu ? longPress : {})} + {...(onOpenMenu ? longPress.handlers : {})} onContextMenu={onContextMenu} className={cn( // 32px rows, 44px on touch (R1). diff --git a/components/files/__tests__/file-item-parts.test.tsx b/components/files/__tests__/file-item-parts.test.tsx new file mode 100644 index 000000000..09c76a75a --- /dev/null +++ b/components/files/__tests__/file-item-parts.test.tsx @@ -0,0 +1,27 @@ +import { render } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; + +let appLocale = 'ar'; +vi.mock('@/i18n/detect-locale', () => ({ getEffectiveLocale: () => appLocale })); + +import { FileSize, formatFullDate, formatShortDate } from '../file-item-parts'; + +// The browser here is en-US; the app's language must win. +describe('Files list dates and sizes', () => { + afterEach(() => { appLocale = 'ar'; }); + + it('writes dates in the app language, not the browser one', () => { + const now = new Date('2026-10-27T12:00:00Z'); + expect(formatShortDate('2026-03-05T10:00:00Z', now)).toMatch(/مارس/); + expect(formatFullDate('2026-03-05T10:00:00Z')).toMatch(/مارس/); + appLocale = 'de'; + expect(formatShortDate('2026-03-05T10:00:00Z', now)).toMatch(/März/); + }); + + it('keeps the number before the unit in a right-to-left layout', () => { + const { container } = render(
); + const size = container.querySelector('bdi')!; + expect(size).toHaveAttribute('dir', 'ltr'); + expect(size.textContent).toBe('6.7 KB'); + }); +}); diff --git a/components/files/file-details-panel.tsx b/components/files/file-details-panel.tsx index c8e98a0a5..5636c64f2 100644 --- a/components/files/file-details-panel.tsx +++ b/components/files/file-details-panel.tsx @@ -4,12 +4,12 @@ import { useMemo } from "react"; import { useTranslations } from "next-intl"; import { X, Download, Share2, Star, StarOff, FolderOpen, Eye, SquarePen, Files } from "@/components/icons"; import { Button } from "@/components/ui/button"; -import { cn, formatFileSize } from "@/lib/utils"; +import { cn } from "@/lib/utils"; import type { FileResource } from "@/stores/file-store"; import type { FileResourceView } from "@/lib/plugin-types"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { - ResourceIcon, Thumbnail, canShowThumbnail, formatFullDate, type IconOptions, + FileSize, ResourceIcon, Thumbnail, canShowThumbnail, formatFullDate, type IconOptions, } from "@/components/files/file-item-parts"; interface FileDetailsPanelProps { @@ -91,7 +91,7 @@ export function FileDetailsPanel({

{t("folder_summary", { folders, files })}

- + } /> {selection.length <= 1 && }
{selection.length <= 1 && ( @@ -160,7 +160,7 @@ export function FileDetailsPanel({ label={t("type")} value={resource.isDirectory || resource.isSymlink ? kindOf(resource, t) : (resource.contentType || kindOf(resource, t))} /> - {!resource.isDirectory && !resource.isSymlink && } + {!resource.isDirectory && !resource.isSymlink && } />} {resource.lastModified && } @@ -170,7 +170,7 @@ export function FileDetailsPanel({ ); } -function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) { +function Row({ label, value, mono }: { label: string; value: React.ReactNode; mono?: boolean }) { return (
{label}
diff --git a/components/files/file-item-parts.tsx b/components/files/file-item-parts.tsx index 2b201fc3e..37a33b43e 100644 --- a/components/files/file-item-parts.tsx +++ b/components/files/file-item-parts.tsx @@ -3,8 +3,10 @@ import { useEffect, useRef, useState } from "react"; import { useTranslations } from "next-intl"; import { Share2, Users } from "@/components/icons"; -import { cn } from "@/lib/utils"; +import { cn, formatFileSize } from "@/lib/utils"; import { getEffectiveTimeZone } from "@/lib/timezone"; +import { getEffectiveLocale } from "@/i18n/detect-locale"; +import { useSettingsStore } from "@/stores/settings-store"; import type { FileResource } from "@/stores/file-store"; import { getFileIconByName, getFolderIcon, getSymlinkIcon, isImageFile, type FileIconSize } from "@/components/files/file-icons"; @@ -124,17 +126,33 @@ export function ShareBadge({ resource, t, className }: { return null; } +/** + * A size that keeps its number before its unit ("6.7 KB") in a right-to-left + * layout too, where the bidi algorithm would otherwise show "KB 6.7". + */ +export function FileSize({ bytes }: { bytes: number }) { + return {formatFileSize(bytes)}; +} + +/** Dates follow the app's language and 12/24-hour setting, as Mail's list does, not the browser's. */ +function dateLocale(): { locale: string; hour12: boolean } { + const locale = getEffectiveLocale(); + return { locale: locale === "en" ? "en-US" : locale, hour12: useSettingsStore.getState().timeFormat === "12h" }; +} + /** Full date and time, for tooltips and the details panel. */ export function formatFullDate(value: string): string { if (!value) return ""; try { - return new Date(value).toLocaleString(undefined, { + const { locale, hour12 } = dateLocale(); + return new Date(value).toLocaleString(locale, { timeZone: getEffectiveTimeZone(), year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", + hour12, }); } catch { return value; @@ -149,12 +167,13 @@ export function formatShortDate(value: string, now = new Date()): string { const timeZone = getEffectiveTimeZone(); const dayKey = (d: Date) => d.toLocaleDateString("en-CA", { timeZone }); try { + const { locale, hour12 } = dateLocale(); if (dayKey(date) === dayKey(now)) { - return date.toLocaleTimeString(undefined, { timeZone, hour: "2-digit", minute: "2-digit" }); + return date.toLocaleTimeString(locale, { timeZone, hour: "2-digit", minute: "2-digit", hour12 }); } const sameYear = date.toLocaleDateString("en-CA", { timeZone, year: "numeric" }) === now.toLocaleDateString("en-CA", { timeZone, year: "numeric" }); - return date.toLocaleDateString(undefined, { + return date.toLocaleDateString(locale, { timeZone, day: "numeric", month: "short", diff --git a/components/files/file-picker-dialog.tsx b/components/files/file-picker-dialog.tsx index 55a94e275..e49320ca0 100644 --- a/components/files/file-picker-dialog.tsx +++ b/components/files/file-picker-dialog.tsx @@ -175,7 +175,7 @@ export function FilePickerDialog({ client, onClose, onPick }: FilePickerDialogPr {node.name} {detail && {detail}} - {formatFileSize(node.size)} + {formatFileSize(node.size)} ); }; diff --git a/components/files/file-views.tsx b/components/files/file-views.tsx index f3d23336d..ce627400a 100644 --- a/components/files/file-views.tsx +++ b/components/files/file-views.tsx @@ -3,11 +3,11 @@ import { useRef } from "react"; import { useTranslations } from "next-intl"; import { ArrowUp, ArrowDown, MoreVertical, Download, Share2, Star, Check } from "@/components/icons"; -import { cn, formatFileSize } from "@/lib/utils"; +import { cn } from "@/lib/utils"; import { IconButton } from "@/components/ui/icon-button"; import type { FileResource } from "@/stores/file-store"; import { - ResourceIcon, ShareBadge, Thumbnail, canShowThumbnail, formatFullDate, formatShortDate, + FileSize, ResourceIcon, ShareBadge, Thumbnail, canShowThumbnail, formatFullDate, formatShortDate, type IconOptions, } from "@/components/files/file-item-parts"; import type { SortDir, SortKey } from "@/components/files/files-header"; @@ -271,7 +271,7 @@ function FileListRow({ resource, isSelected, isFocused, isDropTarget, isCut, isS
{note && <>{note} · } {formatShortDate(resource.lastModified)} - {!resource.isDirectory && !resource.isSymlink && <> · {formatFileSize(resource.contentLength)}} + {!resource.isDirectory && !resource.isSymlink && <> · }
)}
@@ -280,7 +280,7 @@ function FileListRow({ resource, isSelected, isFocused, isDropTarget, isCut, isS {!touch && formatShortDate(resource.lastModified)}
- {!touch && (resource.isDirectory || resource.isSymlink ? "—" : formatFileSize(resource.contentLength))} + {!touch && (resource.isDirectory || resource.isSymlink ? "—" : )}
{!touch && !resource.isDirectory && !resource.isSymlink && ( @@ -482,7 +482,7 @@ function FileTile({ resource, isSelected, isFocused, isCut, selecting, touch, ic
- {resource.isSymlink ? "" : `${formatFileSize(resource.contentLength)} · `}{formatShortDate(resource.lastModified)} + {!resource.isSymlink && <> · }{formatShortDate(resource.lastModified)}
diff --git a/components/filters/__tests__/filter-rule-modal.test.tsx b/components/filters/__tests__/filter-rule-modal.test.tsx index 6e9062919..b0fd0937b 100644 --- a/components/filters/__tests__/filter-rule-modal.test.tsx +++ b/components/filters/__tests__/filter-rule-modal.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { FilterRule } from '@/lib/jmap/sieve-types'; import { toast } from '@/stores/toast-store'; @@ -82,7 +82,8 @@ describe('FilterRuleModal incomplete action', () => { expect(onSave).not.toHaveBeenCalled(); expect(toast.error).not.toHaveBeenCalled(); - expect(screen.getByRole('alert').textContent).toBe('validation_empty_actions'); + // The row is there; what it misses is the folder, not "an action". + expect(screen.getByRole('alert').textContent).toBe('validation_action_folder'); const folder = screen.getByRole('combobox', { name: 'move_to_folder' }); expect(folder.getAttribute('aria-invalid')).toBe('true'); @@ -92,6 +93,44 @@ describe('FilterRuleModal incomplete action', () => { expect(onSave.mock.calls[0][0].actions).toEqual([{ type: 'move', mailboxId: 'news', value: 'News' }]); }); + it('marks the empty condition too, and focuses the first field to fix', async () => { + const onSave = vi.fn(); + render( {}} />); + fireEvent.change(screen.getByPlaceholderText('rule_name_placeholder'), { target: { value: 'News' } }); + fireEvent.click(screen.getByRole('button', { name: 'save' })); + + const value = screen.getByRole('textbox', { name: 'condition_value' }); + const folder = screen.getByRole('combobox', { name: 'move_to_folder' }); + expect(value.getAttribute('aria-invalid')).toBe('true'); + expect(folder.getAttribute('aria-invalid')).toBe('true'); + expect(screen.getAllByRole('alert').map((a) => a.textContent)).toEqual([ + 'validation_empty_conditions', + 'validation_action_folder', + ]); + // The condition comes first on the form, so focus goes there, not to + // the folder. + await waitFor(() => expect(document.activeElement).toBe(value)); + + fireEvent.change(value, { target: { value: 'news@example.com' } }); + expect(value.getAttribute('aria-invalid')).toBe('false'); + expect(onSave).not.toHaveBeenCalled(); + }); + + it('does not save a second action left without its folder', () => { + const onSave = vi.fn(); + render( {}} />); + fireEvent.change(screen.getByPlaceholderText('rule_name_placeholder'), { target: { value: 'News' } }); + fireEvent.change(screen.getByPlaceholderText('value_placeholder_multi'), { target: { value: 'news@example.com' } }); + fireEvent.change(screen.getByRole('combobox', { name: 'move_to_folder' }), { target: { value: 'news' } }); + fireEvent.click(screen.getByText('add_action')); + fireEvent.click(screen.getByRole('button', { name: 'save' })); + + expect(onSave).not.toHaveBeenCalled(); + const folders = screen.getAllByRole('combobox', { name: 'move_to_folder' }); + expect(folders.map((f) => f.getAttribute('aria-invalid'))).toEqual(['false', 'true']); + expect(screen.getByRole('alert').textContent).toBe('validation_action_folder'); + }); + it('starts a changed action without a folder too, like a new row', () => { render( {}} />); fireEvent.change(screen.getByRole('combobox', { name: 'actions' }), { target: { value: 'copy' } }); diff --git a/components/filters/__tests__/sieve-editor-modal.test.tsx b/components/filters/__tests__/sieve-editor-modal.test.tsx new file mode 100644 index 000000000..4cb27f407 --- /dev/null +++ b/components/filters/__tests__/sieve-editor-modal.test.tsx @@ -0,0 +1,68 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import type { FilterRule } from '@/lib/jmap/sieve-types'; +import { generateScript } from '@/lib/sieve/generator'; +import { SieveEditorModal } from '../sieve-editor-modal'; + +const visualRule: FilterRule = { + id: 'r1', + name: 'Receipts', + enabled: true, + matchType: 'all', + conditions: [{ field: 'from', comparator: 'contains', value: 'shop@example.com' }], + actions: [{ type: 'move', value: 'Receipts' }], + stopProcessing: false, +}; + +const handWritten = 'require ["fileinto"];\nif header :contains "Subject" "x" {\n fileinto "X";\n}\n'; + +function renderEditor(content: string, onSave = vi.fn()) { + render( + {}} onValidate={async () => ({ isValid: true })} />, + ); + return onSave; +} + +// The Save button stays "Save": it never turns into "Save anyway" and never +// needs a second click. Only a save that would drop the visual builder's +// rules asks, once, in a confirm dialog. +describe('SieveEditorModal save', () => { + it('saves at once when the visual rules are not lost', () => { + const onSave = renderEditor(handWritten); + fireEvent.change(screen.getByLabelText('script_content'), { target: { value: `${handWritten}# note\n` } }); + + fireEvent.click(screen.getByRole('button', { name: 'save' })); + + expect(onSave).toHaveBeenCalledTimes(1); + expect(onSave).toHaveBeenCalledWith(`${handWritten}# note\n`); + expect(screen.queryByRole('alertdialog')).toBeNull(); + }); + + it('asks once before a script the builder cannot read replaces the visual rules', async () => { + const onSave = renderEditor(generateScript([visualRule])); + fireEvent.change(screen.getByLabelText('script_content'), { target: { value: handWritten } }); + + fireEvent.click(screen.getByRole('button', { name: 'save' })); + + const dialog = await screen.findByRole('alertdialog'); + expect(dialog.textContent).toContain('overwrite_title'); + expect(onSave).not.toHaveBeenCalled(); + // The editor's own button keeps its label. + expect(screen.queryByText('confirm_save')).toBeNull(); + + fireEvent.click(screen.getAllByRole('button', { name: 'save' }).at(-1)!); + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + expect(onSave).toHaveBeenCalledWith(handWritten); + }); + + it('does not save when the user keeps editing', async () => { + const onSave = renderEditor(generateScript([visualRule])); + fireEvent.change(screen.getByLabelText('script_content'), { target: { value: handWritten } }); + + fireEvent.click(screen.getByRole('button', { name: 'save' })); + fireEvent.click(await screen.findByRole('button', { name: 'keep_editing' })); + + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); + expect(onSave).not.toHaveBeenCalled(); + }); +}); diff --git a/components/filters/filter-rule-modal.tsx b/components/filters/filter-rule-modal.tsx index e813d2331..9f4dabf21 100644 --- a/components/filters/filter-rule-modal.tsx +++ b/components/filters/filter-rule-modal.tsx @@ -260,7 +260,7 @@ export function FilterRuleModal({ }) .map((c) => (c.field === "header" ? { ...c, headerName: (c.headerName ?? "").trim() } : c)), [conditions]); - const validActions = useMemo(() => actions + const resolvedActions = useMemo(() => actions .map((a) => { if (!ACTIONS_WITH_MAILBOX.has(a.type)) return a; // Store the folder id next to the path and refresh the path from it, @@ -268,8 +268,17 @@ export function FilterRuleModal({ const mailboxId = mailboxIdFor(a); const path = mailboxId ? mailboxPathMap.get(mailboxId) : undefined; return mailboxId ? { ...a, mailboxId, value: path ?? a.value } : a; - }) - .filter((a) => !ACTIONS_WITH_VALUE.has(a.type) || a.value?.trim()), [actions, mailboxIdFor, mailboxPathMap]); + }), [actions, mailboxIdFor, mailboxPathMap]); + // A row still missing its folder, tag, address or message. + const actionIncomplete = useMemo( + () => resolvedActions.map((a) => ACTIONS_WITH_VALUE.has(a.type) && !a.value?.trim()), + [resolvedActions], + ); + const validActions = useMemo( + () => resolvedActions.filter((_, i) => !actionIncomplete[i]), + [resolvedActions, actionIncomplete], + ); + const firstIncompleteAction = resolvedActions.find((_, i) => actionIncomplete[i]); // Old mail can only be sorted by what the client can check the way Sieve // does. Until an action is complete (a Move still without its folder), only @@ -298,7 +307,17 @@ export function FilterRuleModal({ : validConditions.some((c) => c.field === "header" && !isValidSieveHeaderName(c.headerName ?? "")) ? t("validation_header_name") : null, - actions: validActions.length === 0 ? t("validation_empty_actions") : null, + // A row that is there but unfinished says what it misses; "at least one + // action" is left for when no row has a type at all. + actions: firstIncompleteAction + ? t(ACTIONS_WITH_MAILBOX.has(firstIncompleteAction.type) + ? "validation_action_folder" + : firstIncompleteAction.type === "add_label" + ? "validation_action_tag" + : firstIncompleteAction.type === "forward" + ? "validation_action_forward" + : "validation_action_reject") + : validActions.length === 0 ? t("validation_empty_actions") : null, period: !periodOn ? null // A field the browser holds half filled in (a date without its time) @@ -313,6 +332,14 @@ export function FilterRuleModal({ }; const hasErrors = Object.values(errors).some(Boolean); const shown = (error: string | null) => (showErrors ? error : null); + // The fields the shown errors are about: every empty value while no + // condition has one, a header name Sieve refuses, an unfinished action. + const valueMissing = (c: FilterCondition) => + !!shown(errors.conditions) && validConditions.length === 0 && !takesNoValue(c) && isConditionValueEmpty(c.value); + const headerInvalid = (c: FilterCondition) => + !!shown(errors.conditions) && c.field === "header" && !isConditionValueEmpty(c.value) && + !isValidSieveHeaderName((c.headerName ?? "").trim()); + const actionInvalid = (index: number) => !!shown(errors.actions) && actionIncomplete[index]; const handleSave = useCallback(() => { const halfFilled = periodOn && @@ -535,7 +562,10 @@ export function FilterRuleModal({ updateCondition(index, { headerName: e.target.value }) } placeholder={t("header_name")} - className="w-28" + aria-label={t("header_name")} + aria-invalid={headerInvalid(condition)} + data-rule-error={headerInvalid(condition)} + className={cn("w-28", headerInvalid(condition) && "border-destructive")} /> )} @@ -598,7 +628,11 @@ export function FilterRuleModal({ ? t("attachment_type_placeholder") : t("value_placeholder_multi") } - className="flex-1 min-w-[120px]" + aria-label={t("condition_value")} + aria-invalid={valueMissing(condition)} + aria-describedby={valueMissing(condition) ? "rule-conditions-error" : undefined} + data-rule-error={valueMissing(condition)} + className={cn("flex-1 min-w-[120px]", valueMissing(condition) && "border-destructive")} type={condition.field === "size" ? "number" : "text"} /> )} @@ -622,7 +656,7 @@ export function FilterRuleModal({ {t("add_condition")} - +
@@ -656,6 +690,7 @@ export function FilterRuleModal({ {ACTIONS_WITH_MAILBOX.has(action.type) && (() => { const selectedId = mailboxIdFor(action); + const invalid = actionInvalid(index); const missing = !!selectedId && !mailboxPathMap.has(selectedId); return ( updateAction(index, { value: e.target.value })} placeholder={t("reject_placeholder")} - className="flex-1 min-w-[180px]" + aria-label={t("reject_message")} + aria-invalid={actionInvalid(index)} + aria-describedby={actionInvalid(index) ? "rule-actions-error" : undefined} + data-rule-error={actionInvalid(index)} + className={cn("flex-1 min-w-[180px]", actionInvalid(index) && "border-destructive")} /> )} @@ -717,8 +761,11 @@ export function FilterRuleModal({ setSearch(e.target.value)} @@ -60,7 +60,7 @@ export function IconPicker({ value, onChange, className }: IconPickerProps) { {search && ( diff --git a/components/layout/mobile-header.tsx b/components/layout/mobile-header.tsx index 85267493e..c04c36b67 100644 --- a/components/layout/mobile-header.tsx +++ b/components/layout/mobile-header.tsx @@ -121,6 +121,7 @@ export function MobileHeader({ nesting one inside another is invalid markup. */
(e: React.MouseEvent) => { + if (interceptLeave(e)) return; if (onNavigate) { const intercepted = onNavigate(itemId); if (intercepted !== false) { @@ -421,7 +423,7 @@ export function NavigationRail({ { if (activeAppId) onCloseInlineApp?.(); }} + onClick={(e) => { if (interceptLeave(e)) return; if (activeAppId) onCloseInlineApp?.(); }} data-plugin-page-link={`${page.pluginId}/${page.id}`} title={page.pluginName} className={cn( @@ -517,6 +519,7 @@ export function NavigationRail({ { if (activeAppId) onCloseInlineApp?.(); }} + onClick={(e) => { if (interceptLeave(e)) return; if (activeAppId) onCloseInlineApp?.(); }} data-plugin-page-link={`${page.pluginId}/${page.id}`} className={cn( "relative flex items-center gap-2.5 rounded-md transition-colors duration-150", diff --git a/components/mail/__tests__/mail-palette-commands.test.tsx b/components/mail/__tests__/mail-palette-commands.test.tsx index 11744d27d..1c9539fa8 100644 --- a/components/mail/__tests__/mail-palette-commands.test.tsx +++ b/components/mail/__tests__/mail-palette-commands.test.tsx @@ -108,6 +108,24 @@ describe('Mail in the command palette', () => { expect(ids).not.toContain('mark-read'); }); + it("says whose a shared folder is and where a subfolder is, so two Inboxes don't look the same", async () => { + const shared = { ...folder('support:inbox', 'inbox'), isShared: true, accountName: 'Support', accountId: 'support', originalId: 'inbox' } as Mailbox; + const archive = folder('archive', 'archive', 'Archive'); + const year = { ...folder('y2025', undefined, '2025'), parentId: 'archive' } as Mailbox; + const all = [...folders, shared, archive, year]; + useEmailStore.setState({ mailboxes: all, selectedMailbox: 'work' } as never); + client.getMailboxes = vi.fn().mockResolvedValue(all); + let groups: CommandGroup[] = []; + render(<> { groups = g; }} />); + await screen.findByText('viewer', {}, { timeout: 5000 }); + await waitFor(() => expect(groups.find((g) => g.label === 'sidebar.mail')).toBeDefined()); + // The test translator drops parameters; the place also goes in the keywords. + const where = (id: string) => groups.find((g) => g.label === 'sidebar.mail')!.commands.find((c) => c.id === id)!.keywords?.slice(1); + expect(where('go-support:inbox')).toEqual(['Support']); + expect(where('go-inbox')).toEqual([]); + expect(where('go-y2025')?.[0]).toMatch(/archive/i); + }); + it('moves the open message from "Move to…"', async () => { let groups: CommandGroup[] = []; render(<> { groups = g; }} />); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 49b9b2d4d..61f4ea543 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -1136,7 +1136,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { handlers: keyboardHandlers, }); - // Compose and search from the command palette (components/command-palette). + // Compose and search from the command palette (components/command-palette); + // the tour closes the phone's folder drawer and reader to point at the list + // and the tab bar. useAppCommands({ compose: keyboardHandlers.onCompose, search: () => { @@ -1144,6 +1146,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { else keyboardHandlers.onFocusSearch?.(); }, 'all-inboxes': () => handleMailboxSelect(UNIFIED_MAILBOX_IDS.inbox), + 'show-list': () => { + if (!isMobile) return; + if (sidebarOpen) setSidebarOpen(false); + if (!showComposer && (activeView !== "list" || selectedEmail)) handleMobileBack(); + }, }, isAuthenticated && !!client); // Shared mail refresh: re-fetch mailboxes (unread counts) + the active list @@ -4215,6 +4222,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // "Go to" for every folder. Compose is the palette's own. const paletteEmail = isScheduledView ? null : selectedEmail; const hasPaletteEmail = () => !!paletteEmail; + const paletteMailboxesById = new Map(mailboxes.map((mb) => [mb.id, mb])); + const paletteMailboxName = (mb: Mailbox) => localizeMailboxName(mb.role, mb.name, (k) => t(`sidebar.mailboxes.${k}`)); useRegisterCommands(t('sidebar.mail'), [ { id: 'reply', label: t('context_menu.reply'), icon: Reply, shortcut: 'r', when: hasPaletteEmail, run: () => void handleReply() }, { id: 'reply-all', label: t('context_menu.reply_all'), icon: ReplyAll, shortcut: 'a', when: hasPaletteEmail, run: () => void handleReplyAll() }, @@ -4248,12 +4257,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { ...mailboxes .filter((mb) => !mb.id.startsWith('shared-') && mb.id !== selectedMailbox) .map((mb) => { - const name = localizeMailboxName(mb.role, mb.name, (k) => t(`sidebar.mailboxes.${k}`)); + const name = paletteMailboxName(mb); + // Two Inboxes (yours and a shared Support account's), or "2025" in + // Archive and in Receipts: say whose, or where, it is. + const parent = mb.parentId ? paletteMailboxesById.get(mb.parentId) : undefined; + const where = [mb.isShared ? mb.accountName : undefined, parent ? paletteMailboxName(parent) : undefined] + .filter(Boolean).join(' / '); return { id: `go-${mb.id}`, - label: t('context_menu.go_to_folder', { folder: name }), + label: t('context_menu.go_to_folder', { folder: where ? `${name} · ${where}` : name }), icon: Folder, - keywords: [name], + keywords: where ? [name, where] : [name], run: () => void handleMailboxSelect(mb.id), }; }), diff --git a/components/settings/__tests__/address-book-management-settings.test.tsx b/components/settings/__tests__/address-book-management-settings.test.tsx index 69c7fec76..1fd7554ab 100644 --- a/components/settings/__tests__/address-book-management-settings.test.tsx +++ b/components/settings/__tests__/address-book-management-settings.test.tsx @@ -55,7 +55,7 @@ describe('address books in Settings', () => { expect(deletes).toHaveLength(1); fireEvent.click(deletes[0]); - const dialog = within(screen.getByRole('alertdialog')); + const dialog = within(screen.getByRole('alertdialog', { name: 'delete_title' })); expect(removeAddressBook).not.toHaveBeenCalled(); fireEvent.click(dialog.getByRole('button', { name: 'delete' })); await waitFor(() => expect(removeAddressBook).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ id: 'club' }))); diff --git a/components/settings/__tests__/calendar-management-settings.test.tsx b/components/settings/__tests__/calendar-management-settings.test.tsx index af1f4d79d..439eb43d6 100644 --- a/components/settings/__tests__/calendar-management-settings.test.tsx +++ b/components/settings/__tests__/calendar-management-settings.test.tsx @@ -72,3 +72,47 @@ describe('CalendarManagementSettings CalDAV fallback address', () => { vi.restoreAllMocks(); }); }); + +// Phone: seven icons in a row hid the calendar names. The row actions are +// also in a ⋮ menu (right-click too), the same list as the icons, and every +// icon is a named button with the touch hit area. +describe('CalendarManagementSettings row actions', () => { + const work = { + id: 'work', name: 'Work', color: '#ff0000', isDefault: false, isShared: false, shareWith: null, sortOrder: 1, + myRights: { mayShare: true, mayDelete: true, mayWriteAll: true }, + } as unknown as Calendar; + const home = { ...appleColored, color: '#00ff00', sortOrder: 0 } as Calendar; + + beforeEach(() => { + useAuthStore.setState({ client: { getAccountId: () => 'acct-1' }, serverUrl: null, username: null } as never); + useCalendarStore.setState({ calendars: [home, work], icalSubscriptions: [], updateCalendar: vi.fn() } as never); + }); + + it('offers the row actions from a named ⋮ menu, Delete last and only where it applies', async () => { + render(); + const more = screen.getAllByRole('button', { name: 'row_actions' }); + expect(more).toHaveLength(2); + + fireEvent.click(more[1]); // Work, not the default calendar + const menu = await screen.findByRole('menu'); + const items = Array.from(menu.querySelectorAll('[role="menuitem"]')).map((el) => el.textContent); + expect(items).toEqual(['edit', 'default_alerts', 'share', 'clear_events', 'move_up', 'move_down', 'delete']); + + fireEvent.click(screen.getByRole('menuitem', { name: 'delete' })); + expect(await screen.findByRole('alertdialog')).toBeTruthy(); + }); + + it('leaves Delete out of the default calendar menu', async () => { + render(); + fireEvent.click(screen.getAllByRole('button', { name: 'row_actions' })[0]); + const menu = await screen.findByRole('menu'); + expect(Array.from(menu.querySelectorAll('[role="menuitem"]')).map((el) => el.textContent)).not.toContain('delete'); + }); + + it('names every row icon', () => { + render(); + for (const name of ['move_up', 'move_down', 'edit', 'default_alerts', 'share', 'clear_events', 'delete', 'change_color']) { + expect(screen.getAllByRole('button', { name }).length).toBeGreaterThan(0); + } + }); +}); diff --git a/components/settings/__tests__/keyword-settings.test.tsx b/components/settings/__tests__/keyword-settings.test.tsx index 333a37ecf..6e994ce58 100644 --- a/components/settings/__tests__/keyword-settings.test.tsx +++ b/components/settings/__tests__/keyword-settings.test.tsx @@ -89,6 +89,17 @@ describe('KeywordSettings', () => { expect(useSettingsStore.getState().emailKeywords.find((k) => k.id === 'red')).toBeUndefined(); }); + // A list of "Edit" / "Delete" with nothing else said which tag a screen + // reader user was on; each button is named after its tag now, as a full + // size icon button (32px, 44px hit area on touch). + it('names each tag row button after its tag', () => { + render(); + expect(screen.getAllByRole('button', { name: 'edit_tag' })).toHaveLength(DEFAULT_KEYWORDS.length); + const deletes = screen.getAllByRole('button', { name: 'delete_tag' }); + expect(deletes).toHaveLength(DEFAULT_KEYWORDS.length); + expect(deletes[0].className).toContain('h-8 w-8'); + }); + it('shows edit form when edit button clicked', () => { render(); const editButtons = screen.getAllByTitle('edit'); diff --git a/components/settings/__tests__/settings-delete-undo.test.tsx b/components/settings/__tests__/settings-delete-undo.test.tsx index 63b633221..5433e965a 100644 --- a/components/settings/__tests__/settings-delete-undo.test.tsx +++ b/components/settings/__tests__/settings-delete-undo.test.tsx @@ -45,7 +45,8 @@ describe('deleting a tag', () => { render(); const second = DEFAULT_KEYWORDS[1]; - fireEvent.click(screen.getAllByRole('button', { name: 'delete' })[1]); + // Each Delete is named after its tag (the mocked t returns the key). + fireEvent.click(screen.getAllByRole('button', { name: 'delete_tag' })[1]); expect(useSettingsStore.getState().emailKeywords.map((k) => k.id)).not.toContain(second.id); expect(screen.queryByRole('alertdialog')).toBeNull(); diff --git a/components/settings/__tests__/settings-palette-commands.test.tsx b/components/settings/__tests__/settings-palette-commands.test.tsx new file mode 100644 index 000000000..4d983ede1 --- /dev/null +++ b/components/settings/__tests__/settings-palette-commands.test.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { act, render } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import en from '@/locales/en/common.json'; + +// #40 / SET-27: in Settings the palette offered only "Go to Settings", and +// "out of office" found nothing. Each page the list shows is a command now, +// found by the words the settings search knows. + +vi.mock('next-intl', () => ({ + useTranslations: (ns?: string) => (key: string, values?: Record) => + `${ns ? `${ns}.` : ''}${key}${values?.name ? `(${values.name})` : ''}`, + useMessages: () => en, + useLocale: () => 'en', + useFormatter: () => ({ dateTime: String, relativeTime: String, number: String }), +})); +vi.mock('@/hooks/use-media-query', () => ({ + useIsDesktop: () => true, + useIsMobile: () => false, + useMediaQuery: () => false, + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), +})); +vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail', stalwartFeaturesEnabled: true }) })); +vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null })); +vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null })); +vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null })); +vi.mock('@/components/settings/appearance-settings', () => ({ AppearanceSettings: () => null })); + +const { SettingsApp } = await import('../settings-app'); +const { useAuthStore } = await import('@/stores/auth-store'); +const { usePluginStore } = await import('@/stores/plugin-store'); +const { useThemeStore } = await import('@/stores/theme-store'); +const { useRegisteredCommandGroups, commandMatches } = await import('@/lib/command-registry'); + +type Groups = ReturnType; +let groups: Groups = []; +function Probe() { + groups = useRegisteredCommandGroups(); + return null; +} + +beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request'))); + // A server with Sieve and vacation, so Filters and Out of office are pages. + const client = new Proxy({}, { + get: (_t, prop) => { + if (prop === 'then') return undefined; + if (prop === 'getAccountId') return () => 'acc'; + if (typeof prop === 'string' && prop.startsWith('supports')) return () => true; + return () => null; + }, + }); + useAuthStore.setState({ isAuthenticated: true, isLoading: false, client: client as never }); + usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) }); + useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) } as never); + try { localStorage.setItem('settings-active-tab', 'appearance'); } catch { /* ignore */ } +}); +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +function settingsCommands() { + const group = groups.find((g) => g.label === 'sidebar.settings'); + return group?.commands ?? []; +} + +function find(query: string) { + return settingsCommands().filter((c) => commandMatches(query, c.label, c.keywords)); +} + +describe('Settings commands in the palette', () => { + it('offers "Go to " for the settings pages', async () => { + render(<>); + await act(async () => { await vi.advanceTimersByTimeAsync(100); }); + + const ids = settingsCommands().map((c) => c.id); + expect(ids).toEqual(expect.arrayContaining(['go-vacation', 'go-filters', 'go-security', 'go-account'])); + expect(settingsCommands().find((c) => c.id === 'go-vacation')?.label).toBe('command_palette.go_to(settings.tabs.vacation)'); + }); + + it('finds pages by the words the settings search knows', async () => { + render(<>); + await act(async () => { await vi.advanceTimersByTimeAsync(100); }); + + expect(find('out of office').map((c) => c.id)).toContain('go-vacation'); + expect(find('app password').map((c) => c.id)).toContain('go-security'); + expect(find('add rule').map((c) => c.id)).toContain('go-filters'); + }); + + it('opens the page it names', async () => { + const tree = () => <>; + const { rerender } = render(tree()); + await act(async () => { await vi.advanceTimersByTimeAsync(100); }); + + act(() => settingsCommands().find((c) => c.id === 'go-filters')!.run()); + expect(localStorage.getItem('settings-active-tab')).toBe('filters'); + // The palette reads the commands when it opens, after Settings drew + // the new page; a second render stands in for that here. + rerender(tree()); + rerender(tree()); + // The page that is open is not offered. + expect(settingsCommands().map((c) => c.id)).not.toContain('go-filters'); + }); +}); diff --git a/components/settings/account-security-settings.tsx b/components/settings/account-security-settings.tsx index 8514f5218..11697c9b6 100644 --- a/components/settings/account-security-settings.tsx +++ b/components/settings/account-security-settings.tsx @@ -522,7 +522,7 @@ function CredentialSection({ icon: Icon, i18nNamespace, entries, onCreate, onRem

{tk('description')}

@@ -750,7 +750,7 @@ function PublicKeysSection() {

{t('encryption.description')}

@@ -780,7 +780,7 @@ function PublicKeysSection() {
+ setEditingId(book.id)} className="text-muted-foreground"> + + )} {canSetDefault && ( - + handleSetDefault(book)} disabled={isLoading} className="text-muted-foreground"> + + )} {!book.isShared && book.myRights?.mayShare && ( - + setSharingId(book.id)} className="text-muted-foreground"> + + )} {canDelete && ( - + + )}
diff --git a/components/settings/calendar-management-settings.tsx b/components/settings/calendar-management-settings.tsx index 4c56f0e69..f8841ba76 100644 --- a/components/settings/calendar-management-settings.tsx +++ b/components/settings/calendar-management-settings.tsx @@ -9,7 +9,10 @@ import { useAuthStore } from '@/stores/auth-store'; import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot'; import { toast } from '@/stores/toast-store'; import { SettingsSection } from './settings-section'; -import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser, Users, Bell, ArrowUp, ArrowDown, GripVertical } from '@/components/icons'; +import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser, Users, Bell, ArrowUp, ArrowDown, GripVertical, MoreVertical } from '@/components/icons'; +import { IconButton } from '@/components/ui/icon-button'; +import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu'; +import { useContextMenu } from '@/hooks/use-context-menu'; import { AlertListEditor } from '@/components/calendar/alert-list-editor'; import { alertsToRows, rowsToAlerts, type AlertRow } from '@/lib/calendar-alerts'; import { ShareCollectionDialog } from './share-collection-dialog'; @@ -287,6 +290,7 @@ export function CalendarManagementSettings() { const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor); const colorPickerRef = useRef(null); const [draggingId, setDraggingId] = useState(null); + const { contextMenu, openContextMenu, openContextMenuFrom, closeContextMenu, menuRef } = useContextMenu(); const listedCalendars = calendars .filter(cal => !isSubscriptionCalendar(cal.id)) @@ -560,8 +564,64 @@ export function CalendarManagementSettings() { } }; + // One action list per calendar (R6): the row's icons on wider screens, the + // ⋮ and right-click menu everywhere. + const calendarActions = (cal: Calendar) => { + const actions: Array<{ id: string; label: string; icon: typeof Pencil; run: () => void; destructive?: boolean }> = [ + { id: 'edit', label: t('edit'), icon: Pencil, run: () => setEditingId(cal.id) }, + ]; + // The owner's defaults; a sharee can't write the calendar. + if (!cal.isShared) { + actions.push({ id: 'alerts', label: t('default_alerts'), icon: Bell, run: () => setAlertsEditingId(cal.id) }); + } + if (cal.myRights?.mayShare && !cal.isShared && !isSubscriptionCalendar(cal.id)) { + actions.push({ id: 'share', label: t('share'), icon: Users, run: () => setSharingId(cal.id) }); + } + actions.push({ id: 'clear', label: t('clear_events'), icon: Eraser, run: () => setClearingId(cal.id) }); + if (!cal.isDefault) { + actions.push({ id: 'delete', label: t('delete'), icon: Trash2, run: () => setDeletingId(cal.id), destructive: true }); + } + return actions; + }; + + const renderRowMenu = () => { + const cal = contextMenu.data; + if (!cal) return null; + const run = (fn: () => void) => () => { closeContextMenu(); fn(); }; + const siblings = cal.isShared ? [] : orderableCalendars.filter(c => calendarAccountKey(c) === calendarAccountKey(cal)); + const orderIndex = siblings.indexOf(cal); + const previousId = siblings[orderIndex - 1]?.id; + const nextId = siblings[orderIndex + 1]?.id; + const buildUrl = buildCalDavUrl(cal.id); + const actions = calendarActions(cal); + const plain = actions.filter((a) => !a.destructive); + const destructive = actions.filter((a) => a.destructive); + return ( + + {plain.map((a) => ( + + ))} + {buildUrl && ( + handleCopyUrl(buildUrl))} /> + )} + {siblings.length > 1 && ( + <> + + void handleMove(cal.id, previousId))} /> + void handleMove(cal.id, nextId))} /> + + )} + {destructive.length > 0 && } + {destructive.map((a) => ( + + ))} + + ); + }; + return ( + {renderRowMenu()}
{listedCalendars.map((cal) => { const color = (cal.isShared && sharedCalendarColors[sharedCalendarColorKey(cal)]) || cal.color || '#3b82f6'; @@ -611,8 +671,9 @@ export function CalendarManagementSettings() { onDragOver={isOrderable && draggingId ? (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } : undefined} onDrop={isOrderable && draggingId ? (e) => { e.preventDefault(); void handleMove(draggingId, cal.id); setDraggingId(null); } : undefined} onDragEnd={() => setDraggingId(null)} + onContextMenu={(e) => openContextMenu(e, cal)} className={cn( - "flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group", + "flex items-center gap-2 sm:gap-3 py-2.5 px-3 rounded-md border border-border bg-background group", draggingId === cal.id && "opacity-50", )} > @@ -628,6 +689,7 @@ export function CalendarManagementSettings() { className="w-5 h-5 rounded-full shrink-0 transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" style={{ backgroundColor: color }} title={t('change_color')} + aria-label={t('change_color')} /> {/* Inline color picker popover */} @@ -648,7 +710,7 @@ export function CalendarManagementSettings() {
- {cal.name} + {cal.name} {(() => { const caldavUrl = buildCalDavUrl(cal.id); if (!caldavUrl) return null; @@ -658,24 +720,23 @@ export function CalendarManagementSettings() { {caldavUrl} - + +
); })()}
{cal.isDefault && ( - + {t('default')} )} @@ -689,7 +750,7 @@ export function CalendarManagementSettings() { : t('shared_with_count', { count: shareCount }); return ( @@ -700,81 +761,44 @@ export function CalendarManagementSettings() { ); })()} + {/* Phone: seven icons next to the name left it a letter or two + and pushed the Default badge over the copy button, so the + row actions sit behind a ⋮ there, as for folders. */} + openContextMenuFrom(e.currentTarget, cal, 'end')} + > + + + {/* Shown for touch too: there is no hover, and no HTML drag and drop. */} {isOrderable && ( -
- - +
+ handleMove(cal.id, previousId)} disabled={!previousId} className="text-muted-foreground"> + + + handleMove(cal.id, nextId)} disabled={!nextId} className="text-muted-foreground"> + +
)} -
- - {/* The owner's defaults; a sharee can't write the calendar. */} - {!cal.isShared && ( - - )} - {cal.myRights?.mayShare && !cal.isShared && !isSubscriptionCalendar(cal.id) && ( - - )} - - {!cal.isDefault && ( - - )} + + + ))}
); @@ -855,32 +879,25 @@ export function CalendarManagementSettings() {
)}
-
- - - + +
); diff --git a/components/settings/keyword-settings.tsx b/components/settings/keyword-settings.tsx index 27539452c..d2438404f 100644 --- a/components/settings/keyword-settings.tsx +++ b/components/settings/keyword-settings.tsx @@ -16,6 +16,8 @@ import { useEmailStore } from "@/stores/email-store"; import { useFilterStore } from "@/stores/filter-store"; import { SettingsSection, SettingItem, ToggleSwitch, Select, AdvancedOnly } from './settings-section'; import { Plus, Pencil, Trash2, GripVertical, Check, X, Loader2, Search } from "@/components/icons"; +import { Button } from "@/components/ui/button"; +import { IconButton } from "@/components/ui/icon-button"; import { cn } from "@/lib/utils"; import { KEYWORD_PREFIX } from "@/lib/thread-utils"; import { findUnrecognizedKeywords, type UnrecognizedKeyword } from "@/lib/keyword-discovery"; @@ -93,6 +95,7 @@ function KeywordRow({ }) { const t = useTranslations("settings.keywords"); const hasChildren = hasChildKeywords(keyword.id, keywords); + const tagLabel = keyword.label || keyword.id; // Measured with the prefix attached, since that is what occupies the column. const keywordCandidates = (nestedTags ? keywordRenderings(keywordLevels(keyword.id)) : [keyword.id]) .map((rendering) => KEYWORD_PREFIX + rendering); @@ -134,24 +137,19 @@ function KeywordRow({ ariaLabel={t("visibility_field")} className="text-xs py-1" /> -
- - + +
); @@ -727,17 +725,18 @@ export function KeywordSettings() { /> ) : (
- +
)}
diff --git a/components/settings/settings-app.tsx b/components/settings/settings-app.tsx index e5e24d08b..7bbd2d664 100644 --- a/components/settings/settings-app.tsx +++ b/components/settings/settings-app.tsx @@ -27,6 +27,7 @@ import { X, type AppIcon, } from '@/components/icons'; +import { useRegisterCommands } from '@/lib/command-registry'; import { Button } from '@/components/ui/button'; import { IconButton } from '@/components/ui/icon-button'; import { Input } from '@/components/ui/input'; @@ -262,6 +263,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps = const router = useRouter(); const t = useTranslations('settings'); const tSidebar = useTranslations('sidebar'); + const tPalette = useTranslations('command_palette'); useDocumentTitle(tSidebar('settings')); const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); @@ -656,10 +658,6 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps = } }, [activeTab]); - if (!isAuthenticated) { - return null; - } - const supportsVacation = client?.supportsVacationResponse() ?? false; const supportsCalendar = client?.supportsCalendars() ?? false; const supportsSieve = client?.supportsSieve() ?? false; @@ -786,6 +784,26 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps = setPendingHighlight({ tab: tabId, label: sub.label, pluginId: sub.pluginId, advanced: sub.advanced }); }; + // Ctrl/⌘+K in Settings: one "Go to " per page shown in the list, + // found by the same words as the settings search ("out of office", "app + // password", "add rule"). + useRegisterCommands( + tSidebar('settings'), + visibleTabs.map((tab) => ({ + id: `go-${tab.id}`, + label: tPalette('go_to', { name: tab.label }), + icon: tab.icon, + keywords: [tabSearchHaystacks[tab.id as Tab] ?? ''], + run: () => handleTabSelect(tab.id), + when: () => tab.id !== effectiveActiveTab, + })), + isAuthenticated, + ); + + if (!isAuthenticated) { + return null; + } + const activeTabLabel = visibleTabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? ''; const renderTabContent = () => ( @@ -909,7 +927,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
-
+
diff --git a/components/tour/__tests__/tour-provider.test.tsx b/components/tour/__tests__/tour-provider.test.tsx new file mode 100644 index 000000000..15f00d319 --- /dev/null +++ b/components/tour/__tests__/tour-provider.test.tsx @@ -0,0 +1,111 @@ +import { act, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Which steps a run of the tour counts, and where it takes the user. + +const env = vi.hoisted(() => ({ + phone: true, + pathname: '/mail/folder/inbox', + push: vi.fn(), + command: vi.fn(), +})); + +vi.mock('@/i18n/navigation', () => ({ + useRouter: () => ({ push: env.push }), + usePathname: () => env.pathname, +})); +vi.mock('@/hooks/use-media-query', () => ({ + useMediaQuery: () => env.phone, + useIsMobile: () => env.phone, +})); +vi.mock('@/lib/app-commands', () => ({ requestAppCommand: env.command })); +vi.mock('../tour-overlay', () => ({ TourOverlay: () => null })); +vi.mock('@/stores/auth-store', () => ({ + useAuthStore: (sel: (s: unknown) => unknown) => sel({ isDemoMode: true, client: { supportsContacts: () => true } }), +})); +vi.mock('@/stores/calendar-store', () => ({ + useCalendarStore: (sel: (s: unknown) => unknown) => sel({ supportsCalendar: true }), +})); +vi.mock('@/stores/webdav-store', () => ({ + useWebDAVStore: (sel: (s: unknown) => unknown) => sel({ supportsWebDAV: true }), +})); +vi.mock('@/stores/policy-store', () => ({ + usePolicyStore: (sel: (s: unknown) => unknown) => sel({ isFeatureEnabled: () => true }), +})); +const updateSetting = vi.fn(); +vi.mock('@/stores/settings-store', () => ({ + useSettingsStore: (sel: (s: unknown) => unknown) => sel({ tourCompleted: false, showOnboardingOnNewDevices: false, updateSetting }), +})); + +const { TourProvider, useTour } = await import('../tour-provider'); +const { getTourSteps } = await import('../tour-steps'); + +let api: ReturnType; +function Probe() { + api = useTour(); + const step = api.steps[api.currentStep]; + return

{api.isActive ? `${api.currentStep + 1}/${api.totalSteps}/${step?.id}` : 'off'}

; +} +const probe = () => screen.getByTestId('probe').textContent; + +beforeEach(() => { + env.phone = true; + env.pathname = '/mail/folder/inbox'; + env.push.mockClear(); + env.command.mockClear(); + localStorage.clear(); +}); + +function start() { + render(); + act(() => api.startTour()); +} + +describe('tour run', () => { + it('counts only the steps a phone shows, starting at step 1', () => { + start(); + const shown = getTourSteps({ isDemoMode: true, supportsCalendar: true, supportsWebDAV: true, supportsContacts: true, touch: true, phone: true }); + expect(probe()).toBe(`1/${shown.length}/compose`); + }); + + it('drops a step that has nothing to point at, so the count stays whole', () => { + start(); + const total = api.totalSteps; + act(() => api.nextStep()); + expect(probe()).toBe(`2/${total}/search`); + act(() => api.skipStep(1)); + expect(probe()).toBe(`2/${total - 1}/email-list`); + act(() => api.skipStep(-1)); + expect(probe()).toBe(`1/${total - 2}/compose`); + }); + + it('closes the phone reader for Mail steps but not for the reader step', () => { + start(); + expect(env.command).toHaveBeenLastCalledWith('show-list'); + while (api.steps[api.currentStep].id !== 'email-viewer') act(() => api.nextStep()); + env.command.mockClear(); + env.pathname = '/mail/message/m1'; + act(() => api.nextStep()); + // The next Mail step (Calendar in the tab bar) needs the list back. + expect(api.steps[api.currentStep].id).toBe('nav-calendar'); + expect(env.command).toHaveBeenCalledWith('show-list'); + env.command.mockClear(); + act(() => api.prevStep()); + expect(env.command).not.toHaveBeenCalled(); + }); + + it('takes the user to the app of the step', () => { + start(); + while (api.steps[api.currentStep].id !== 'calendar-view') act(() => api.nextStep()); + expect(env.push).toHaveBeenLastCalledWith('/calendar'); + // File storage is shown over Files. + while (api.steps[api.currentStep].id !== 'nav-files') act(() => api.nextStep()); + expect(env.push).toHaveBeenLastCalledWith('/files'); + }); + + it('leaves a desktop reader open', () => { + env.phone = false; + start(); + expect(env.command).not.toHaveBeenCalled(); + }); +}); diff --git a/components/tour/__tests__/tour.test.tsx b/components/tour/__tests__/tour.test.tsx index 6c0b1de38..ac5d0e33b 100644 --- a/components/tour/__tests__/tour.test.tsx +++ b/components/tour/__tests__/tour.test.tsx @@ -4,29 +4,37 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // The tour on a phone, where the rail's anchors are not rendered, and its // keyboard handling. +const nav = vi.hoisted(() => ({ pathname: '/' })); +vi.mock('@/i18n/navigation', () => ({ usePathname: () => nav.pathname })); + const tour = vi.hoisted(() => ({ currentStep: 0, nextStep: vi.fn(), prevStep: vi.fn(), + skipStep: vi.fn(), stopTour: vi.fn(), + // The same objects on every render, as the provider's steps are. + steps: [ + { id: 'one', target: '[data-tour="one"]', titleKey: 'one', descriptionKey: 'one_desc', placement: 'right' }, + { id: 'two', target: '[data-tour="two"]', titleKey: 'two', descriptionKey: 'two_desc', placement: 'right' }, + { id: 'three', target: '[data-tour="three"]', titleKey: 'three', descriptionKey: 'three_desc', placement: 'right' }, + { id: 'files', target: '[data-tour="four"]', titleKey: 'four', descriptionKey: 'four_desc', placement: 'right', page: '/files' }, + ], })); vi.mock('../tour-provider', () => ({ useTour: () => ({ currentStep: tour.currentStep, totalSteps: 3, - steps: [ - { id: 'one', target: '[data-tour="one"]', titleKey: 'one', descriptionKey: 'one_desc', placement: 'right' }, - { id: 'two', target: '[data-tour="two"]', titleKey: 'two', descriptionKey: 'two_desc', placement: 'right' }, - { id: 'three', target: '[data-tour="three"]', titleKey: 'three', descriptionKey: 'three_desc', placement: 'right' }, - ], + steps: tour.steps, nextStep: tour.nextStep, prevStep: tour.prevStep, + skipStep: tour.skipStep, stopTour: tour.stopTour, }), })); -const { TourOverlay, findTourTarget } = await import('../tour-overlay'); +const { TourOverlay, findTourTarget, computeTooltipPosition } = await import('../tour-overlay'); const { getTourSteps } = await import('../tour-steps'); /** jsdom lays nothing out: give the elements marked `data-size` a box. */ @@ -34,8 +42,10 @@ const realRect = HTMLElement.prototype.getBoundingClientRect; beforeEach(() => { vi.useFakeTimers(); tour.currentStep = 0; + nav.pathname = '/'; tour.nextStep.mockClear(); tour.prevStep.mockClear(); + tour.skipStep.mockClear(); tour.stopTour.mockClear(); HTMLElement.prototype.getBoundingClientRect = function () { const sized = this.hasAttribute('data-size'); @@ -48,6 +58,8 @@ afterEach(() => { HTMLElement.prototype.getBoundingClientRect = realRect; hosts.splice(0).forEach((h) => h.remove()); document.documentElement.removeAttribute('dir'); + // jsdom has no elementsFromPoint; tests that need it add one. + delete (document as { elementsFromPoint?: unknown }).elementsFromPoint; }); const hosts: HTMLElement[] = []; @@ -69,11 +81,47 @@ describe('tour targets', () => { expect(findTourTarget('[data-tour="one"]')).toBeNull(); }); - it('skips a step whose target is in the page but not shown, instead of leaving the user behind the overlay', () => { + it('ignores a copy covered by something else, like the tab bar under an open message', () => { + anchors('
'); + const reader = document.getElementById('reader-bar')!; + (document as { elementsFromPoint?: unknown }).elementsFromPoint = () => [reader, document.body]; + expect(findTourTarget('[data-tour="one"]')).toBeNull(); + // The tour's own layers on top don't count. + const layer = document.createElement('div'); + layer.setAttribute('data-tour-layer', ''); + const tabbar = document.getElementById('tabbar')!; + (document as { elementsFromPoint?: unknown }).elementsFromPoint = () => [layer, tabbar, document.body]; + expect(findTourTarget('[data-tour="one"]')?.id).toBe('tabbar'); + }); + + it('drops a step whose target is in the page but not shown, instead of leaving the user behind the overlay', () => { anchors(''); render(); act(() => { vi.advanceTimersByTime(1100); }); - expect(tour.nextStep).toHaveBeenCalledTimes(1); + expect(tour.skipStep).toHaveBeenCalledWith(1); + expect(tour.nextStep).not.toHaveBeenCalled(); + }); + + it("waits for the step's app before pointing at an anchor the app being left also has", () => { + anchors(''); + tour.currentStep = 3; + nav.pathname = '/settings'; + const { rerender } = render(); + act(() => { vi.advanceTimersByTime(6000); }); + expect(tour.skipStep).not.toHaveBeenCalled(); + expect(document.querySelector('[role="dialog"]')).toHaveClass('invisible'); + nav.pathname = '/files'; + rerender(); + act(() => { vi.advanceTimersByTime(400); }); + expect(screen.getByRole('dialog', { name: 'four' })).not.toHaveClass('invisible'); + }); + + it('has no card that catches taps while it looks for the target', () => { + render(); + act(() => { vi.advanceTimersByTime(300); }); + const card = document.querySelector('[role="dialog"]') as HTMLElement; + expect(card).toHaveClass('invisible'); + expect(card.style.pointerEvents).toBe('none'); }); it('shows a step whose target is on screen', () => { @@ -92,6 +140,33 @@ describe('tour targets', () => { expect(ids({})).toContain('nav-contacts'); expect(ids({ supportsContacts: false })).not.toContain('nav-contacts'); }); + + it('puts the card above a tab at the end of the phone tab bar instead of over it', () => { + const w = window.innerWidth; + const hgt = window.innerHeight; + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 }); + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 844 }); + try { + const tab = { left: 234, top: 792, width: 78, height: 52 }; + const pos = computeTooltipPosition(tab, 'right', { width: 360, height: 196 }); + expect(pos.top + 196).toBeLessThanOrEqual(tab.top); + expect(pos.left + 360).toBeLessThanOrEqual(390 - 8); + } finally { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: w }); + Object.defineProperty(window, 'innerHeight', { configurable: true, value: hgt }); + } + }); + + it('leaves out on a phone the steps that point into the rail or the folder drawer', () => { + const ids = (phone: boolean) => + getTourSteps({ isDemoMode: true, supportsCalendar: true, supportsWebDAV: true, touch: phone, phone }).map((s) => s.id); + for (const id of ['sidebar', 'keywords', 'demo-banner', 'quota']) { + expect(ids(false), id).toContain(id); + expect(ids(true), id).not.toContain(id); + } + expect(ids(true)[0]).toBe('compose'); + expect(ids(true)).toContain('nav-files'); + }); }); describe('tour keyboard', () => { diff --git a/components/tour/tour-overlay.tsx b/components/tour/tour-overlay.tsx index 8751bbc03..02b9e265d 100644 --- a/components/tour/tour-overlay.tsx +++ b/components/tour/tour-overlay.tsx @@ -9,6 +9,10 @@ import { useFocusTrap } from "@/hooks/use-focus-trap"; import { Button } from "@/components/ui/button"; import { isEditableEventTarget } from "@/lib/keyboard"; import { isDocumentRTL } from "@/i18n/direction"; +import { findTourTarget, isTourStepOnPage } from "./tour-steps"; +import { usePathname } from "@/i18n/navigation"; + +export { findTourTarget }; interface Rect { top: number; @@ -21,23 +25,6 @@ const PADDING = 8; const TOOLTIP_GAP = 12; const TOOLTIP_MAX_W = 360; -/** - * The first element for the selector that is on screen. The same anchor can - * exist twice (the rail and the phone's tab bar); one with no size is not - * rendered in this layout, and one in a closed drawer (inert, or pushed off - * the side of the screen) can't be seen: neither has anything to point at. - */ -export function findTourTarget(selector: string): Element | null { - for (const el of document.querySelectorAll(selector)) { - if (el.closest("[inert]")) continue; - const r = el.getBoundingClientRect(); - if (r.width === 0 && r.height === 0) continue; - if (r.right <= 0 || r.left >= window.innerWidth) continue; - return el; - } - return null; -} - function getTargetRect(selector: string): Rect | null { const el = findTourTarget(selector); if (!el) return null; @@ -49,8 +36,10 @@ function getTargetRect(selector: string): Rect | null { const HIDDEN_TARGET_ATTEMPTS = 10; /** Polls before a step whose target never appeared (another page loading) is skipped. */ const MISSING_TARGET_ATTEMPTS = 50; +/** Polls while the step's app is still loading (a first visit compiles it in development). */ +const PAGE_LOAD_ATTEMPTS = 300; -function computeTooltipPosition( +export function computeTooltipPosition( target: Rect, placement: "top" | "bottom" | "left" | "right", tooltipSize: { width: number; height: number } @@ -60,21 +49,25 @@ function computeTooltipPosition( const tw = Math.max(tooltipSize.width, 200); // minimum fallback width const th = Math.max(tooltipSize.height, 100); // minimum fallback height + // Along the side it sits on, the card slides to stay on screen: above a + // tab at the end of the phone's tab bar it can't be centred on the tab. + const alongX = (left: number) => Math.max(8, Math.min(left, vw - tw - 8)); + const alongY = (top: number) => Math.max(8, Math.min(top, vh - th - 8)); const positions = { bottom: { top: target.top + target.height + PADDING + TOOLTIP_GAP, - left: target.left + target.width / 2 - tw / 2, + left: alongX(target.left + target.width / 2 - tw / 2), }, top: { top: target.top - PADDING - TOOLTIP_GAP - th, - left: target.left + target.width / 2 - tw / 2, + left: alongX(target.left + target.width / 2 - tw / 2), }, right: { - top: target.top + target.height / 2 - th / 2, + top: alongY(target.top + target.height / 2 - th / 2), left: target.left + target.width + PADDING + TOOLTIP_GAP, }, left: { - top: target.top + target.height / 2 - th / 2, + top: alongY(target.top + target.height / 2 - th / 2), left: target.left - PADDING - TOOLTIP_GAP - tw, }, }; @@ -100,8 +93,13 @@ function computeTooltipPosition( export function TourOverlay() { const t = useTranslations(); - const { currentStep, totalSteps, steps, nextStep, prevStep, stopTour } = useTour(); + const { currentStep, totalSteps, steps, nextStep, prevStep, skipStep, stopTour } = useTour(); const step = steps[currentStep]; + // The same anchor can be in the app being left (the tab bar in Settings + // while Files loads): look for the target only once the step's app is up. + const pathname = usePathname(); + const onPageRef = useRef(true); + onPageRef.current = !step || isTourStepOnPage(step, pathname); const [targetRect, setTargetRect] = useState(null); const [tooltipPos, setTooltipPos] = useState<{ top: number; left: number } | null>(null); @@ -112,14 +110,12 @@ export function TourOverlay() { // Use refs for callbacks to avoid stale closures in timers/intervals const updatePositionRef = useRef<() => void>(() => {}); - const nextStepRef = useRef<() => void>(() => {}); - const prevStepRef = useRef<() => void>(() => {}); + const skipStepRef = useRef<(direction: 1 | -1) => void>(() => {}); // A step with nothing to point at is skipped in the direction the user went. const directionRef = useRef<1 | -1>(1); const goNext = useCallback(() => { directionRef.current = 1; nextStep(); }, [nextStep]); const goPrev = useCallback(() => { directionRef.current = -1; prevStep(); }, [prevStep]); - nextStepRef.current = nextStep; - prevStepRef.current = prevStep; + skipStepRef.current = skipStep; const focusTrapRef = useFocusTrap({ isActive: visible, @@ -172,6 +168,7 @@ export function TourOverlay() { const tryFind = () => { if (cancelled) return true; + if (!onPageRef.current) return false; const el = findTourTarget(step.target); if (el) { el.scrollIntoView({ behavior: "smooth", block: "nearest" }); @@ -195,7 +192,9 @@ export function TourOverlay() { // Poll for the element to appear (another page loading). One that is in // the DOM but not rendered in this layout (the rail on a phone) is given // up on sooner. + let loading = 0; const interval = setInterval(() => { + if (!onPageRef.current && ++loading < PAGE_LOAD_ATTEMPTS) return; attempts++; if (tryFind()) { clearInterval(interval); @@ -205,8 +204,8 @@ export function TourOverlay() { if (attempts >= limit) { clearInterval(interval); if (cancelled) return; - if (directionRef.current === -1 && currentStep > 0) prevStepRef.current(); - else nextStepRef.current(); + // Dropped from this run, so the counter never counts it. + skipStepRef.current(directionRef.current); } }, 100); @@ -301,6 +300,7 @@ export function TourOverlay() { <> {/* SVG overlay with cutout */} ).current = node; (focusTrapRef as React.MutableRefObject).current = node; }} + data-tour-layer="" role="dialog" aria-modal="true" aria-label={t(step.titleKey)} + aria-hidden={!visible || undefined} className={cn( "fixed z-[9999] transition-all", - visible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-2" + // While the next target is looked for, the card is not there at + // all: an invisible card would still catch taps. + visible ? "opacity-100 translate-y-0" : "invisible opacity-0 translate-y-2" )} style={{ top: tooltipPos?.top ?? -9999, left: tooltipPos?.left ?? -9999, maxWidth: TOOLTIP_MAX_W, transition: reducedMotion ? "none" : "opacity 200ms ease, transform 200ms ease, top 300ms ease, left 300ms ease", - pointerEvents: "auto", + pointerEvents: visible ? "auto" : "none", }} onClick={(e) => e.stopPropagation()} > diff --git a/components/tour/tour-provider.tsx b/components/tour/tour-provider.tsx index 9d241e1b5..79bf37b1a 100644 --- a/components/tour/tour-provider.tsx +++ b/components/tour/tour-provider.tsx @@ -8,8 +8,9 @@ import { useCalendarStore } from "@/stores/calendar-store"; import { useWebDAVStore } from "@/stores/webdav-store"; import { useSettingsStore } from "@/stores/settings-store"; import { usePolicyStore } from "@/stores/policy-store"; -import { getTourSteps, type TourStep } from "./tour-steps"; -import { useMediaQuery } from "@/hooks/use-media-query"; +import { getTourSteps, isTourStepOnPage, type TourStep } from "./tour-steps"; +import { useIsMobile, useMediaQuery } from "@/hooks/use-media-query"; +import { requestAppCommand } from "@/lib/app-commands"; import { TourOverlay } from "./tour-overlay"; const TOUR_COMPLETED_KEY = "tour_completed"; @@ -24,6 +25,12 @@ interface TourContextValue { stopTour: () => void; nextStep: () => void; prevStep: () => void; + /** + * Drops the current step, which has nothing to point at, from this run of + * the tour, so the counter only ever counts steps that show. `direction` + * is the way the user was going. + */ + skipStep: (direction: 1 | -1) => void; hasCompletedTour: boolean; resetTourCompletion: () => void; } @@ -36,6 +43,12 @@ export function useTour() { return ctx; } +function rememberStep(step: TourStep | undefined) { + try { + if (step) localStorage.setItem(TOUR_CURRENT_STEP_KEY, step.id); + } catch { /* */ } +} + export function TourProvider({ children }: { children: ReactNode }) { const router = useRouter(); const pathname = usePathname(); @@ -54,14 +67,32 @@ export function TourProvider({ children }: { children: ReactNode }) { const client = useAuthStore((s) => s.client); const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled')); const touch = useMediaQuery("(pointer: coarse)"); + const phone = useIsMobile(); + // Steps found to have nothing to point at during this run of the tour. + const [skipped, setSkipped] = useState([]); - const steps = getTourSteps({ + const allSteps = getTourSteps({ isDemoMode, supportsCalendar: supportsCalendar && calendarEnabled, supportsWebDAV: supportsWebDAV !== false, supportsContacts: (client?.supportsContacts?.() ?? false) && contactsEnabled, touch, + phone, }); + const steps = skipped.length ? allSteps.filter((s) => !skipped.includes(s.id)) : allSteps; + const step = isActive ? steps[currentStep] : undefined; + + // Take the user to the app the step is about. On a phone the open message + // covers the list and the tab bar, so every Mail step but the reader's own + // closes it first. + useEffect(() => { + if (!step) return; + if (!isTourStepOnPage(step, pathname)) { + router.push(step.page ?? "/"); + } else if (phone && !step.page && step.view !== "reader") { + requestAppCommand("show-list"); + } + }, [step, pathname, phone, router]); useEffect(() => { // One-time migration: if the legacy per-device flag is set but the synced @@ -88,29 +119,20 @@ export function TourProvider({ children }: { children: ReactNode }) { setHasCompletedTour(true); }, [tourCompleted, showOnboardingOnNewDevices]); + // A fresh run gives every step its chance again; the effect above takes the + // user to the first step's app. const startTour = useCallback(() => { let resumeStep = 0; try { const stored = localStorage.getItem(TOUR_CURRENT_STEP_KEY); - if (stored) { - const parsed = parseInt(stored, 10); - if (!isNaN(parsed) && parsed >= 0) resumeStep = parsed; - } + const index = stored ? allSteps.findIndex((s) => s.id === stored) : -1; + if (index >= 0) resumeStep = index; } catch { /* */ } - // If the resume step is beyond the current steps, start from 0 - if (resumeStep >= steps.length) resumeStep = 0; - - // Most steps live on the mailbox - navigate there (or to the step's specific page) - // so we don't start the tour on a page where the targets don't exist. - const targetPage = steps[resumeStep]?.page ?? "/"; - if (pathname !== targetPage) { - router.push(targetPage); - } - + setSkipped([]); setCurrentStep(resumeStep); setIsActive(true); - }, [steps, pathname, router]); + }, [allSteps]); const stopTour = useCallback(() => { setIsActive(false); @@ -134,38 +156,31 @@ export function TourProvider({ children }: { children: ReactNode }) { completeTour(); return; } - const next = currentStep + 1; - const nextStepDef = steps[next]; - const currentStepDef = steps[currentStep]; - setCurrentStep(next); - try { - localStorage.setItem(TOUR_CURRENT_STEP_KEY, String(next)); - } catch { /* */ } - - // Navigate when the next step is on a different page (treat "no page" as the mailbox) - const nextPage = nextStepDef?.page ?? "/"; - const currentPage = currentStepDef?.page ?? "/"; - if (nextPage !== currentPage) { - router.push(nextPage); - } - }, [currentStep, steps, completeTour, router]); + setCurrentStep(currentStep + 1); + rememberStep(steps[currentStep + 1]); + }, [currentStep, steps, completeTour]); const prevStep = useCallback(() => { if (currentStep <= 0) return; - const prev = currentStep - 1; - const prevStepDef = steps[prev]; - setCurrentStep(prev); - try { - localStorage.setItem(TOUR_CURRENT_STEP_KEY, String(prev)); - } catch { /* */ } + setCurrentStep(currentStep - 1); + rememberStep(steps[currentStep - 1]); + }, [currentStep, steps]); - if (prevStepDef?.page) { - router.push(prevStepDef.page); - } else if (steps[currentStep]?.page) { - // Going back from a page-specific step to a non-page step => go to mail - router.push("/"); + const skipStep = useCallback((direction: 1 | -1) => { + const current = steps[currentStep]; + if (!current) return; + const rest = steps.filter((s) => s !== current); + // Going on, the next step moves up into this index; going back, the + // previous one is a place lower (at the start, the next one shows). + const index = direction === -1 && currentStep > 0 ? currentStep - 1 : currentStep; + if (index >= rest.length) { + completeTour(); + return; } - }, [currentStep, steps, router]); + setSkipped((ids) => [...ids, current.id]); + setCurrentStep(index); + rememberStep(rest[index]); + }, [currentStep, steps, completeTour]); const resetTourCompletion = useCallback(() => { setHasCompletedTour(false); @@ -185,6 +200,7 @@ export function TourProvider({ children }: { children: ReactNode }) { stopTour, nextStep, prevStep, + skipStep, hasCompletedTour, resetTourCompletion, }; diff --git a/components/tour/tour-steps.ts b/components/tour/tour-steps.ts index 4074e32a1..0f9639713 100644 --- a/components/tour/tour-steps.ts +++ b/components/tour/tour-steps.ts @@ -6,11 +6,60 @@ export interface TourStep { placement: "top" | "bottom" | "left" | "right"; interactive?: boolean; spotlight?: "rect" | "circle"; + /** The app the step is in; none means Mail. */ page?: string; + /** + * "reader": the step is in Mail's reading pane, which on a phone covers the + * list and the tab bar. Every other Mail step closes the reader there. + */ + view?: "reader"; + /** false: the target only exists in the rail or the folder drawer, so a phone never shows it. */ + phone?: false; demoOnly?: boolean; beforeAction?: () => void; } +/** + * The first element for the selector that the user can see: not in a closed + * drawer (inert), with a size, on screen sideways and not covered by + * something else (the open message over the phone's tab bar). The same anchor + * can exist twice (the rail and the phone's tab bar). + */ +export function findTourTarget(selector: string): Element | null { + for (const el of document.querySelectorAll(selector)) { + if (el.closest("[inert]")) continue; + const r = el.getBoundingClientRect(); + if (r.width === 0 && r.height === 0) continue; + if (r.right <= 0 || r.left >= window.innerWidth) continue; + if (isCovered(el, r)) continue; + return el; + } + return null; +} + +/** Whether the middle of an on-screen element is under another element (the tour's own layers aside). */ +function isCovered(el: Element, r: DOMRect): boolean { + if (typeof document.elementsFromPoint !== "function") return false; + const x = r.left + r.width / 2; + const y = r.top + r.height / 2; + // Below or above the fold: scrolled into view before it is shown. + if (y < 0 || y > window.innerHeight || x < 0 || x > window.innerWidth) return false; + const top = document.elementsFromPoint(x, y).find((hit) => !hit.closest("[data-tour-layer]")); + return !!top && top !== el && !el.contains(top) && !top.contains(el); +} + +/** Whether the user is in the app the step is about. */ +export function isTourStepOnPage(step: Pick, pathname: string): boolean { + const page = step.page && step.page !== "/" ? step.page : null; + if (!page) return pathname === "/" || pathname === "" || pathname === "/mail" || pathname.startsWith("/mail/"); + return pathname === page || pathname.startsWith(page + "/"); +} + +/** Clicks the visible copy of a tour anchor (the phone's compose button, not the closed drawer's). */ +function clickTourTarget(selector: string): void { + (findTourTarget(selector) as HTMLElement | null)?.click(); +} + export const BASE_TOUR_STEPS: TourStep[] = [ { id: "sidebar", @@ -18,6 +67,7 @@ export const BASE_TOUR_STEPS: TourStep[] = [ titleKey: "tour.sidebar_title", descriptionKey: "tour.sidebar_desc", placement: "right", + phone: false, }, { id: "compose", @@ -48,6 +98,7 @@ export const BASE_TOUR_STEPS: TourStep[] = [ titleKey: "tour.email_viewer_title", descriptionKey: "tour.email_viewer_desc", placement: "left", + view: "reader", beforeAction: () => { // Click the "Welcome to Bulwark Mail!" email (or the first email) to open the viewer const emailList = document.querySelector('[data-tour="email-list"]'); @@ -72,6 +123,7 @@ export const BASE_TOUR_STEPS: TourStep[] = [ titleKey: "tour.keywords_title", descriptionKey: "tour.keywords_desc", placement: "right", + phone: false, }, { id: "nav-calendar", @@ -111,12 +163,9 @@ export const DEMO_TOUR_STEPS: TourStep[] = [ titleKey: "tour.compose_open_title", descriptionKey: "tour.compose_open_desc", placement: "left", + view: "reader", demoOnly: true, - beforeAction: () => { - // Click the compose button to open the composer - const btn = document.querySelector('[data-tour="compose-button"]') as HTMLElement | null; - if (btn) btn.click(); - }, + beforeAction: () => clickTourTarget('[data-tour="compose-button"]'), }, { id: "calendar-view", @@ -146,11 +195,7 @@ export const DEMO_TOUR_STEPS: TourStep[] = [ page: "/calendar", interactive: true, demoOnly: true, - beforeAction: () => { - // Click the create event button to open the modal - const btn = document.querySelector('[data-tour="create-event-button"]') as HTMLElement | null; - if (btn) btn.click(); - }, + beforeAction: () => clickTourTarget('[data-tour="create-event-button"]'), }, { id: "contacts-list", @@ -176,6 +221,8 @@ export const DEMO_TOUR_STEPS: TourStep[] = [ titleKey: "tour.files_title", descriptionKey: "tour.files_desc", placement: "right", + // Shown over the Files app, pointing at its place in the rail or tab bar. + page: "/files", demoOnly: true, }, { @@ -184,7 +231,7 @@ export const DEMO_TOUR_STEPS: TourStep[] = [ titleKey: "tour.demo_banner_title", descriptionKey: "tour.demo_banner_desc", placement: "bottom", - page: "/", + phone: false, demoOnly: true, }, { @@ -193,6 +240,7 @@ export const DEMO_TOUR_STEPS: TourStep[] = [ titleKey: "tour.quota_title", descriptionKey: "tour.quota_desc", placement: "right", + phone: false, demoOnly: true, }, ]; @@ -205,6 +253,8 @@ export function getTourSteps(options: { supportsContacts?: boolean; /** A touch screen: no keyboard to use the shortcuts with. */ touch?: boolean; + /** The phone layout: no rail, the folders in a drawer. */ + phone?: boolean; }): TourStep[] { let steps = [...BASE_TOUR_STEPS]; @@ -227,5 +277,9 @@ export function getTourSteps(options: { steps = [...steps, ...demoSteps]; } + if (options.phone) { + steps = steps.filter((s) => s.phone !== false); + } + return steps; } diff --git a/components/ui/__tests__/context-menu-touch.test.tsx b/components/ui/__tests__/context-menu-touch.test.tsx index 2a518c982..09a8d9072 100644 --- a/components/ui/__tests__/context-menu-touch.test.tsx +++ b/components/ui/__tests__/context-menu-touch.test.tsx @@ -22,4 +22,15 @@ describe('ContextMenu rows on a touch screen', () => { expect(row.className).not.toMatch(/(^|\s)min-h-11/); } }); + + it('hides key hints under a coarse pointer only (R8)', () => { + render( + {}}> + {}} /> + , + ); + const hint = screen.getByText('F2'); + expect(hint.className).toContain('pointer-coarse:hidden'); + expect(hint.className).not.toMatch(/(^|\s)hidden/); + }); }); diff --git a/components/ui/__tests__/dialog.test.tsx b/components/ui/__tests__/dialog.test.tsx index 34046f93d..513b38489 100644 --- a/components/ui/__tests__/dialog.test.tsx +++ b/components/ui/__tests__/dialog.test.tsx @@ -59,6 +59,17 @@ describe('Dialog', () => { expect(document.activeElement).toBe(opener); }); + it('skips fields out of the tab order, like the hidden input behind a drop zone', () => { + render( + {}} title="T"> + + + + , + ); + expect(document.activeElement).toBe(screen.getByLabelText('name')); + }); + it('honours initialFocus', () => { function Host() { const target = useRef(null); diff --git a/components/ui/__tests__/toast-layout.test.tsx b/components/ui/__tests__/toast-layout.test.tsx index 5fc4a8cb9..5068e33a2 100644 --- a/components/ui/__tests__/toast-layout.test.tsx +++ b/components/ui/__tests__/toast-layout.test.tsx @@ -41,6 +41,13 @@ describe('toast layout', () => { expect(container).toHaveClass('[html[data-composer-open]_&]:bottom-20'); }); + it('sits above a registered bottom bar on the desktop too, such as a form footer', () => { + render(); + const container = screen.getByRole('status'); + expect(container).toHaveClass('bottom-[max(1.25rem,calc(var(--bottom-controls-height,0px)+0.75rem))]'); + expect(container).not.toHaveClass('bottom-5'); + }); + it('names the close button and hides it on a phone', () => { render(); act(() => { toast.success('Saved'); }); diff --git a/components/ui/context-menu.tsx b/components/ui/context-menu.tsx index 7c63f5898..c6f7e3674 100644 --- a/components/ui/context-menu.tsx +++ b/components/ui/context-menu.tsx @@ -560,8 +560,9 @@ export function ContextMenuItem({ ) : ( {label} )} + {/* Key hints mean nothing without a keyboard: hidden on touch (R8). */} {shortcut && ( - {shortcut} + {shortcut} )} ); diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx index 266f4f357..4512d1c74 100644 --- a/components/ui/dialog.tsx +++ b/components/ui/dialog.tsx @@ -53,8 +53,10 @@ const SIZES: Record = { "max-w-2xl max-sm:max-w-none max-sm:h-full max-sm:max-h-none max-sm:rounded-none max-sm:border-0", }; +// A field out of the tab order (the hidden input behind a file drop zone) +// can't take the focus: picking it left the focus on the page behind. const FIELD_SELECTOR = - 'input:not([type="hidden"]):not([type="button"]):not([type="submit"]):not([type="checkbox"]):not([type="radio"]):not(:disabled), textarea:not(:disabled), select:not(:disabled), [contenteditable="true"]'; + 'input:not([type="hidden"]):not([type="button"]):not([type="submit"]):not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([tabindex="-1"]):not(:disabled), textarea:not([tabindex="-1"]):not(:disabled), select:not([tabindex="-1"]):not(:disabled), [contenteditable="true"]'; /** * The one dialog shell (R4): a portal on at z-[60] over a dimmed diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx index d2e1b91af..5d43a493c 100644 --- a/components/ui/toast.tsx +++ b/components/ui/toast.tsx @@ -317,8 +317,9 @@ export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose:
{ expect(result.current?.past.map((e) => e.id)).toEqual(['old', 'older']); }); + it('shows a series under its own title when its next occurrence was renamed', () => { + const { client } = clientWith(() => new Promise(() => {})); + const series = [ + makeEvent('sync-1', 'Sync edited', '2026-10-29T10:00:00', { uid: 'sync', recurrenceId: '2026-10-29T10:00:00' }), + makeEvent('sync-2', 'Sync', '2026-11-05T10:00:00', { uid: 'sync', recurrenceId: '2026-11-05T10:00:00' }), + makeEvent('sync-3', 'Sync', '2026-11-12T10:00:00', { uid: 'sync', recurrenceId: '2026-11-12T10:00:00' }), + ]; + const { result } = renderHook(() => useCalendarEventSearch('sync', client, series)); + expect(result.current?.upcoming.map((e) => e.id)).toEqual(['sync-1', 'sync-2']); + }); + it('keeps the loaded matches when the server can not search', async () => { const { client } = clientWith(async () => { throw new Error('unsupportedFilter'); }); const { result } = renderHook(() => useCalendarEventSearch('review', client, loaded)); diff --git a/hooks/__tests__/use-format-event-date.test.ts b/hooks/__tests__/use-format-event-date.test.ts new file mode 100644 index 000000000..1a683de28 --- /dev/null +++ b/hooks/__tests__/use-format-event-date.test.ts @@ -0,0 +1,35 @@ +import { renderHook } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; + +// German dates on the event card read "Sa, Okt 31, 2026": English order +// with translated names. Each locale now gets its own order. + +let locale = 'en'; +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => locale, +})); + +import { useFormatEventDate } from '../use-format-event-date'; + +function formatIn(code: string, date: Date) { + locale = code; + const { result } = renderHook(() => useFormatEventDate()); + return result.current(date); +} + +describe('useFormatEventDate', () => { + const date = new Date(2026, 9, 31); + + it('keeps the English order in English', () => { + expect(formatIn('en', date)).toBe('Sat, Oct 31, 2026'); + }); + + it('puts the day before the month in German', () => { + expect(formatIn('de', date)).toBe('Sa., 31. Okt. 2026'); + }); + + it('follows French too', () => { + expect(formatIn('fr', date)).toBe('sam. 31 oct. 2026'); + }); +}); diff --git a/hooks/__tests__/use-hover-preview-gate.test.ts b/hooks/__tests__/use-hover-preview-gate.test.ts new file mode 100644 index 000000000..6de1c03c8 --- /dev/null +++ b/hooks/__tests__/use-hover-preview-gate.test.ts @@ -0,0 +1,43 @@ +import { renderHook } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { useHoverPreviewGate } from '../use-hover-preview-gate'; + +// The hover preview opened on top of the open editor, and by itself after +// a dialog closed with the pointer resting on an event. + +function move(x: number, y: number) { + window.dispatchEvent(new MouseEvent('pointermove', { clientX: x, clientY: y })); +} + +describe('useHoverPreviewGate', () => { + it('lets a preview open when nothing is open', () => { + const { result } = renderHook(() => useHoverPreviewGate(false)); + expect(result.current()).toBe(true); + }); + + it('holds previews back while an editor is open, and after it closed until the pointer moves', () => { + const { result, rerender } = renderHook(({ open }) => useHoverPreviewGate(open), { initialProps: { open: true } }); + move(100, 100); + expect(result.current()).toBe(false); + rerender({ open: false }); + expect(result.current()).toBe(false); + move(101, 101); + expect(result.current()).toBe(false); + move(140, 120); + expect(result.current()).toBe(true); + }); + + it('holds previews back while a modal dialog is on the page, and after it closed', async () => { + const { result } = renderHook(() => useHoverPreviewGate(false)); + move(10, 10); + const dialog = document.createElement('div'); + dialog.setAttribute('aria-modal', 'true'); + document.body.appendChild(dialog); + expect(result.current()).toBe(false); + dialog.remove(); + await new Promise((r) => setTimeout(r, 0)); + expect(result.current()).toBe(false); + move(60, 60); + expect(result.current()).toBe(true); + }); +}); diff --git a/hooks/__tests__/use-keyboard-shortcuts.test.ts b/hooks/__tests__/use-keyboard-shortcuts.test.ts index 4e75a0dc4..c47ff91c6 100644 --- a/hooks/__tests__/use-keyboard-shortcuts.test.ts +++ b/hooks/__tests__/use-keyboard-shortcuts.test.ts @@ -130,3 +130,38 @@ describe('useKeyboardShortcuts: n opens the next unread message', () => { expect(KEYBOARD_SHORTCUTS.navigation).toContainEqual({ key: 'n', description: 'shortcuts.navigation.next_unread' }); }); }); + +// Enter on the focused "Skip to message list" link (or any button) belongs to +// that control: it must not open the selected message. +describe('useKeyboardShortcuts: Enter on a link or button', () => { + function setup() { + const onOpenEmail = vi.fn(); + renderHook(() => + useKeyboardShortcuts({ enabled: true, emails: [], selectedEmailId: 'e1', handlers: { onOpenEmail } }), + ); + return onOpenEmail; + } + + it.each([ + ['a link', () => { const a = document.createElement('a'); a.href = '#message-list'; return a; }], + ['a button', () => document.createElement('button')], + ])('leaves Enter on %s to it', (_name, make) => { + const onOpenEmail = setup(); + const el = make(); + document.body.appendChild(el); + try { + const event = new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true, cancelable: true }); + el.dispatchEvent(event); + expect(onOpenEmail).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + } finally { + el.remove(); + } + }); + + it('still opens the selected message with Enter elsewhere', () => { + const onOpenEmail = setup(); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true })); + expect(onOpenEmail).toHaveBeenCalledTimes(1); + }); +}); diff --git a/hooks/use-bottom-controls.ts b/hooks/use-bottom-controls.ts index 64b1aded8..9dcf2f2de 100644 --- a/hooks/use-bottom-controls.ts +++ b/hooks/use-bottom-controls.ts @@ -3,8 +3,9 @@ import { useEffect, useState } from 'react'; /** - * Bars pinned to the bottom of the screen on a phone (the tab bar, a - * viewer's action bar) register here. While any is on screen, + * Bars pinned to the bottom of the screen register here: on a phone the tab + * bar and a viewer's action bar, on any screen an editor's Save/Cancel + * footer. While any is on screen, * `--bottom-controls-height` on says how far from the bottom of the * window the topmost of them reaches, and toasts sit above it. */ diff --git a/hooks/use-calendar-event-search.ts b/hooks/use-calendar-event-search.ts index 9d8122c28..a1f48e830 100644 --- a/hooks/use-calendar-event-search.ts +++ b/hooks/use-calendar-event-search.ts @@ -22,8 +22,11 @@ export interface CalendarSearchResults { } // One row per event: a series found as loaded occurrences and as the -// server's base event shows once. -const seriesKey = (event: CalendarEvent) => `${event.accountId ?? ""} ${event.uid || event.originalId || event.id}`; +// server's base event shows once. An occurrence renamed on its own gets a +// row of its own, so the series still shows under its own title: the next +// occurrence being the renamed one hid the series. +const seriesKey = (event: CalendarEvent) => + `${event.accountId ?? ""} ${event.uid || event.originalId || event.id} ${event.title ?? ""}`; /** * Searches the user's events by title, place, description and participants diff --git a/hooks/use-calendar-locale.ts b/hooks/use-calendar-locale.ts index d2eb78d28..1130b3fd4 100644 --- a/hooks/use-calendar-locale.ts +++ b/hooks/use-calendar-locale.ts @@ -200,6 +200,21 @@ export function useCalendarLocale() { }); }; + /** + * Short day label for a phone's title ("Oct 28", "28. Okt."). The weekday + * is in the day's header right under it, and with it German no longer fit. + */ + const formatDayShort = (date: Date): string => { + if (isJalali) { + const { jm, jd } = toJalali(date); + return `${jd} ${JALALI_MONTHS[jm - 1]}`; + } + return date.toLocaleString(locale === "en" ? "en-US" : locale, { + month: "short", + day: "numeric", + }); + }; + // ------------------------------------------------------------------ // Calendar-system-aware month/year getters (for navigation, etc.) // All return values use **0-based** months to stay compatible with @@ -244,6 +259,7 @@ export function useCalendarLocale() { formatWeekRange, formatWeekRangeShort, formatFullDate, + formatDayShort, getMonth, getYear, monthLabelKeys, diff --git a/hooks/use-close-on-back.ts b/hooks/use-close-on-back.ts index ba8079478..01c02f7ba 100644 --- a/hooks/use-close-on-back.ts +++ b/hooks/use-close-on-back.ts @@ -10,6 +10,14 @@ const STATE_KEY = "__closeOnBack"; // would pop it and close the layer at once. let pendingHistoryBack: Promise | null = null; +/** + * Resolves once the Back a closed layer took to remove its history entry has + * landed. A navigation started before then would be undone by it. + */ +export function historySettled(): Promise { + return pendingHistoryBack ?? Promise.resolve(); +} + const currentEntry = () => (window.history.state as Record | null)?.[STATE_KEY]; interface UseCloseOnBackOptions { diff --git a/hooks/use-format-event-date.ts b/hooks/use-format-event-date.ts index 9e09b3ee7..e2600995f 100644 --- a/hooks/use-format-event-date.ts +++ b/hooks/use-format-event-date.ts @@ -4,13 +4,10 @@ import { format } from "date-fns"; import { toJalali, shouldUseJalaliCalendar, JALALI_MONTHS } from "@/lib/jalali-utils"; /** - * Returns a memoized function that formats a calendar event date - * using the current locale for day and month names. - * - * The string will be in the format: "EEE, MMM d, yyyy" - * - * For example: "Wed, Apr 29, 2026" (en) - * "Qua, Abr 29, 2026" (pt) + * Returns a memoized function that formats a calendar event date in the + * current locale's own order and names (Intl), e.g. + * "Wed, Apr 29, 2026" (en) + * "Mi., 29. Apr. 2026" (de) * * When the Jalali calendar is active (fa locale), the format uses * Persian day/month names with the Jalali year, e.g.: @@ -31,12 +28,14 @@ export function useFormatEventDate(): (date: Date) => string { return `${t(`days.${dayOfWeek}`)}, ${jd} ${monthName} ${jy}`; } - const dayOfWeek = format(date, "EEE").toLowerCase(); - const month = format(date, "MMM").toLowerCase(); - const day = format(date, "d"); - const year = format(date, "yyyy"); - return `${t(`days.${dayOfWeek}`)}, ${t(`months.${month}`)} ${day}, ${year}`; + // A display date: its local fields are the wall-clock to show. + return date.toLocaleDateString(locale === "en" ? "en-US" : locale, { + weekday: "short", + month: "short", + day: "numeric", + year: "numeric", + }); }, - [t, isJalali] + [t, isJalali, locale] ); } diff --git a/hooks/use-hover-preview-gate.ts b/hooks/use-hover-preview-gate.ts new file mode 100644 index 000000000..430a2a873 --- /dev/null +++ b/hooks/use-hover-preview-gate.ts @@ -0,0 +1,72 @@ +import { useCallback, useEffect, useRef } from "react"; +import { isContextMenuOpen } from "@/components/ui/context-menu"; + +/** How far the pointer has to move before a preview may open again. */ +const MOVE_THRESHOLD = 4; + +const MODAL_SELECTOR = '[aria-modal="true"]'; + +/** + * Decides whether a hover preview may open. Not while an editor, a card, + * a menu or a dialog is open (`overlayOpen` plus any modal on the page), + * and not after one closed until the pointer really moves: the pointer + * resting where the dialog was otherwise "hovers" the event under it and + * the preview opens by itself. + */ +export function useHoverPreviewGate(overlayOpen: boolean): () => boolean { + const overlayOpenRef = useRef(overlayOpen); + const pointer = useRef<{ x: number; y: number } | null>(null); + // Where the pointer was when previews were held back; null when free. + const heldAt = useRef<{ x: number; y: number } | null>(null); + + const hold = useCallback(() => { + heldAt.current = pointer.current ?? { x: Number.NaN, y: Number.NaN }; + }, []); + + useEffect(() => { + const onMove = (e: PointerEvent | MouseEvent) => { + const at = { x: e.clientX, y: e.clientY }; + pointer.current = at; + const held = heldAt.current; + if (!held) return; + // Position unknown when it was held: the first move frees it. + if (Number.isNaN(held.x) || Math.hypot(at.x - held.x, at.y - held.y) > MOVE_THRESHOLD) { + heldAt.current = null; + } + }; + window.addEventListener("pointermove", onMove, { passive: true }); + return () => window.removeEventListener("pointermove", onMove); + }, []); + + // An editor, card or quick editor of the calendar closing. + useEffect(() => { + const was = overlayOpenRef.current; + overlayOpenRef.current = overlayOpen; + if (was && !overlayOpen) hold(); + }, [overlayOpen, hold]); + + // Any other dialog (a confirm, import, shortcuts) portalled onto . + useEffect(() => { + if (typeof MutationObserver === "undefined") return; + const observer = new MutationObserver((records) => { + for (const record of records) { + for (const node of record.removedNodes) { + if (node instanceof Element && (node.matches(MODAL_SELECTOR) || node.querySelector(MODAL_SELECTOR))) { + hold(); + return; + } + } + } + }); + observer.observe(document.body, { childList: true }); + return () => observer.disconnect(); + }, [hold]); + + return useCallback(() => { + if (overlayOpenRef.current || isContextMenuOpen() || document.querySelector(MODAL_SELECTOR)) { + hold(); + return false; + } + return heldAt.current === null; + }, [hold]); +} diff --git a/hooks/use-keyboard-shortcuts.ts b/hooks/use-keyboard-shortcuts.ts index 6fb79dc2f..0540c9729 100644 --- a/hooks/use-keyboard-shortcuts.ts +++ b/hooks/use-keyboard-shortcuts.ts @@ -2,7 +2,7 @@ import { useEffect, useCallback, useRef } from "react"; import { Email } from "@/lib/jmap/types"; -import { isEditableEventTarget } from "@/lib/keyboard"; +import { isActivatableEventTarget, isEditableEventTarget } from "@/lib/keyboard"; import { keyboardHooks } from "@/lib/plugin-hooks"; export interface KeyboardShortcutHandlers { @@ -198,6 +198,8 @@ export function useKeyboardShortcuts({ case "enter": case "o": + // Enter on a focused link or button (the skip link) is the control's. + if (key === "enter" && isActivatableEventTarget(event)) break; if (selectedEmailId) { event.preventDefault(); run("open-email", h.onOpenEmail); diff --git a/hooks/use-long-press.ts b/hooks/use-long-press.ts index 972c5fd23..cf72ead0a 100644 --- a/hooks/use-long-press.ts +++ b/hooks/use-long-press.ts @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; const LONG_PRESS_DURATION = 500; const MOVE_THRESHOLD = 10; @@ -69,5 +69,12 @@ export function useLongPress( firedRef.current = false; }, [cancel]); - return { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed }; + // `handlers` holds only the touch handlers, safe to spread onto a DOM + // element; spreading the whole result would put `isPressed` on it. + const handlers = useMemo( + () => ({ onTouchStart, onTouchEnd, onTouchMove, onTouchCancel }), + [onTouchStart, onTouchEnd, onTouchMove, onTouchCancel] + ); + + return { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed, handlers }; } diff --git a/hooks/use-time-grid-focus.ts b/hooks/use-time-grid-focus.ts index 9c8ccddcd..ae1668ac9 100644 --- a/hooks/use-time-grid-focus.ts +++ b/hooks/use-time-grid-focus.ts @@ -17,6 +17,11 @@ interface UseTimeGridFocusOptions { selectedDate: Date; /** Creates an event at the slot (Enter, `n` or `c`). */ onCreate: (start: Date) => void; + /** + * Whether a day is scrolled into view. The tab stop stays on a day in + * view: one in a hidden week made Tab scroll the grid off its week. + */ + isInView?: (key: string) => boolean; } export interface TimeGridSlotProps { @@ -27,6 +32,7 @@ export interface TimeGridSlotProps { } const slotId = (key: string, hour: number) => `${key}T${hour}`; +const everyDay = () => true; /** * Keyboard access to the empty slots of the week and day grids (CAL-13, @@ -35,7 +41,7 @@ const slotId = (key: string, hour: number) => `${key}T${hour}`; * event there. The month grid works the same way with days. */ export function useTimeGridFocus({ - rootRef, headerRef, gutterWidth, days, hours, selectedDate, onCreate, + rootRef, headerRef, gutterWidth, days, hours, selectedDate, onCreate, isInView = everyDay, }: UseTimeGridFocusOptions) { const firstHour = hours.startMinutes / 60; const lastHour = hours.endMinutes / 60 - 1; @@ -43,17 +49,23 @@ export function useTimeGridFocus({ const dayIndexByKey = useMemo(() => new Map(days.map((day, index) => [dayKey(day), index])), [days]); - // The slot that takes Tab: the last one focused while it is still drawn, - // else the selected day (or the first loaded one) at the current hour. + // The slot that takes Tab: the last one focused while it is still drawn + // and in view, else the selected day (or the first day in view, else the + // first loaded one) at the current hour. const tabStop = useMemo(() => { - if (focused && dayIndexByKey.has(focused.key) && focused.hour >= firstHour && focused.hour <= lastHour) { + if (focused && dayIndexByKey.has(focused.key) && isInView(focused.key) && focused.hour >= firstHour && focused.hour <= lastHour) { return focused; } const selectedKey = dayKey(selectedDate); - const key = dayIndexByKey.has(selectedKey) ? selectedKey : days[0] ? dayKey(days[0]) : selectedKey; - const hour = Math.min(lastHour, Math.max(firstHour, displayNow().getHours())); + const firstInView = days.map(dayKey).find(isInView); + const key = dayIndexByKey.has(selectedKey) && isInView(selectedKey) + ? selectedKey + : firstInView ?? (days[0] ? dayKey(days[0]) : selectedKey); + const hour = focused && focused.hour >= firstHour && focused.hour <= lastHour + ? focused.hour + : Math.min(lastHour, Math.max(firstHour, displayNow().getHours())); return { key, hour }; - }, [focused, dayIndexByKey, firstHour, lastHour, selectedDate, days]); + }, [focused, dayIndexByKey, firstHour, lastHour, selectedDate, days, isInView]); const focusSlot = useCallback((key: string, hour: number) => { const root = rootRef.current; diff --git a/lib/__tests__/demo-client-files.test.ts b/lib/__tests__/demo-client-files.test.ts new file mode 100644 index 000000000..9a4c9b873 --- /dev/null +++ b/lib/__tests__/demo-client-files.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { DemoJMAPClient } from '@/lib/demo/demo-client'; + +// A move, its Undo and a restore from the trash are all parentId updates; +// none of them may make a file look edited. +describe('DemoJMAPClient files', () => { + it('keeps a file\'s Modified time when it is moved or renamed', async () => { + const client = new DemoJMAPClient(); + const folder = await client.createFileDirectory('Target', null); + const nodes = await client.listAllFileNodes(); + const original = nodes.find((n) => n.blobId != null && n.modified)!; + const before = original.modified; + + await client.updateFileNodes({ [original.id]: { parentId: folder.id } }); + await client.updateFileNode(original.id, { name: 'renamed.txt' }); + + const after = (await client.getFileNodes([original.id]))[0]; + expect(after.parentId).toBe(folder.id); + expect(after.name).toBe('renamed.txt'); + expect(after.modified).toBe(before); + }); +}); diff --git a/lib/__tests__/leave-guard.test.ts b/lib/__tests__/leave-guard.test.ts new file mode 100644 index 000000000..80661c2e7 --- /dev/null +++ b/lib/__tests__/leave-guard.test.ts @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useCloseOnBack } from '@/hooks/use-close-on-back'; +import { confirmLeave, hasUnsavedChanges, interceptLeave, registerLeaveGuard } from '@/lib/leave-guard'; + +const cleanups: Array<() => void> = []; +afterEach(() => { cleanups.splice(0).forEach((off) => off()); }); + +function guard(dirty: { value: boolean }, answer: boolean) { + const confirm = vi.fn(async () => { + if (answer) dirty.value = false; + return answer; + }); + cleanups.push(registerLeaveGuard({ isDirty: () => dirty.value, confirm })); + return confirm; +} + +function link() { + const a = document.createElement('a'); + const onClick = vi.fn((e: MouseEvent) => { interceptLeave(e as unknown as { preventDefault(): void; currentTarget: EventTarget | null }); }); + a.addEventListener('click', onClick); + document.body.appendChild(a); + cleanups.push(() => a.remove()); + return { a, onClick }; +} + +describe('leave guard', () => { + it('lets a click through when nothing is unsaved', () => { + guard({ value: false }, true); + const e = { preventDefault: vi.fn(), currentTarget: null }; + expect(interceptLeave(e)).toBe(false); + expect(e.preventDefault).not.toHaveBeenCalled(); + }); + + it('stops the click, asks, and clicks again once the changes are let go', async () => { + const confirm = guard({ value: true }, true); + const { a, onClick } = link(); + const click = new MouseEvent('click', { cancelable: true }); + a.dispatchEvent(click); + expect(click.defaultPrevented).toBe(true); + expect(confirm).toHaveBeenCalledOnce(); + await vi.waitFor(() => expect(onClick).toHaveBeenCalledTimes(2)); + expect(hasUnsavedChanges()).toBe(false); + }); + + it('stays put when the user keeps editing', async () => { + const confirm = guard({ value: true }, false); + const { a, onClick } = link(); + a.dispatchEvent(new MouseEvent('click', { cancelable: true })); + await vi.waitFor(() => expect(confirm).toHaveBeenCalledOnce()); + await Promise.resolve(); + expect(onClick).toHaveBeenCalledTimes(1); + expect(hasUnsavedChanges()).toBe(true); + }); + + it('leaves only once the asking dialog has taken its history entry back off', async () => { + // The confirm dialog holds a history entry while it is open (Back closes + // it) and steps Back off it as it closes. Leaving before that Back lands + // would be undone by it. + const dialog = renderHook((props: { open: boolean }) => useCloseOnBack({ onBack: () => {}, active: props.open }), { + initialProps: { open: true }, + }); + await vi.waitFor(() => expect((window.history.state as Record | null)?.__closeOnBack).toBeTruthy()); + const order: string[] = []; + window.addEventListener('popstate', () => order.push('back landed'), { once: true }); + cleanups.push(registerLeaveGuard({ + isDirty: () => true, + confirm: async () => { dialog.rerender({ open: false }); return true; }, + })); + const a = document.createElement('a'); + a.addEventListener('click', (e) => { if (!interceptLeave(e)) order.push('left'); }); + document.body.appendChild(a); + cleanups.push(() => { a.remove(); dialog.unmount(); }); + a.click(); + await vi.waitFor(() => expect(order).toContain('left')); + expect(order).toEqual(['back landed', 'left']); + }); + + it('asks only the editors that have changes', async () => { + const clean = guard({ value: false }, true); + const dirty = guard({ value: true }, true); + expect(await confirmLeave()).toBe(true); + expect(clean).not.toHaveBeenCalled(); + expect(dirty).toHaveBeenCalledOnce(); + }); +}); diff --git a/lib/__tests__/push-bindings.test.ts b/lib/__tests__/push-bindings.test.ts index 5bb9f0300..0856fafa8 100644 --- a/lib/__tests__/push-bindings.test.ts +++ b/lib/__tests__/push-bindings.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { reconcilePushBindings, releasePushBindings, type PushBinding, type PushRole } from '../push-bindings'; +import { DemoJMAPClient } from '../demo/demo-client'; /** A client that counts what the reconciliation does to it. */ class FakeClient { @@ -98,6 +99,41 @@ describe('reconcilePushBindings', () => { expect(fresh.setups).toBe(1); }); + it('switches between demo logins, which have no live stream', () => { + const work = new DemoJMAPClient('work'); + const personal = new DemoJMAPClient('personal'); + const bindings = new Map(); + const roles: Array<[string, PushRole]> = []; + const onError = vi.fn(); + const run = (active: string) => reconcilePushBindings({ + bindings, + clients: new Map([['work', work], ['personal', personal]]), + activeAccountId: active, + listen: (c, role) => { roles.push([c === work ? 'work' : 'personal', role]); }, + onError, + }); + run('work'); + expect(() => run('personal')).not.toThrow(); + expect(() => run('work')).not.toThrow(); + expect(onError).not.toHaveBeenCalled(); + expect(bindings.get(work)?.role).toBe('active'); + expect(bindings.get(personal)?.role).toBe('background'); + }); + + it('treats a client without hasSSEStream as having no stream', () => { + const plain = (name: string) => ({ name, setupPushNotifications: vi.fn(), closePushNotifications: vi.fn() }); + const a = plain('a'); + const b = plain('b'); + const bindings = new Map, PushBinding>(); + const run = (active: string) => reconcilePushBindings({ + bindings, clients: new Map([['a', a], ['b', b]]), activeAccountId: active, listen: vi.fn(), + }); + run('a'); + expect(() => run('b')).not.toThrow(); + expect(bindings.get(b)?.role).toBe('active'); + expect(bindings.get(a)?.role).toBe('background'); + }); + it('releases everything on logout', () => { const { clients, bindings, run } = world(['a', 'b']); run(['a', 'b'], 'a'); diff --git a/lib/__tests__/settings-wording.test.ts b/lib/__tests__/settings-wording.test.ts index 02a797347..0dc62186b 100644 --- a/lib/__tests__/settings-wording.test.ts +++ b/lib/__tests__/settings-wording.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; import en from '@/locales/en/common.json'; import { getByPath } from '../settings-search'; @@ -10,8 +12,14 @@ const text = (key: string) => getByPath(en, key) as string; // Words that keep their capital inside a sentence-case label. const PROPER = /^(ASCII-armored|JMAP|Sieve|HTML|PGP|S\/MIME|TOTP|API|IPs|ASCII|BCC|Pro|Junk|Trash|Bulwark|Webmail|Siege|Spam|ID|URL|OAuth\/OIDC\)|\(OAuth\/OIDC\)|Mountain|View|Cc|To|ALL|ANY|Z|→|&)$/; +// Every word after the first, and each part of a hyphenated word after its +// first part: "Sub-Addressing" counts "Addressing". function titleCaseWords(value: string): string[] { - return value.split(/\s+/).slice(1).filter((w) => /^[A-Z]/.test(w) && !PROPER.test(w.replace(/[(),.]/g, ''))); + const words = value.split(/\s+/).flatMap((word, i) => { + const parts = word.split('-'); + return i === 0 ? parts.slice(1) : parts; + }); + return words.filter((w) => /^[A-Z]/.test(w) && !PROPER.test(w.replace(/[(),.]/g, ''))); } describe('settings wording', () => { @@ -21,6 +29,12 @@ describe('settings wording', () => { expect(text('settings.folders.create_subfolder')).toBe('New subfolder'); expect(text('settings.filters.add_rule')).toBe('New rule'); expect(text('identities.create_new')).toBe('New identity'); + expect(text('calendar.management.add_calendar')).toBe('New calendar'); + expect(text('settings.security.app_passwords.new')).toBe('New app password'); + expect(text('settings.security.api_keys.new')).toBe('New API key'); + // A public key and an account exist already: they are added, not created. + expect(text('settings.security.public_keys.add')).toBe('Add public key'); + expect(text('settings.account.accounts.add')).toBe('Add account'); expect(text('settings.vacation.save')).toBe('Save'); expect(text('settings.filters.save')).toBe('Save'); expect(text('identities.form.save')).toBe('Save'); @@ -45,6 +59,66 @@ describe('settings wording', () => { expect(offenders).toEqual([]); }); + // R9: a success toast states the result, without "successfully". + it('reports Settings saves without "successfully"', () => { + for (const key of [ + 'notifications.filters_saved', + 'settings.filters.saved', + 'notifications.identity_created', + 'notifications.identity_updated', + 'notifications.templates_exported', + ]) { + expect(text(key), key).not.toMatch(/successful|!/i); + } + expect(text('notifications.filters_saved')).toBe('Filters saved'); + }); + + // Security: App passwords come after the mail client section. + it('sends the mail client hint to the app passwords section below it', () => { + expect(text('settings.security.email_client.password_instructions')).toContain('in the section below'); + }); + + // Settings > Contacts deletes address books and categories as well. + it('says the contact lists can be deleted, not only renamed', () => { + expect(text('settings.contacts.manage_description')).toBe('Rename or delete your address books'); + expect(text('settings.contacts.categories_description')).toBe('Rename or delete contact categories'); + }); + + // R5: every editor asks the same question with the same buttons, in every + // language: Settings and Contacts say what Calendar says. + it('asks "Discard changes?" with Keep editing / Discard everywhere', () => { + expect(text('calendar.discard.title')).toBe('Discard changes?'); + const mismatches: string[] = []; + for (const locale of readdirSync(join(process.cwd(), 'locales'))) { + const messages = JSON.parse(readFileSync(join(process.cwd(), 'locales', locale, 'common.json'), 'utf8')); + const ref = getByPath(messages, 'calendar.discard') as Record; + const expected: Record = { + 'settings.discard_changes': ref.title, + 'settings.unsaved_changes': ref.message, + 'settings.keep_editing': ref.keep, + 'settings.discard': ref.confirm, + 'contacts.form.discard_title': ref.title, + 'contacts.form.discard_message': ref.message, + 'contacts.form.keep_editing': ref.keep, + 'contacts.form.discard': ref.confirm, + }; + for (const [key, value] of Object.entries(expected)) { + if (getByPath(messages, key) !== value) mismatches.push(`${locale} ${key}`); + } + } + expect(mismatches).toEqual([]); + }); + + it('counts every word of a hyphenated label', () => { + expect(titleCaseWords('Sub-Addressing')).toEqual(['Addressing']); + expect(titleCaseWords('Sub-addressing')).toEqual([]); + }); + + it('says "Back to mail" in Settings, the admin and plugin pages', () => { + expect(text('settings.back_to_mail')).toBe('Back to mail'); + expect(text('plugin_pages.back_to_mail')).toBe('Back to mail'); + }); + it('does not repeat the debug section title as its only row', () => { expect(text('settings.advanced.debug_mode.toggle')).not.toBe(text('settings.advanced.debug_mode.label')); }); diff --git a/lib/app-commands.ts b/lib/app-commands.ts index b5298775d..d3bbf1c47 100644 --- a/lib/app-commands.ts +++ b/lib/app-commands.ts @@ -8,7 +8,7 @@ import { useEffect, useRef } from 'react'; -export type AppCommand = 'compose' | 'search' | 'all-inboxes'; +export type AppCommand = 'compose' | 'search' | 'all-inboxes' | 'show-list'; export const COMMAND_PALETTE_OPEN_EVENT = 'bulwark:command-palette'; diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 50b095d2e..c00a06b6f 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -174,6 +174,8 @@ export class DemoJMAPClient implements IJMAPClient { setupPushNotifications(): boolean { return true; } closePushNotifications(): void { /* no-op in demo */ } + // Demo changes arrive through the state-change callback, not a live stream. + hasSSEStream(): boolean { return false; } onConnectionChange(callback: (connected: boolean) => void): void { this.connectionCallback = callback; } onRateLimit(): void { /* no-op in demo */ } isRateLimited(): boolean { return false; } @@ -1425,8 +1427,10 @@ export class DemoJMAPClient implements IJMAPClient { } async updateFileNode(id: string, updates: Partial>): Promise { + // A rename or move leaves `modified` alone, as on Stalwart: it dates the + // content, and a move or its Undo shouldn't make a file look edited. const node = this.data.fileNodes.find(n => n.id === id); - if (node) Object.assign(node, updates, { modified: new Date().toISOString() }); + if (node) Object.assign(node, updates); } async updateFileNodes(updates: Record>>): Promise<{ updated: string[]; notUpdated: Record }> { @@ -1434,7 +1438,7 @@ export class DemoJMAPClient implements IJMAPClient { for (const [id, patch] of Object.entries(updates)) { const node = this.data.fileNodes.find(n => n.id === id); if (node) { - Object.assign(node, patch, { modified: new Date().toISOString() }); + Object.assign(node, patch); updated.push(id); } } diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 549d06060..d8669fc72 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -148,6 +148,8 @@ export interface IJMAPClient { // ── Push / state ────────────────────────────────────────────── setupPushNotifications(): boolean; closePushNotifications(): void; + /** Whether this client holds one of the tab's live event streams (vs. slow-polling or none). */ + hasSSEStream(): boolean; onConnectionChange(callback: (connected: boolean) => void): void; onRateLimit(callback: (rateLimited: boolean, retryAfterMs: number) => void): void; isRateLimited(): boolean; diff --git a/lib/keyboard.ts b/lib/keyboard.ts index 749d585bd..8ab485c07 100644 --- a/lib/keyboard.ts +++ b/lib/keyboard.ts @@ -24,3 +24,20 @@ export function isEditableEventTarget(event: Event): boolean { return attr === "" || attr === "true" || attr === "plaintext-only"; }); } + +/** + * Whether a key event comes from a control that Enter or Space activates + * itself (a link, a button): a page-wide shortcut on those keys must leave it + * alone, or a focused "Skip to message list" link opens a message instead. + */ +export function isActivatableEventTarget(event: Event): boolean { + const path = + typeof event.composedPath === "function" + ? event.composedPath() + : [event.target]; + return path.some( + (node) => + node instanceof HTMLElement && + node.matches('a[href], button, summary, [role="button"], [role="link"], [role="checkbox"], [role="menuitem"], [role="option"], [role="tab"], [role="switch"]'), + ); +} diff --git a/lib/leave-guard.ts b/lib/leave-guard.ts new file mode 100644 index 000000000..6e927031a --- /dev/null +++ b/lib/leave-guard.ts @@ -0,0 +1,78 @@ +import { useEffect, useRef } from "react"; +import { historySettled } from "@/hooks/use-close-on-back"; + +/** + * Leave guard (R5): an editor with unsaved changes registers here, and the + * shell's own navigation (the desktop rail, the phone tab bar) asks it + * before leaving. The editor's in-app exits (Cancel, another item, Back) + * ask through the editor's own code; this covers what the editor can't see. + */ +export interface LeaveGuard { + /** True while there is something to lose. Read on every navigation. */ + isDirty: () => boolean; + /** + * Ask the user; resolves true when the changes may be dropped. Only ask: + * the navigation that follows unmounts the editor, and clearing its state + * first can run its own clean-up (a Back guard's history step) before the + * navigation lands. The guard isn't asked again for that navigation. + */ + confirm: () => Promise; +} + +const guards = new Set(); +// Set while the guarded click is replayed, so it isn't stopped again. +let replaying = false; +let asking = false; + +export function registerLeaveGuard(guard: LeaveGuard): () => void { + guards.add(guard); + return () => { guards.delete(guard); }; +} + +export function hasUnsavedChanges(): boolean { + for (const guard of guards) if (guard.isDirty()) return true; + return false; +} + +/** Asks each editor with changes in turn; true when all of them may be left. */ +export async function confirmLeave(): Promise { + for (const guard of [...guards]) { + if (guard.isDirty() && !(await guard.confirm())) return false; + } + return true; +} + +/** + * For the click handler of a link or button that leaves the current app: + * when an editor has unsaved changes, stops the click, asks, and clicks the + * same element again once the user lets the changes go. Returns true when + * it stopped the click; the handler should then do nothing else. + */ +export function interceptLeave(e: { preventDefault(): void; currentTarget: EventTarget | null }): boolean { + if (replaying || !hasUnsavedChanges()) return false; + e.preventDefault(); + const target = e.currentTarget instanceof HTMLElement ? e.currentTarget : null; + if (asking) return true; + asking = true; + void confirmLeave().then(async (leave) => { + asking = false; + if (!leave || !target) return; + // The dialog that asked steps Back off its own history entry as it + // closes; a navigation started before that lands is undone by it. + await new Promise((resolve) => setTimeout(resolve, 0)); + await historySettled(); + replaying = true; + try { target.click(); } finally { replaying = false; } + }); + return true; +} + +/** Registers the calling editor's guard while it is mounted. */ +export function useLeaveGuard(isDirty: () => boolean, confirm: () => Promise): void { + const latest = useRef({ isDirty, confirm }); + useEffect(() => { latest.current = { isDirty, confirm }; }); + useEffect(() => registerLeaveGuard({ + isDirty: () => latest.current.isDirty(), + confirm: () => latest.current.confirm(), + }), []); +} diff --git a/lib/push-bindings.ts b/lib/push-bindings.ts index f02a53cfa..0834ee304 100644 --- a/lib/push-bindings.ts +++ b/lib/push-bindings.ts @@ -17,7 +17,8 @@ export type PushRole = 'active' | 'background'; export interface PushBindable { setupPushNotifications(): unknown; closePushNotifications(): void; - hasSSEStream(): boolean; + /** Optional: a client without it (demo, tests) counts as having no live stream. */ + hasSSEStream?(): boolean; } export interface PushBinding { @@ -77,7 +78,7 @@ export function reconcilePushBindings({ if (!bound) { bind(client, accountId, role); } else if (bound.role !== role) { - if (role === 'active' && !client.hasSSEStream()) { + if (role === 'active' && !(typeof client.hasSSEStream === 'function' && client.hasSSEStream())) { // Newly active on a slow poll: take the stream from the login that // just stopped being active - re-bound as background below - so the // account the user is looking at still updates live. diff --git a/lib/sieve/__tests__/external-rules.test.ts b/lib/sieve/__tests__/external-rules.test.ts index d734af1fd..4160023cb 100644 --- a/lib/sieve/__tests__/external-rules.test.ts +++ b/lib/sieve/__tests__/external-rules.test.ts @@ -287,6 +287,24 @@ describe('external rule preservation (issue #201)', () => { expect(externalAfter[0].name).toBe('Untouchable'); expect(externalAfter[0].conditions[0]).toMatchObject({ field: 'header', headerName: 'X-Spam' }); }); + + it('writes the same script on every save, with no blank lines piling up around external rules', () => { + const initial = `${generateScript([makeBulwarkRule({ name: 'Mine' })])}\n\n# rule:[First]\nif header :is "X-Spam" "yes" {\n discard;\n}\n\n\n# rule:[Second]\nif header :contains "Subject" "invoice" {\n fileinto "Finance";\n}\n\n`; + const save = (script: string) => { + const parsed = parseScript(script); + return generateScript(parsed.rules, parsed.vacation, { externalRequires: parsed.externalRequires }); + }; + + const first = save(initial); + const second = save(first); + const third = save(second); + + expect(second).toBe(first); + expect(third).toBe(first); + expect(first).not.toMatch(/\n\n\n/); + expect(first).toContain('# --- External rules (managed outside Bulwark) ---\n# rule:[First]'); + expect(first).toContain('}\n\n# rule:[Second]'); + }); }); describe('Nextcloud Mail marker regions', () => { diff --git a/lib/sieve/generator.ts b/lib/sieve/generator.ts index f4cdf7c1d..de7e442f1 100644 --- a/lib/sieve/generator.ts +++ b/lib/sieve/generator.ts @@ -495,15 +495,17 @@ export function generateScript( lines.push('}'); } - // Append preserved external rules verbatim. Each rawBlock already carries its - // own leading comments and trailing whitespace from the source script. - if (externalRules.length > 0) { + // Append preserved external rules verbatim. Each rawBlock carries its own + // leading comments, but also the blank lines around it in the source + // script; trim those and put exactly one blank line between blocks, so a + // save writes the same script as the one before it. + const externalBlocks = externalRules + .map((ext) => (ext.rawBlock ?? '').trim()) + .filter((block) => block.length > 0); + if (externalBlocks.length > 0) { lines.push(''); lines.push('# --- External rules (managed outside Bulwark) ---'); - for (const ext of externalRules) { - if (!ext.rawBlock) continue; - lines.push(ext.rawBlock.replace(/\s+$/, '')); - } + lines.push(externalBlocks.join('\n\n')); } lines.push(''); diff --git a/locales/ar/common.json b/locales/ar/common.json index 4807fb7b7..cb1572518 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -1013,8 +1013,8 @@ "email_tagged": "تم وسم الرسالة", "emails_tagged": "تم وسم {count} رسالة", "tag_failed": "فشل الوسم", - "identity_created": "تم إنشاء الهوية بنجاح", - "identity_updated": "تم تحديث الهوية بنجاح", + "identity_created": "تم إنشاء الهوية", + "identity_updated": "تم تحديث الهوية", "identity_deleted": "تم حذف الهوية", "identity_set_primary": "تم تحديث الهوية الأساسية", "identity_create_failed": "فشل إنشاء الهوية: {error}", @@ -1024,10 +1024,10 @@ "identity_not_found": "الهوية غير موجودة", "vacation_saved": "تم حفظ إعدادات الغياب", "vacation_save_failed": "فشل حفظ إعدادات الغياب", - "filters_saved": "تم حفظ المرشحات بنجاح", + "filters_saved": "تم حفظ المرشحات", "filters_save_failed": "فشل حفظ المرشحات", "filters_deleted": "تم حذف قاعدة المرشح", - "templates_exported": "تم تصدير القوالب بنجاح", + "templates_exported": "تم تصدير القوالب", "templates_imported": "{count, plural, one {تم استيراد قالب واحد} other {تم استيراد # قالب}}", "templates_import_errors": "تعذّر استيراد بعض القوالب", "templates_import_empty": "لم يتم العثور على قوالب في الملف", @@ -1120,8 +1120,8 @@ "import_success": "تم استيراد الإعدادات بنجاح", "import_error": "فشل استيراد الإعدادات", "reset_confirm": "هل أنت متأكد أنك تريد إعادة تعيين جميع الإعدادات إلى الافتراضية؟", - "unsaved_changes": "لديك تغييرات غير محفوظة", - "discard_changes": "تجاهل التغييرات غير المحفوظة؟", + "unsaved_changes": "لم يتم حفظ تغييراتك.", + "discard_changes": "تجاهل التغييرات؟", "discard": "تجاهل", "keep_editing": "متابعة التعديل", "search_placeholder": "بحث في الإعدادات", @@ -1355,6 +1355,8 @@ "id_exists": "معرّف الوسم هذا موجود بالفعل", "edit": "تعديل الوسم", "delete": "حذف الوسم", + "edit_tag": "تعديل {name}", + "delete_tag": "حذف {name}", "deleted": "تم حذف الوسم", "save": "حفظ", "add": "إضافة", @@ -1948,7 +1950,7 @@ "app_passwords": { "title": "كلمات مرور التطبيقات", "description": "أنشئ كلمات مرور للتطبيقات التي لا تدعم المصادقة الثنائية", - "add": "إضافة", + "new": "كلمة مرور تطبيق جديدة", "create": "إنشاء", "cancel": "إلغاء", "done": "تم", @@ -1973,6 +1975,7 @@ "api_keys": { "title": "مفاتيح API", "description": "أنشئ مفاتيح API للنصوص البرمجية والتكاملات التي تتواصل مع الخادم مباشرة", + "new": "مفتاح API جديد", "name_label": "اسم المفتاح", "name_placeholder": "مثال: نص النسخ الاحتياطي، عامل CI", "copy_now_warning": "انسخ مفتاح API هذا الآن - لن يُعرض مرة أخرى.", @@ -2012,7 +2015,7 @@ "jmap_username_label": "اسم مستخدم JMAP", "copy": "نسخ", "copied": "تم النسخ", - "password_instructions": "استخدم اسم مستخدم JMAP أعلاه مع كلمة مرور تطبيق لتسجيل الدخول إلى عميل البريد الخاص بك. أنشئ كلمة مرور تطبيق في القسم أعلاه إذا لم تفعل بعد." + "password_instructions": "استخدم اسم مستخدم JMAP أعلاه مع كلمة مرور تطبيق لتسجيل الدخول إلى عميل البريد الخاص بك. أنشئ كلمة مرور تطبيق في القسم أدناه إذا لم تفعل بعد." }, "link_device": { "title": "ربط تطبيق الجوال", @@ -2054,6 +2057,8 @@ "key_placeholder": "الصق مفتاحك العام هنا", "none": "لا توجد مفاتيح عامة مُعدّة", "title": "المفاتيح العامة", + "add": "إضافة مفتاح عام", + "add_submit": "إضافة", "added": "تمت إضافة المفتاح العام", "removed": "تم حذف المفتاح العام", "add_error": "تعذّرت إضافة المفتاح العام", @@ -2341,10 +2346,10 @@ "export_label": "تصدير جهات الاتصال", "export_description": "تصدير جميع جهات الاتصال كملف vCard (‎.vcf)", "manage_title": "دفاتر العناوين", - "manage_description": "إعادة تسمية دفاتر عناوينك", + "manage_description": "إعادة تسمية دفاتر عناوينك أو حذفها", "no_address_books": "لم يتم العثور على دفاتر عناوين", "categories_title": "الفئات", - "categories_description": "إعادة تسمية فئات جهات الاتصال", + "categories_description": "إعادة تسمية فئات جهات الاتصال أو حذفها", "no_categories": "لم يتم العثور على فئات" }, "calendar_management": { @@ -2418,7 +2423,7 @@ "validation_error": "النص يحتوي على أخطاء", "save": "حفظ", "saving": "جارٍ الحفظ...", - "saved": "تم حفظ المرشحات بنجاح", + "saved": "تم حفظ المرشحات", "save_failed": "فشل حفظ المرشحات", "loading": "جارٍ تحميل المرشحات...", "not_supported": "خادم بريدك لا يدعم مرشحات البريد.", @@ -2486,6 +2491,7 @@ "label_name": "اسم الوسم", "label_placeholder": "اختر وسمًا", "header_name": "اسم الترويسة", + "condition_value": "قيمة الشرط", "header_placeholder": "مثال: X-Mailing-List", "size_bytes": "الحجم بالبايت", "size_placeholder": "مثال: 1000000", @@ -2513,6 +2519,10 @@ "validation_empty_name": "اسم القاعدة مطلوب", "validation_empty_conditions": "مطلوب شرط واحد على الأقل بقيمة", "validation_empty_actions": "مطلوب إجراء واحد على الأقل", + "validation_action_folder": "اختر مجلدًا لهذا الإجراء", + "validation_action_tag": "اختر وسمًا لهذا الإجراء", + "validation_action_forward": "أدخل عنوانًا لإعادة التوجيه إليه", + "validation_action_reject": "أدخل رسالة الرفض", "validation_header_name": "يمكن أن يحتوي اسم الترويسة على الأحرف A-Z والأرقام والشرطات والشرطات السفلية فقط", "templates_section": "البدء من قالب", "template_newsletters": "نقل النشرات البريدية إلى مجلد", @@ -2525,12 +2535,12 @@ "script_content": "نص Sieve", "valid": "النص صالح", "invalid": "النص يحتوي على أخطاء", - "save_warning": "سيؤدي الحفظ إلى استبدال أي قواعد مرئية. لا يمكن التراجع عن هذا. انقر على حفظ مرة أخرى للتأكيد.", "validating": "جارٍ التحقق...", "validate": "تحقق", "cancel": "إلغاء", "save": "حفظ", - "confirm_save": "تأكيد الحفظ", + "overwrite_title": "استبدال قواعدك المرئية؟", + "overwrite_message": "هذا البرنامج النصي لا يحتوي على القواعد التي أنشأتها في المحرر المرئي. حفظه يستبدلها، ولا يمكن التراجع عن ذلك.", "validation_failed": "فشل طلب التحقق" }, "rule_summary": { @@ -3258,8 +3268,8 @@ "empty_filtered_hint": "حاول تعديل المرشحات أو مسحها", "clear_search": "مسح البحث", "import_vcard": "استيراد vCard", - "delete_confirm_title": "حذف جهة الاتصال", - "delete_confirm": "هل أنت متأكد أنك تريد حذف جهة الاتصال هذه؟", + "delete_named_title": "حذف {name}؟", + "delete_details_message": "ستُحذف عناوين البريد الإلكتروني وأرقام الهواتف والملاحظات وكل التفاصيل الأخرى. لا يمكنك التراجع عن ذلك.", "local_mode": "تُخزَّن جهات الاتصال محليًا (الخادم لا يدعم جهات اتصال JMAP)", "back_to_contacts": "العودة إلى جهات الاتصال", "back_to_email": "العودة إلى البريد", @@ -3295,6 +3305,7 @@ "share": "مشاركة دفتر العناوين", "new_contact_in_book": "جهة اتصال جديدة في دفتر العناوين هذا", "delete": "حذف دفتر العناوين", + "delete_title": "حذف دفتر العناوين؟", "confirm_delete": "حذف \"{name}\"؟ ستتم إزالة جميع جهات الاتصال في دفتر العناوين هذا.", "deleted": "تم حذف دفتر العناوين", "delete_failed": "فشل حذف دفتر العناوين", @@ -3499,7 +3510,7 @@ "edit": "تعديل المجموعة", "empty": "لا توجد مجموعات بعد", "delete_confirm_title": "حذف المجموعة", - "delete_confirm": "هل أنت متأكد أنك تريد حذف هذه المجموعة؟", + "delete_message": "ستُحذف المجموعة. تبقى جهات الاتصال التي فيها.", "name_label": "اسم المجموعة", "name_placeholder": "مثال: فريق، عائلة", "name_required": "اسم المجموعة مطلوب", @@ -3541,13 +3552,13 @@ "selected": "{count, plural, one {تم تحديد واحدة} other {تم تحديد #}}", "select_all": "تحديد الكل", "delete": "حذف", - "delete_confirm_title": "حذف جهات الاتصال", "delete_confirm": "حذف {count, plural, one {جهة اتصال واحدة} other {# جهة اتصال}}؟", "deleted": "{count, plural, one {تم حذف جهة اتصال واحدة} other {تم حذف # جهة اتصال}}", "add_to_group": "إضافة إلى مجموعة", "choose_group": "اختر مجموعة", "new_group": "مجموعة جديدة…", "adding_contacts": "جارٍ إضافة {count, plural, one {جهة اتصال واحدة} other {# جهة اتصال}}", + "moving_contacts": "جارٍ نقل {count, plural, one {جهة اتصال واحدة} other {# جهة اتصال}}", "added_to_group": "تمت إضافة جهات الاتصال إلى المجموعة", "export": "تصدير", "clear": "إلغاء التحديد" @@ -3776,6 +3787,10 @@ "recurrence_scope": { "edit_title": "تعديل حدث متكرر", "delete_title": "حذف حدث متكرر", + "delete_question": "حذف الحدث المتكرر؟", + "delete_this_message": "سيتم حذف هذا التكرار فقط من \"{title}\". لا يمكن التراجع عن ذلك.", + "delete_following_message": "سيتم حذف هذا التكرار وكل التكرارات التالية من \"{title}\". لا يمكن التراجع عن ذلك.", + "delete_all_message": "سيتم حذف كل تكرارات \"{title}\". لا يمكن التراجع عن ذلك.", "description": "هذا حدث متكرر. ما الأحداث التي تريد تعديلها؟", "this_event": "هذا الحدث فقط", "this_and_future": "هذا الحدث والأحداث التالية", @@ -4011,13 +4026,14 @@ "color": "اللون", "change_color": "تغيير اللون", "random_color": "لون عشوائي جديد", - "add_calendar": "إضافة تقويم", + "add_calendar": "تقويم جديد", "edit": "تعديل", "delete": "حذف", "save": "حفظ", "create": "إنشاء", "cancel": "إلغاء", "default": "افتراضي", + "row_actions": "إجراءات {name}", "confirm_delete": "حذف \"{name}\"؟ ستتم إزالة جميع الأحداث في هذا التقويم.", "confirm_clear": "مسح جميع الأحداث من \"{name}\"؟ لا يمكن التراجع عن هذا.", "set_default": "تعيين كافتراضي", diff --git a/locales/ca/common.json b/locales/ca/common.json index 168e71bc6..e491f50f3 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -1013,8 +1013,8 @@ "email_tagged": "Correu etiquetat", "emails_tagged": "{count} correus etiquetats", "tag_failed": "No s'ha pogut etiquetar", - "identity_created": "Identitat creada correctament", - "identity_updated": "Identitat actualitzada correctament", + "identity_created": "Identitat creada", + "identity_updated": "Identitat actualitzada", "identity_deleted": "Identitat suprimida", "identity_set_primary": "Identitat principal actualitzada", "identity_create_failed": "No s'ha pogut crear la identitat: {error}", @@ -1024,10 +1024,10 @@ "identity_not_found": "No s'ha trobat la identitat", "vacation_saved": "S'ha desat la configuració d'absència", "vacation_save_failed": "No s'ha pogut desar la configuració d'absència", - "filters_saved": "Filtres desats correctament", + "filters_saved": "Filtres desats", "filters_save_failed": "No s'han pogut desar els filtres", "filters_deleted": "Regla de filtre suprimida", - "templates_exported": "Plantilles exportades correctament", + "templates_exported": "Plantilles exportades", "templates_imported": "{count, plural, one {# plantilla importada} other {# plantilles importades}}", "templates_import_errors": "Algunes plantilles no s'han pogut importar", "templates_import_empty": "No s'ha trobat cap plantilla al fitxer", @@ -1120,8 +1120,8 @@ "import_success": "Configuració importada correctament", "import_error": "No s'ha pogut importar la configuració", "reset_confirm": "Segur que voleu restablir tota la configuració als valors per defecte?", - "unsaved_changes": "Teniu canvis sense desar", - "discard_changes": "Voleu descartar els canvis sense desar?", + "unsaved_changes": "Els canvis no s'han desat.", + "discard_changes": "Voleu descartar els canvis?", "discard": "Descarta", "keep_editing": "Continua editant", "search_placeholder": "Cerca a la configuració", @@ -1355,6 +1355,8 @@ "id_exists": "Aquest ID d'etiqueta ja existeix", "edit": "Edita l'etiqueta", "delete": "Suprimeix l'etiqueta", + "edit_tag": "Edita {name}", + "delete_tag": "Suprimeix {name}", "deleted": "Etiqueta suprimida", "save": "Desa", "add": "Afegeix", @@ -1948,7 +1950,7 @@ "app_passwords": { "title": "Contrasenyes d'aplicació", "description": "Creeu contrasenyes per a aplicacions que no admeten l'autenticació de dos factors", - "add": "Afegeix", + "new": "Contrasenya d'aplicació nova", "create": "Crea", "cancel": "Cancel·la", "done": "Fet", @@ -1973,6 +1975,7 @@ "api_keys": { "title": "Claus API", "description": "Creeu claus API per a scripts i integracions que es comuniquen directament amb el servidor", + "new": "Clau API nova", "name_label": "Nom de la clau", "name_placeholder": "p. ex. Script de còpia de seguretat, executor de CI", "copy_now_warning": "Copieu aquesta clau API ara - no es tornarà a mostrar.", @@ -2012,7 +2015,7 @@ "jmap_username_label": "Nom d'usuari JMAP", "copy": "Copia", "copied": "Copiat", - "password_instructions": "Utilitzeu el nom d'usuari JMAP anterior juntament amb una contrasenya d'aplicació per iniciar la sessió al client de correu. Creeu una contrasenya d'aplicació a la secció anterior si encara no ho heu fet." + "password_instructions": "Utilitzeu el nom d'usuari JMAP anterior juntament amb una contrasenya d'aplicació per iniciar la sessió al client de correu. Creeu una contrasenya d'aplicació a la secció següent si encara no ho heu fet." }, "link_device": { "title": "Enllaça l'aplicació mòbil", @@ -2054,6 +2057,8 @@ "key_placeholder": "Enganxeu aquí la vostra clau pública", "none": "No hi ha cap clau pública configurada", "title": "Claus públiques", + "add": "Afegeix una clau pública", + "add_submit": "Afegeix", "added": "Clau pública afegida", "removed": "Clau pública suprimida", "add_error": "No s'ha pogut afegir la clau pública", @@ -2341,10 +2346,10 @@ "export_label": "Exporta contactes", "export_description": "Exporteu tots els contactes com a fitxer vCard (.vcf)", "manage_title": "Llibretes d'adreces", - "manage_description": "Canvieu el nom de les llibretes d'adreces", + "manage_description": "Canvieu el nom de les llibretes d'adreces o suprimiu-les", "no_address_books": "No s'ha trobat cap llibreta d'adreces", "categories_title": "Categories", - "categories_description": "Canvieu el nom de les categories de contactes", + "categories_description": "Canvieu el nom de les categories de contactes o suprimiu-les", "no_categories": "No s'ha trobat cap categoria" }, "calendar_management": { @@ -2418,7 +2423,7 @@ "validation_error": "L'script té errors", "save": "Desa", "saving": "Desant...", - "saved": "Filtres desats correctament", + "saved": "Filtres desats", "save_failed": "No s'han pogut desar els filtres", "loading": "Carregant els filtres...", "not_supported": "El servidor de correu no admet filtres de correu.", @@ -2486,6 +2491,7 @@ "label_name": "Nom de l'etiqueta", "label_placeholder": "Selecciona una etiqueta", "header_name": "Nom de la capçalera", + "condition_value": "Valor de la condició", "header_placeholder": "p. ex., X-Mailing-List", "size_bytes": "Mida en bytes", "size_placeholder": "p. ex., 1000000", @@ -2513,6 +2519,10 @@ "validation_empty_name": "El nom de la regla és obligatori", "validation_empty_conditions": "Cal com a mínim una condició amb un valor", "validation_empty_actions": "Cal com a mínim una acció", + "validation_action_folder": "Trieu una carpeta per a aquesta acció", + "validation_action_tag": "Trieu una etiqueta per a aquesta acció", + "validation_action_forward": "Introduïu una adreça on reenviar", + "validation_action_reject": "Introduïu el missatge de rebuig", "validation_header_name": "Els noms de capçalera només poden contenir les lletres A-Z, xifres, guionets i guions baixos", "templates_section": "Comença des d'una plantilla", "template_newsletters": "Mou els butlletins a una carpeta", @@ -2525,12 +2535,12 @@ "script_content": "Script Sieve", "valid": "L'script és vàlid", "invalid": "L'script té errors", - "save_warning": "En desar se sobreescriuran totes les regles visuals. Això no es pot desfer. Torneu a fer clic a Desa per confirmar-ho.", "validating": "Validant...", "validate": "Valida", "cancel": "Cancel·la", "save": "Desa", - "confirm_save": "Confirma el desament", + "overwrite_title": "Voleu substituir les regles visuals?", + "overwrite_message": "Aquest script no conté les regles que heu creat a l'editor visual. Si el deseu, les substituirà, i no es pot desfer.", "validation_failed": "Ha fallat la sol·licitud de validació" }, "rule_summary": { @@ -3258,8 +3268,8 @@ "empty_filtered_hint": "Proveu d'ajustar o netejar els filtres", "clear_search": "Neteja la cerca", "import_vcard": "Importa vCard", - "delete_confirm_title": "Suprimeix el contacte", - "delete_confirm": "Segur que voleu suprimir aquest contacte?", + "delete_named_title": "Voleu suprimir {name}?", + "delete_details_message": "Se suprimiran les adreces electròniques, els números de telèfon, les notes i la resta de dades. No es pot desfer.", "local_mode": "Els contactes es desen localment (el servidor no admet JMAP Contacts)", "back_to_contacts": "Torna als contactes", "back_to_email": "Torna al correu", @@ -3295,6 +3305,7 @@ "share": "Comparteix la llibreta d'adreces", "new_contact_in_book": "Contacte nou en aquesta llibreta d'adreces", "delete": "Suprimeix la llibreta d'adreces", + "delete_title": "Voleu suprimir 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", @@ -3499,7 +3510,7 @@ "edit": "Edita el grup", "empty": "Encara no hi ha grups", "delete_confirm_title": "Suprimeix el grup", - "delete_confirm": "Segur que voleu suprimir aquest grup?", + "delete_message": "Se suprimirà el grup. Els contactes que conté es conserven.", "name_label": "Nom del grup", "name_placeholder": "p. ex., Equip, Família", "name_required": "El nom del grup és obligatori", @@ -3541,13 +3552,13 @@ "selected": "{count, plural, one {1 seleccionat} other {# seleccionats}}", "select_all": "Selecciona-ho tot", "delete": "Suprimeix", - "delete_confirm_title": "Suprimeix els contactes", "delete_confirm": "Voleu suprimir {count, plural, one {1 contacte} other {# contactes}}?", "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}}", + "moving_contacts": "Movent {count, plural, one {1 contacte} other {# contactes}}", "added_to_group": "Contactes afegits al grup", "export": "Exporta", "clear": "Neteja la selecció" @@ -3776,6 +3787,10 @@ "recurrence_scope": { "edit_title": "Edita l'esdeveniment periòdic", "delete_title": "Suprimeix l'esdeveniment periòdic", + "delete_question": "Voleu suprimir l'esdeveniment periòdic?", + "delete_this_message": "Només se suprimirà aquesta repetició de «{title}». No es pot desfer.", + "delete_following_message": "Se suprimiran aquesta repetició i totes les següents de «{title}». No es pot desfer.", + "delete_all_message": "Se suprimiran totes les repeticions de «{title}». No es pot desfer.", "description": "Aquest és un esdeveniment periòdic. Quins esdeveniments voleu modificar?", "this_event": "Només aquest esdeveniment", "this_and_future": "Aquest i els següents esdeveniments", @@ -4011,13 +4026,14 @@ "color": "Color", "change_color": "Canvia el color", "random_color": "Color aleatori nou", - "add_calendar": "Afegeix un calendari", + "add_calendar": "Calendari nou", "edit": "Edita", "delete": "Suprimeix", "save": "Desa", "create": "Crea", "cancel": "Cancel·la", "default": "Predeterminat", + "row_actions": "Accions per a {name}", "confirm_delete": "Voleu suprimir «{name}»? S'eliminaran tots els esdeveniments d'aquest calendari.", "confirm_clear": "Voleu esborrar tots els esdeveniments de «{name}»? Això no es pot desfer.", "set_default": "Estableix com a predeterminat", diff --git a/locales/cs/common.json b/locales/cs/common.json index d0cec617a..3f66414b3 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1013,8 +1013,8 @@ "email_tagged": "Zpráva byla označena štítkem", "emails_tagged": "{count} zpráv bylo označeno štítkem", "tag_failed": "Označení štítkem selhalo", - "identity_created": "Identita úspěšně vytvořena", - "identity_updated": "Identita úspěšně aktualizována", + "identity_created": "Identita vytvořena", + "identity_updated": "Identita aktualizována", "identity_deleted": "Identita odstraněna", "identity_set_primary": "Hlavní identita byla aktualizována", "identity_create_failed": "Vytvoření identity selhalo: {error}", @@ -1024,10 +1024,10 @@ "identity_not_found": "Identita nenalezena", "vacation_saved": "Nastavení nepřítomnosti bylo uloženo", "vacation_save_failed": "Uložení nastavení nepřítomnosti selhalo", - "filters_saved": "Filtry úspěšně uloženy", + "filters_saved": "Filtry uloženy", "filters_save_failed": "Uložení filtrů selhalo", "filters_deleted": "Pravidlo filtru bylo odstraněno", - "templates_exported": "Šablony úspěšně exportovány", + "templates_exported": "Šablony exportovány", "templates_imported": "{count, plural, one {Importována 1 šablona} few {Importovány # šablony} other {Importováno # šablon}}", "templates_import_errors": "Některé šablony se nepodařilo importovat", "templates_import_empty": "V souboru nebyly nalezeny žádné šablony", @@ -1120,8 +1120,8 @@ "import_success": "Nastavení bylo importováno", "import_error": "Import nastavení selhal", "reset_confirm": "Opravdu chcete obnovit výchozí nastavení?", - "unsaved_changes": "Máte neuložené změny", - "discard_changes": "Zahodit neuložené změny?", + "unsaved_changes": "Vaše změny nebyly uloženy.", + "discard_changes": "Zahodit změny?", "discard": "Zahodit", "keep_editing": "Pokračovat v úpravách", "tabs": { @@ -1352,6 +1352,8 @@ "id_exists": "Toto ID štítku již existuje", "edit": "Upravit štítek", "delete": "Odstranit štítek", + "edit_tag": "Upravit {name}", + "delete_tag": "Smazat {name}", "deleted": "Štítek byl odstraněn", "save": "Uložit", "add": "Přidat", @@ -1941,7 +1943,7 @@ "app_passwords": { "title": "Hesla aplikací", "description": "Vytvořte hesla pro aplikace, které nepodporují dvoufázové ověření", - "add": "Přidat", + "new": "Nové heslo aplikace", "create": "Vytvořit", "cancel": "Zrušit", "done": "Hotovo", @@ -1966,6 +1968,7 @@ "api_keys": { "title": "API klíče", "description": "Vytvořte si API klíče pro skripty a integrace, které komunikují přímo se serverem.", + "new": "Nový API klíč", "name_label": "Název klíče", "name_placeholder": "např. Zálohovací skript, CI runner", "copy_now_warning": "API klíč si hned zkopírujte – už se znovu nezobrazí.", @@ -2005,7 +2008,7 @@ "jmap_username_label": "Uživatelské jméno JMAP", "copy": "Kopírovat", "copied": "Zkopírováno", - "password_instructions": "Použijte výše uvedené uživatelské jméno JMAP společně s heslem aplikace pro přihlášení v poštovním klientovi. Pokud jste tak ještě neučinili, vytvořte si heslo aplikace v sekci výše." + "password_instructions": "Použijte výše uvedené uživatelské jméno JMAP společně s heslem aplikace pro přihlášení v poštovním klientovi. Pokud jste tak ještě neučinili, vytvořte si heslo aplikace v sekci níže." }, "link_device": { "title": "Propojit mobilní aplikaci", @@ -2047,6 +2050,8 @@ "key_placeholder": "Sem vložte svůj veřejný klíč", "none": "Nejsou nastaveny žádné veřejné klíče", "title": "Veřejné klíče", + "add": "Přidat veřejný klíč", + "add_submit": "Přidat", "added": "Veřejný klíč byl přidán", "removed": "Veřejný klíč byl odstraněn", "add_error": "Veřejný klíč se nepodařilo přidat", @@ -2334,10 +2339,10 @@ "export_label": "Exportovat kontakty", "export_description": "Exportovat všechny kontakty jako soubor vCard (.vcf)", "manage_title": "Adresáře", - "manage_description": "Přejmenovat své adresáře", + "manage_description": "Přejmenovat nebo smazat své adresáře", "no_address_books": "Nenalezeny žádné adresáře", "categories_title": "Kategorie", - "categories_description": "Přejmenovat kategorie kontaktů", + "categories_description": "Přejmenovat nebo smazat kategorie kontaktů", "no_categories": "Nenalezeny žádné kategorie" }, "calendar_management": { @@ -2411,7 +2416,7 @@ "validation_error": "Skript obsahuje chyby", "save": "Uložit", "saving": "Ukládání...", - "saved": "Filtry byly uloženy", + "saved": "Filtry uloženy", "save_failed": "Uložení filtrů selhalo", "loading": "Načítání filtrů...", "not_supported": "Váš poštovní server nepodporuje e-mailové filtry.", @@ -2479,6 +2484,7 @@ "label_name": "Název štítku", "label_placeholder": "Vyberte štítek", "header_name": "Název hlavičky", + "condition_value": "Hodnota podmínky", "header_placeholder": "např. X-Mailing-List", "size_bytes": "Velikost v bajtech", "size_placeholder": "např. 1000000", @@ -2506,6 +2512,10 @@ "validation_empty_name": "Název pravidla je vyžadován", "validation_empty_conditions": "Je vyžadována alespoň jedna podmínka s hodnotou", "validation_empty_actions": "Je vyžadována alespoň jedna akce", + "validation_action_folder": "Vyberte složku pro tuto akci", + "validation_action_tag": "Vyberte štítek pro tuto akci", + "validation_action_forward": "Zadejte adresu, na kterou se má přeposílat", + "validation_action_reject": "Zadejte zprávu o odmítnutí", "validation_header_name": "Názvy hlaviček smí obsahovat jen písmena A-Z, číslice, pomlčky a podtržítka", "templates_section": "Začít ze šablony", "template_newsletters": "Přesunout newslettery do složky", @@ -2518,12 +2528,12 @@ "script_content": "Skript Sieve", "valid": "Skript je platný", "invalid": "Skript obsahuje chyby", - "save_warning": "Uložení přepíše všechna vizuální pravidla. Tuto akci nelze vrátit zpět. Potvrďte opětovným kliknutím na Uložit.", "validating": "Ověřování...", "validate": "Ověřit", "cancel": "Zrušit", "save": "Uložit", - "confirm_save": "Potvrdit uložení", + "overwrite_title": "Nahradit vizuální pravidla?", + "overwrite_message": "Tento skript neobsahuje pravidla, která jste vytvořili ve vizuálním editoru. Uložením je nahradíte a nelze to vrátit zpět.", "validation_failed": "Požadavek na ověření selhal" }, "rule_summary": { @@ -3258,8 +3268,8 @@ "empty_filtered_hint": "Zkuste filtry upravit nebo zrušit", "clear_search": "Vymazat hledání", "import_vcard": "Importovat vCard", - "delete_confirm_title": "Odstranit kontakt", - "delete_confirm": "Opravdu chcete odstranit tento kontakt?", + "delete_named_title": "Odstranit {name}?", + "delete_details_message": "Smažou se e-mailové adresy, telefonní čísla, poznámky a všechny další údaje. Tuto akci nelze vrátit.", "local_mode": "Kontakty jsou uloženy lokálně (server nepodporuje JMAP Contacts)", "back_to_contacts": "Zpět na kontakty", "back_to_email": "Zpět na e-mail", @@ -3293,6 +3303,7 @@ "share": "Sdílet adresář", "new_contact_in_book": "Nový kontakt v tomto adresáři", "delete": "Smazat adresář", + "delete_title": "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", @@ -3497,7 +3508,7 @@ "edit": "Upravit skupinu", "empty": "Žádné skupiny", "delete_confirm_title": "Odstranit skupinu", - "delete_confirm": "Opravdu chcete odstranit tuto skupinu?", + "delete_message": "Skupina bude smazána. Kontakty v ní zůstanou zachovány.", "name_label": "Název skupiny", "name_placeholder": "např. Tým, Rodina", "name_required": "Název skupiny je vyžadován", @@ -3539,13 +3550,13 @@ "selected": "{count, plural, one {1 vybrán} few {# vybrány} other {# vybráno}}", "select_all": "Vybrat vše", "delete": "Odstranit", - "delete_confirm_title": "Odstranit kontakty", "delete_confirm": "Odstranit {count, plural, one {1 kontakt} few {# kontakty} other {# kontaktů}}?", "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ů}}", + "moving_contacts": "Přesouvání {count, plural, one {1 kontaktu} few {# kontaktů} other {# kontaktů}}", "added_to_group": "Kontakty byly přidány do skupiny", "export": "Exportovat", "clear": "Zrušit výběr" @@ -3776,6 +3787,10 @@ "recurrence_scope": { "edit_title": "Upravit opakující se událost", "delete_title": "Odstranit opakující se událost", + "delete_question": "Odstranit opakující se událost?", + "delete_this_message": "Odstraní se pouze tento výskyt události \"{title}\". Tuto akci nelze vrátit.", + "delete_following_message": "Odstraní se tento a všechny další výskyty události \"{title}\". Tuto akci nelze vrátit.", + "delete_all_message": "Odstraní se všechny výskyty události \"{title}\". Tuto akci nelze vrátit.", "description": "Toto je opakující se událost. Které události chcete upravit?", "this_event": "Pouze tuto událost", "this_and_future": "Tuto a všechny následující události", @@ -3982,13 +3997,14 @@ "color": "Barva", "change_color": "Změnit barvu", "random_color": "Nová náhodná barva", - "add_calendar": "Přidat kalendář", + "add_calendar": "Nový kalendář", "edit": "Upravit", "delete": "Odstranit", "save": "Uložit", "create": "Vytvořit", "cancel": "Zrušit", "default": "Výchozí", + "row_actions": "Akce pro {name}", "confirm_delete": "Odstranit \"{name}\"? Všechny události v tomto kalendáři budou odstraněny.", "confirm_clear": "Vymazat všechny události z \"{name}\"? Tuto akci nelze vrátit zpět.", "set_default": "Nastavit jako výchozí", diff --git a/locales/da/common.json b/locales/da/common.json index 42e1616ce..c5d6fdd05 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -1013,8 +1013,8 @@ "email_tagged": "E-mail tagget", "emails_tagged": "{count} e-mails tagget", "tag_failed": "Tagning mislykkedes", - "identity_created": "Identitet oprettet succesfuldt", - "identity_updated": "Identitet opdateret succesfuldt", + "identity_created": "Identitet oprettet", + "identity_updated": "Identitet opdateret", "identity_deleted": "Identitet slettet", "identity_set_primary": "Primær identitet opdateret", "identity_create_failed": "Kunne ikke oprette identitet: {error}", @@ -1024,10 +1024,10 @@ "identity_not_found": "Identitet ikke fundet", "vacation_saved": "Fraværsindstillinger gemt", "vacation_save_failed": "Kunne ikke gemme fraværsindstillinger", - "filters_saved": "Filtre gemt succesfuldt", + "filters_saved": "Filtre gemt", "filters_save_failed": "Kunne ikke gemme filtre", "filters_deleted": "Filterregel slettet", - "templates_exported": "Skabeloner eksporteret succesfuldt", + "templates_exported": "Skabeloner eksporteret", "templates_imported": "{count, plural, one {# skabelon} other {# skabeloner}} importeret", "templates_import_errors": "Nogle skabeloner kunne ikke importeres", "templates_import_empty": "Ingen skabeloner fundet i filen", @@ -1120,8 +1120,8 @@ "import_success": "Indstillinger importeret succesfuldt", "import_error": "Kunne ikke importere indstillinger", "reset_confirm": "Er du sikker på, at du vil nulstille alle indstillinger til standard?", - "unsaved_changes": "Du har ugemte ændringer", - "discard_changes": "Kassér ugemte ændringer?", + "unsaved_changes": "Dine ændringer er ikke gemt.", + "discard_changes": "Kassér ændringer?", "discard": "Kassér", "keep_editing": "Fortsæt redigering", "search_placeholder": "Søg i indstillinger", @@ -1355,6 +1355,8 @@ "id_exists": "Dette tag-ID findes allerede", "edit": "Redigér tag", "delete": "Slet tag", + "edit_tag": "Rediger {name}", + "delete_tag": "Slet {name}", "deleted": "Tag slettet", "save": "Gem", "add": "Tilføj", @@ -1944,7 +1946,7 @@ "app_passwords": { "title": "App-adgangskoder", "description": "Opret adgangskoder til apps, der ikke understøtter to-faktor-godkendelse", - "add": "Tilføj", + "new": "Ny app-adgangskode", "create": "Opret", "cancel": "Annuller", "done": "Færdig", @@ -1969,6 +1971,7 @@ "api_keys": { "title": "API-nøgler", "description": "Opret API-nøgler til scripts og integrationer, der taler direkte med serveren", + "new": "Ny API-nøgle", "name_label": "Nøglenavn", "name_placeholder": "f.eks. Backup-script, CI-runner", "copy_now_warning": "Kopier denne API-nøgle nu - den vil ikke blive vist igen.", @@ -2008,7 +2011,7 @@ "jmap_username_label": "JMAP-brugernavn", "copy": "Kopier", "copied": "Kopieret", - "password_instructions": "Brug dit JMAP-brugernavn ovenfor sammen med en app-adgangskode for at logge ind i din e-mailklient. Opret en app-adgangskode i sektionen ovenfor, hvis du ikke allerede har gjort det." + "password_instructions": "Brug dit JMAP-brugernavn ovenfor sammen med en app-adgangskode for at logge ind i din e-mailklient. Opret en app-adgangskode i sektionen nedenfor, hvis du ikke allerede har gjort det." }, "link_device": { "title": "Tilknyt mobilapp", @@ -2050,6 +2053,8 @@ "key_placeholder": "Indsæt din offentlige nøgle her", "none": "Ingen offentlige nøgler konfigureret", "title": "Offentlige nøgler", + "add": "Tilføj offentlig nøgle", + "add_submit": "Tilføj", "added": "Offentlig nøgle tilføjet", "removed": "Offentlig nøgle slettet", "add_error": "Den offentlige nøgle kunne ikke tilføjes", @@ -2337,10 +2342,10 @@ "export_label": "Eksportér kontakter", "export_description": "Eksportér alle kontakter som en vCard-fil (.vcf)", "manage_title": "Adressebøger", - "manage_description": "Omdøb dine adressebøger", + "manage_description": "Omdøb eller slet dine adressebøger", "no_address_books": "Ingen adressebøger fundet", "categories_title": "Kategorier", - "categories_description": "Omdøb kontaktkategorier", + "categories_description": "Omdøb eller slet kontaktkategorier", "no_categories": "Ingen kategorier fundet" }, "calendar_management": { @@ -2414,7 +2419,7 @@ "validation_error": "Scriptet har fejl", "save": "Gem", "saving": "Gemmer...", - "saved": "Filtre gemt succesfuldt", + "saved": "Filtre gemt", "save_failed": "Kunne ikke gemme filtre", "loading": "Indlæser filtre...", "not_supported": "Din mailserver understøtter ikke e-mail-filtre.", @@ -2482,6 +2487,7 @@ "label_name": "Tag-navn", "label_placeholder": "Vælg tag", "header_name": "Headernavn", + "condition_value": "Betingelsens værdi", "header_placeholder": "f.eks. X-Mailing-List", "size_bytes": "Størrelse i bytes", "size_placeholder": "f.eks. 1000000", @@ -2509,6 +2515,10 @@ "validation_empty_name": "Regelnavn er påkrævet", "validation_empty_conditions": "Mindst én betingelse med en værdi er påkrævet", "validation_empty_actions": "Mindst én handling er påkrævet", + "validation_action_folder": "Vælg en mappe til denne handling", + "validation_action_tag": "Vælg et tag til denne handling", + "validation_action_forward": "Angiv en adresse, der skal videresendes til", + "validation_action_reject": "Angiv afvisningsbeskeden", "validation_header_name": "Headernavne må kun indeholde bogstaverne A-Z, cifre, bindestreger og understregninger", "templates_section": "Start fra skabelon", "template_newsletters": "Flyt nyhedsbreve til mappe", @@ -2521,12 +2531,12 @@ "script_content": "Sieve-script", "valid": "Scriptet er gyldigt", "invalid": "Scriptet har fejl", - "save_warning": "Lagring vil overskrive eventuelle visuelle regler. Dette kan ikke fortrydes. Klik Gem igen for at bekræfte.", "validating": "Validerer...", "validate": "Valider", "cancel": "Annuller", "save": "Gem", - "confirm_save": "Bekræft gem", + "overwrite_title": "Erstat dine visuelle regler?", + "overwrite_message": "Dette script indeholder ikke de regler, du har oprettet i den visuelle editor. Hvis du gemmer det, erstattes de, og det kan ikke fortrydes.", "validation_failed": "Valideringsanmodning mislykkedes" }, "rule_summary": { @@ -3258,8 +3268,8 @@ "empty_filtered_hint": "Prøv at justere eller rydde filtre", "clear_search": "Ryd søgning", "import_vcard": "Importér vCard", - "delete_confirm_title": "Slet kontakt", - "delete_confirm": "Er du sikker på, at du vil slette denne kontakt?", + "delete_named_title": "Slet {name}?", + "delete_details_message": "E-mailadresser, telefonnumre, noter og alle andre oplysninger bliver slettet. Det kan ikke fortrydes.", "local_mode": "Kontakter gemmes lokalt (serveren understøtter ikke JMAP-kontakter)", "back_to_contacts": "Tilbage til kontakter", "back_to_email": "Tilbage til e-mail", @@ -3293,6 +3303,7 @@ "share": "Del adressebog", "new_contact_in_book": "Ny kontakt i denne adressebog", "delete": "Slet adressebog", + "delete_title": "Slet adressebog?", "confirm_delete": "Slet \"{name}\"? Alle kontakter i denne adressebog fjernes.", "deleted": "Adressebog slettet", "delete_failed": "Kunne ikke slette adressebog", @@ -3497,7 +3508,7 @@ "edit": "Redigér gruppe", "empty": "Ingen grupper endnu", "delete_confirm_title": "Slet gruppe", - "delete_confirm": "Er du sikker på, at du vil slette denne gruppe?", + "delete_message": "Gruppen bliver slettet. Kontakterne i den bevares.", "name_label": "Gruppenavn", "name_placeholder": "f.eks. Team, Familie", "name_required": "Gruppenavn er påkrævet", @@ -3539,13 +3550,13 @@ "selected": "{count, plural, one {1 valgt} other {# valgte}}", "select_all": "Vælg alle", "delete": "Slet", - "delete_confirm_title": "Slet kontakter", "delete_confirm": "Slet {count, plural, one {1 kontakt} other {# kontakter}}?", "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}}", + "moving_contacts": "Flytter {count, plural, one {1 kontakt} other {# kontakter}}", "added_to_group": "Kontakter tilføjet til gruppe", "export": "Eksportér", "clear": "Ryd markering" @@ -3776,6 +3787,10 @@ "recurrence_scope": { "edit_title": "Redigér tilbagevendende begivenhed", "delete_title": "Slet tilbagevendende begivenhed", + "delete_question": "Slet tilbagevendende begivenhed?", + "delete_this_message": "Kun denne forekomst af \"{title}\" bliver slettet. Det kan ikke fortrydes.", + "delete_following_message": "Denne og alle senere forekomster af \"{title}\" bliver slettet. Det kan ikke fortrydes.", + "delete_all_message": "Alle forekomster af \"{title}\" bliver slettet. Det kan ikke fortrydes.", "description": "Dette er en tilbagevendende begivenhed. Hvilke begivenheder vil du ændre?", "this_event": "Kun denne begivenhed", "this_and_future": "Denne og fremtidige begivenheder", @@ -4008,13 +4023,14 @@ "color": "Farve", "change_color": "Skift farve", "random_color": "Ny tilfældig farve", - "add_calendar": "Tilføj kalender", + "add_calendar": "Ny kalender", "edit": "Redigér", "delete": "Slet", "save": "Gem", "create": "Opret", "cancel": "Annuller", "default": "Standard", + "row_actions": "Handlinger for {name}", "confirm_delete": "Slet \"{name}\"? Alle begivenheder i denne kalender fjernes.", "confirm_clear": "Ryd alle begivenheder fra \"{name}\"? Dette kan ikke fortrydes.", "set_default": "Angiv som standard", diff --git a/locales/de/common.json b/locales/de/common.json index 71425754e..5259d7979 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1013,8 +1013,8 @@ "email_tagged": "E-Mail markiert", "emails_tagged": "{count} E-Mails markiert", "tag_failed": "Markierung fehlgeschlagen", - "identity_created": "Identität erfolgreich erstellt", - "identity_updated": "Identität erfolgreich aktualisiert", + "identity_created": "Identität erstellt", + "identity_updated": "Identität aktualisiert", "identity_deleted": "Identität gelöscht", "identity_set_primary": "Primäre Identität aktualisiert", "identity_create_failed": "Identität erstellen fehlgeschlagen: {error}", @@ -1024,10 +1024,10 @@ "identity_not_found": "Identität nicht gefunden", "vacation_saved": "Abwesenheits-Einstellungen gespeichert", "vacation_save_failed": "Fehler beim Speichern der Abwesenheits-Einstellungen", - "filters_saved": "Filter erfolgreich gespeichert", + "filters_saved": "Filter gespeichert", "filters_save_failed": "Fehler beim Speichern der Filter", "filters_deleted": "Filterregel gelöscht", - "templates_exported": "Vorlagen erfolgreich exportiert", + "templates_exported": "Vorlagen exportiert", "templates_imported": "{count, plural, one {# Vorlage importiert} other {# Vorlagen importiert}}", "templates_import_errors": "Einige Vorlagen konnten nicht importiert werden", "templates_import_empty": "Keine Vorlagen in der Datei gefunden", @@ -1120,7 +1120,7 @@ "import_success": "Einstellungen erfolgreich importiert", "import_error": "Import der Einstellungen fehlgeschlagen", "reset_confirm": "Sind Sie sicher, dass Sie alle Einstellungen auf die Standardwerte zurücksetzen möchten?", - "unsaved_changes": "Sie haben ungespeicherte Änderungen", + "unsaved_changes": "Ihre Änderungen wurden noch nicht gespeichert.", "discard_changes": "Änderungen verwerfen?", "discard": "Verwerfen", "keep_editing": "Weiter bearbeiten", @@ -1352,6 +1352,8 @@ "id_exists": "Diese Label-ID existiert bereits", "edit": "Label bearbeiten", "delete": "Label löschen", + "edit_tag": "{name} bearbeiten", + "delete_tag": "{name} löschen", "deleted": "Label gelöscht", "save": "Speichern", "add": "Hinzufügen", @@ -1941,7 +1943,7 @@ "app_passwords": { "title": "App-Passwörter", "description": "Erstellen Sie Passwörter für Apps, die keine Zwei-Faktor-Authentifizierung unterstützen", - "add": "Hinzufügen", + "new": "Neues App-Passwort", "create": "Erstellen", "cancel": "Abbrechen", "generate": "Generieren", @@ -1966,6 +1968,7 @@ "api_keys": { "title": "API-Schlüssel", "description": "Erstellen Sie API-Schlüssel für Skripte und Integrationen, die direkt mit dem Server kommunizieren", + "new": "Neuer API-Schlüssel", "name_label": "Schlüsselname", "name_placeholder": "z.B. Backup-Skript, CI-Runner", "copy_now_warning": "Kopieren Sie diesen API-Schlüssel jetzt - er wird nicht erneut angezeigt.", @@ -2005,7 +2008,7 @@ "jmap_username_label": "JMAP-Benutzername", "copy": "Kopieren", "copied": "Kopiert", - "password_instructions": "Verwenden Sie Ihren oben angezeigten JMAP-Benutzernamen zusammen mit einem App-Passwort, um sich bei Ihrem E-Mail-Client anzumelden. Erstellen Sie im obigen Abschnitt ein App-Passwort, falls Sie noch keines haben." + "password_instructions": "Verwenden Sie Ihren oben angezeigten JMAP-Benutzernamen zusammen mit einem App-Passwort, um sich bei Ihrem E-Mail-Client anzumelden. Erstellen Sie im Abschnitt unten ein App-Passwort, falls Sie noch keines haben." }, "link_device": { "title": "Mobile App verknüpfen", @@ -2047,6 +2050,8 @@ "key_placeholder": "Fügen Sie hier Ihren öffentlichen Schlüssel ein", "none": "Keine öffentlichen Schlüssel konfiguriert", "title": "Öffentliche Schlüssel", + "add": "Öffentlichen Schlüssel hinzufügen", + "add_submit": "Hinzufügen", "added": "Öffentlicher Schlüssel hinzugefügt", "removed": "Öffentlicher Schlüssel gelöscht", "add_error": "Öffentlicher Schlüssel konnte nicht hinzugefügt werden", @@ -2330,10 +2335,10 @@ "export_label": "Kontakte exportieren", "export_description": "Alle Kontakte als vCard-Datei (.vcf) exportieren", "manage_title": "Adressbücher", - "manage_description": "Adressbücher umbenennen", + "manage_description": "Adressbücher umbenennen oder löschen", "no_address_books": "Keine Adressbücher gefunden", "categories_title": "Kategorien", - "categories_description": "Kontaktkategorien umbenennen", + "categories_description": "Kontaktkategorien umbenennen oder löschen", "no_categories": "Keine Kategorien gefunden", "group_by_letter_description": "Alphabetische Abschnittsüberschriften in der Kontaktliste anzeigen", "group_by_letter_label": "Nach Anfangsbuchstaben gruppieren", @@ -2411,7 +2416,7 @@ "validation_error": "Das Skript enthält Fehler", "save": "Speichern", "saving": "Wird gespeichert...", - "saved": "Filter erfolgreich gespeichert", + "saved": "Filter gespeichert", "save_failed": "Fehler beim Speichern der Filter", "loading": "Filter werden geladen...", "not_supported": "Ihr Mailserver unterstützt keine E-Mail-Filter.", @@ -2479,6 +2484,7 @@ "label_name": "Label-Name", "label_placeholder": "Label auswählen", "header_name": "Header-Name", + "condition_value": "Wert der Bedingung", "header_placeholder": "z.B. X-Mailing-List", "size_bytes": "Größe in Bytes", "size_placeholder": "z.B. 1000000", @@ -2506,6 +2512,10 @@ "validation_empty_name": "Regelname ist erforderlich", "validation_empty_conditions": "Mindestens eine Bedingung mit einem Wert ist erforderlich", "validation_empty_actions": "Mindestens eine Aktion ist erforderlich", + "validation_action_folder": "Wählen Sie einen Ordner für diese Aktion", + "validation_action_tag": "Wählen Sie ein Label für diese Aktion", + "validation_action_forward": "Geben Sie eine Adresse zum Weiterleiten ein", + "validation_action_reject": "Geben Sie die Ablehnungsnachricht ein", "validation_header_name": "Header-Namen dürfen nur die Buchstaben A-Z, Ziffern, Bindestriche und Unterstriche enthalten", "templates_section": "Von Vorlage starten", "template_newsletters": "Newsletter in Ordner verschieben", @@ -2518,12 +2528,12 @@ "script_content": "Sieve-Skript", "valid": "Skript ist gültig", "invalid": "Skript enthält Fehler", - "save_warning": "Das Speichern überschreibt alle visuellen Regeln. Dies kann nicht rückgängig gemacht werden. Klicken Sie erneut auf Speichern zur Bestätigung.", "validating": "Wird validiert...", "validate": "Validieren", "cancel": "Abbrechen", "save": "Speichern", - "confirm_save": "Speichern bestätigen", + "overwrite_title": "Visuelle Regeln ersetzen?", + "overwrite_message": "Dieses Skript enthält die im visuellen Editor erstellten Regeln nicht mehr. Wenn Sie es speichern, ersetzt es diese Regeln. Das lässt sich nicht rückgängig machen.", "validation_failed": "Validierungsanfrage fehlgeschlagen" }, "rule_summary": { @@ -3258,8 +3268,8 @@ "empty_filtered_hint": "Passen Sie die Filter an oder entfernen Sie sie", "clear_search": "Suche löschen", "import_vcard": "vCard importieren", - "delete_confirm_title": "Kontakt löschen", - "delete_confirm": "Möchten Sie diesen Kontakt wirklich löschen?", + "delete_named_title": "{name} löschen?", + "delete_details_message": "E-Mail-Adressen, Telefonnummern, Notizen und alle weiteren Angaben werden gelöscht. Das lässt sich nicht rückgängig machen.", "local_mode": "Kontakte werden lokal gespeichert (Server unterstützt kein JMAP Contacts)", "back_to_contacts": "Zurück zu Kontakten", "back_to_email": "Zurück zur E-Mail", @@ -3293,6 +3303,7 @@ "share": "Adressbuch freigeben", "new_contact_in_book": "Neuer Kontakt in diesem Adressbuch", "delete": "Adressbuch löschen", + "delete_title": "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", @@ -3470,7 +3481,7 @@ "save": "Speichern", "cancel": "Abbrechen", "discard_title": "Änderungen verwerfen?", - "discard_message": "Ihre Änderungen wurden nicht gespeichert.", + "discard_message": "Ihre Änderungen wurden noch nicht gespeichert.", "discard": "Verwerfen", "keep_editing": "Weiter bearbeiten", "remove_entry": "Entfernen", @@ -3497,7 +3508,7 @@ "edit": "Gruppe bearbeiten", "empty": "Keine Gruppen", "delete_confirm_title": "Gruppe löschen", - "delete_confirm": "Möchten Sie diese Gruppe wirklich löschen?", + "delete_message": "Die Gruppe wird gelöscht. Die Kontakte darin bleiben erhalten.", "name_label": "Gruppenname", "name_placeholder": "z.B. Team, Familie", "name_required": "Gruppenname ist erforderlich", @@ -3539,13 +3550,13 @@ "selected": "{count, plural, one {1 ausgewählt} other {# ausgewählt}}", "select_all": "Alle auswählen", "delete": "Löschen", - "delete_confirm_title": "Kontakte löschen", "delete_confirm": "{count, plural, one {1 Kontakt} other {# Kontakte}} löschen?", "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", + "moving_contacts": "{count, plural, one {1 Kontakt} other {# Kontakte}} verschieben", "added_to_group": "Kontakte zur Gruppe hinzugefügt", "export": "Exportieren", "clear": "Auswahl aufheben" @@ -3776,6 +3787,10 @@ "recurrence_scope": { "edit_title": "Wiederkehrendes Ereignis bearbeiten", "delete_title": "Wiederkehrendes Ereignis löschen", + "delete_question": "Wiederkehrenden Termin löschen?", + "delete_this_message": "Nur dieser Termin von „{title}“ wird gelöscht. Das kann nicht rückgängig gemacht werden.", + "delete_following_message": "Dieser und alle folgenden Termine von „{title}“ werden gelöscht. Das kann nicht rückgängig gemacht werden.", + "delete_all_message": "Alle Termine von „{title}“ werden gelöscht. Das kann nicht rückgängig gemacht werden.", "description": "Dies ist ein wiederkehrendes Ereignis. Welche Ereignisse möchten Sie ändern?", "this_event": "Nur dieses Ereignis", "this_and_future": "Dieses und folgende Ereignisse", @@ -3982,13 +3997,14 @@ "color": "Farbe", "change_color": "Farbe ändern", "random_color": "Neue zufällige Farbe", - "add_calendar": "Kalender hinzufügen", + "add_calendar": "Neuer Kalender", "edit": "Bearbeiten", "delete": "Löschen", "save": "Speichern", "create": "Erstellen", "cancel": "Abbrechen", "default": "Standard", + "row_actions": "Aktionen für {name}", "confirm_delete": "\"{name}\" löschen? Alle Termine in diesem Kalender werden entfernt.", "calendar_created": "Kalender erstellt", "calendar_updated": "Kalender aktualisiert", @@ -5044,7 +5060,7 @@ "loading": "Seite wird geladen", "not_available_title": "Diese Seite ist nicht verfügbar", "not_available_description": "Das Plugin, das sie hinzugefügt hat, ist ausgeschaltet oder nicht mehr installiert.", - "back_to_mail": "Zurück zu E-Mail" + "back_to_mail": "Zurück zu E-Mails" }, "command_palette": { "title": "Befehlspalette", diff --git a/locales/en/common.json b/locales/en/common.json index ffd0b64c5..e1b912864 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -119,7 +119,7 @@ "loading_mailboxes": "Loading mailboxes...", "push_connected": "Real-time updates active", "push_disconnected": "Real-time updates inactive", - "keyboard_shortcuts": "Keyboard Shortcuts", + "keyboard_shortcuts": "Keyboard shortcuts", "theme": { "light": "Light mode", "dark": "Dark mode", @@ -232,25 +232,25 @@ "switching_account": "Switching account..." }, "sidebar_apps": { - "modal_title": "Sidebar Apps", - "add_new": "Add App", - "edit_app": "Edit App", + "modal_title": "Sidebar apps", + "add_new": "Add app", + "edit_app": "Edit app", "name_label": "Name", - "name_placeholder": "My App", + "name_placeholder": "My app", "name_required": "Name is required", "url_label": "URL", "url_required": "URL is required", "url_invalid": "Enter a valid http or https URL", "icon_label": "Icon", "icon_required": "Icon is required", - "open_mode_label": "Open Mode", - "open_new_tab": "New Tab", + "open_mode_label": "Open mode", + "open_new_tab": "New tab", "open_inline": "Inline", "cancel": "Cancel", "add": "Add", "update": "Update", "delete": "Delete", - "delete_confirm_title": "Delete App", + "delete_confirm_title": "Delete app", "delete_confirm": "Are you sure you want to delete \"{name}\"?", "no_apps": "No apps added yet", "no_apps_hint": "Add custom apps and links to your sidebar", @@ -263,7 +263,7 @@ "no_icons_found": "No icons found", "inline_badge": "Inline", "tab_badge": "Tab", - "show_on_mobile": "Show on Mobile" + "show_on_mobile": "Show on mobile" }, "email_list": { "sections": { @@ -1013,8 +1013,8 @@ "email_tagged": "Email tagged", "emails_tagged": "{count} emails tagged", "tag_failed": "Tagging failed", - "identity_created": "Identity created successfully", - "identity_updated": "Identity updated successfully", + "identity_created": "Identity created", + "identity_updated": "Identity updated", "identity_deleted": "Identity deleted", "identity_set_primary": "Primary identity updated", "identity_create_failed": "Failed to create identity: {error}", @@ -1024,10 +1024,10 @@ "identity_not_found": "Identity not found", "vacation_saved": "Out of office settings saved", "vacation_save_failed": "Failed to save out of office settings", - "filters_saved": "Filters saved successfully", + "filters_saved": "Filters saved", "filters_save_failed": "Failed to save filters", "filters_deleted": "Filter rule deleted", - "templates_exported": "Templates exported successfully", + "templates_exported": "Templates exported", "templates_imported": "{count, plural, one {# template} other {# templates}} imported", "templates_import_errors": "Some templates could not be imported", "templates_import_empty": "No templates found in the file", @@ -1120,8 +1120,8 @@ "import_success": "Settings imported", "import_error": "Couldn't import settings", "reset_confirm": "Are you sure you want to reset all settings to defaults?", - "unsaved_changes": "You have unsaved changes", - "discard_changes": "Discard unsaved changes?", + "unsaved_changes": "Your changes haven't been saved.", + "discard_changes": "Discard changes?", "discard": "Discard", "keep_editing": "Keep editing", "search_placeholder": "Search settings", @@ -1355,6 +1355,8 @@ "id_exists": "This tag ID already exists", "edit": "Edit tag", "delete": "Delete tag", + "edit_tag": "Edit {name}", + "delete_tag": "Delete {name}", "deleted": "Tag deleted", "save": "Save", "add": "Add", @@ -1948,7 +1950,7 @@ "app_passwords": { "title": "App passwords", "description": "Create passwords for apps that don't support two-factor authentication", - "add": "Add", + "new": "New app password", "create": "Create", "cancel": "Cancel", "done": "Done", @@ -1973,6 +1975,7 @@ "api_keys": { "title": "API keys", "description": "Create API keys for scripts and integrations that talk to the server directly", + "new": "New API key", "name_label": "Key name", "name_placeholder": "e.g. Backup script, CI runner", "copy_now_warning": "Copy this API key now - it will not be shown again.", @@ -2013,6 +2016,8 @@ "key_placeholder": "Paste your public key here", "none": "No public keys configured", "title": "Public keys", + "add": "Add public key", + "add_submit": "Add", "added": "Public key added", "removed": "Public key deleted", "add_error": "Couldn't add the public key", @@ -2026,7 +2031,7 @@ "jmap_username_label": "JMAP username", "copy": "Copy", "copied": "Copied", - "password_instructions": "Use your JMAP username above along with an app password to sign in to your email client. Create an app password in the section above if you haven't already." + "password_instructions": "Use your JMAP username above along with an app password to sign in to your email client. Create an app password in the section below if you haven't already." }, "link_device": { "title": "Link the mobile app", @@ -2078,7 +2083,7 @@ }, "manage": "Manage identities", "sub_addressing": { - "label": "Sub-Addressing", + "label": "Sub-addressing", "description": "Use tags like user+tag@domain.com to organize incoming mail", "learn_more": "Learn more" } @@ -2341,10 +2346,10 @@ "export_label": "Export contacts", "export_description": "Export all contacts as a vCard (.vcf) file", "manage_title": "Address books", - "manage_description": "Rename your address books", + "manage_description": "Rename or delete your address books", "no_address_books": "No address books found", "categories_title": "Categories", - "categories_description": "Rename contact categories", + "categories_description": "Rename or delete contact categories", "no_categories": "No categories found" }, "calendar_management": { @@ -2486,6 +2491,7 @@ "label_name": "Tag name", "label_placeholder": "Select tag", "header_name": "Header name", + "condition_value": "Condition value", "header_placeholder": "e.g., X-Mailing-List", "size_bytes": "Size in bytes", "size_placeholder": "e.g., 1000000", @@ -2513,6 +2519,10 @@ "validation_empty_name": "Rule name is required", "validation_empty_conditions": "At least one condition with a value is required", "validation_empty_actions": "At least one action is required", + "validation_action_folder": "Select a folder for this action", + "validation_action_tag": "Select a tag for this action", + "validation_action_forward": "Enter an address to forward to", + "validation_action_reject": "Enter the rejection message", "validation_header_name": "Header names may only contain the letters A-Z, digits, hyphens and underscores", "templates_section": "Start from template", "template_newsletters": "Move newsletters to folder", @@ -2525,12 +2535,12 @@ "script_content": "Sieve script", "valid": "Script is valid", "invalid": "Script has errors", - "save_warning": "Saving will overwrite any visual rules. This cannot be undone. Click Save again to confirm.", "validating": "Validating...", "validate": "Validate", "cancel": "Cancel", "save": "Save", - "confirm_save": "Save anyway", + "overwrite_title": "Replace your visual rules?", + "overwrite_message": "This script no longer holds the rules you made in the visual builder. Saving it replaces them, and this can't be undone.", "validation_failed": "Validation request failed" }, "rule_summary": { @@ -2610,20 +2620,20 @@ "description": "Configure how files and folders are displayed" }, "default_view": { - "label": "Default View", + "label": "Default view", "description": "Choose between grid and list layout", "list": "List", "grid": "Grid" }, "default_sort": { - "label": "Default Sort", + "label": "Default sort", "description": "Choose the default sorting for files", "name": "Name", "size": "Size", "modified": "Modified" }, "sort_direction": { - "label": "Sort Direction", + "label": "Sort direction", "description": "Choose ascending or descending order", "ascending": "Ascending", "descending": "Descending" @@ -2633,15 +2643,15 @@ "description": "Configure file icon appearance" }, "show_icons": { - "label": "Show File Icons", + "label": "Show file icons", "description": "Display icons next to files and folders" }, "colored_icons": { - "label": "Colored Icons", + "label": "Colored icons", "description": "Use colorful icons instead of monochrome" }, "show_thumbnails": { - "label": "Show Thumbnails", + "label": "Show thumbnails", "description": "Display image previews instead of icons for image files" }, "behavior": { @@ -2649,7 +2659,7 @@ "description": "Configure file browser behavior" }, "show_hidden": { - "label": "Show Hidden Files", + "label": "Show hidden files", "description": "Display files and folders that start with a dot" }, "preview": { @@ -3022,7 +3032,7 @@ "delete_with_contents_confirm": "Delete folder and messages" }, "shortcuts": { - "title": "Keyboard Shortcuts", + "title": "Keyboard shortcuts", "tip": "Press ? anytime to show this help", "or": "or", "sections": { @@ -3258,8 +3268,8 @@ "empty_filtered_hint": "Try adjusting or clearing filters", "clear_search": "Clear search", "import_vcard": "Import vCard", - "delete_confirm_title": "Delete contact", - "delete_confirm": "Are you sure you want to delete this contact?", + "delete_named_title": "Delete {name}?", + "delete_details_message": "Email addresses, phone numbers, notes and all other details will be deleted. You can't undo this.", "local_mode": "Contacts are stored locally (server does not support JMAP Contacts)", "back_to_contacts": "Back to contacts", "back_to_email": "Back to email", @@ -3295,6 +3305,7 @@ "share": "Share address book", "new_contact_in_book": "New contact in this address book", "delete": "Delete address book", + "delete_title": "Delete address book?", "confirm_delete": "Delete \"{name}\"? All contacts in this address book will be removed.", "deleted": "Address book deleted", "delete_failed": "Couldn't delete the address book", @@ -3499,7 +3510,7 @@ "edit": "Edit group", "empty": "No groups yet", "delete_confirm_title": "Delete group", - "delete_confirm": "Are you sure you want to delete this group?", + "delete_message": "The group will be deleted. The contacts in it are kept.", "name_label": "Group name", "name_placeholder": "e.g., Team, Family", "name_required": "Group name is required", @@ -3533,7 +3544,7 @@ "file_too_large": "File is too large (max 5 MB)" }, "export": { - "title": "Export Contacts", + "title": "Export contacts", "success": "{count, plural, one {1 contact exported} other {# contacts exported}}", "exporting": "Exporting..." }, @@ -3541,13 +3552,13 @@ "selected": "{count, plural, one {1 selected} other {# selected}}", "select_all": "Select all", "delete": "Delete", - "delete_confirm_title": "Delete contacts", "delete_confirm": "Delete {count, plural, one {1 contact} other {# contacts}}?", "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}}", + "moving_contacts": "Moving {count, plural, one {1 contact} other {# contacts}}", "added_to_group": "Contacts added to group", "export": "Export", "clear": "Clear selection" @@ -3776,6 +3787,10 @@ "recurrence_scope": { "edit_title": "Edit recurring event", "delete_title": "Delete recurring event", + "delete_question": "Delete recurring event?", + "delete_this_message": "Only this occurrence of \"{title}\" will be deleted. This can't be undone.", + "delete_following_message": "This and every later occurrence of \"{title}\" will be deleted. This can't be undone.", + "delete_all_message": "Every occurrence of \"{title}\" will be deleted. This can't be undone.", "description": "This is a recurring event. Which events would you like to modify?", "this_event": "This event only", "this_and_future": "This and following events", @@ -4011,13 +4026,14 @@ "color": "Color", "change_color": "Change color", "random_color": "New random color", - "add_calendar": "Add calendar", + "add_calendar": "New calendar", "edit": "Edit", "delete": "Delete", "save": "Save", "create": "Create", "cancel": "Cancel", "default": "Default", + "row_actions": "Actions for {name}", "confirm_delete": "Delete \"{name}\"? All events in this calendar will be removed.", "confirm_clear": "Clear all events from \"{name}\"? This cannot be undone.", "set_default": "Set as default", @@ -5044,7 +5060,7 @@ "loading": "Loading page", "not_available_title": "This page is not available", "not_available_description": "The plugin that added it is turned off or no longer installed.", - "back_to_mail": "Back to Mail" + "back_to_mail": "Back to mail" }, "command_palette": { "title": "Command palette", diff --git a/locales/es/common.json b/locales/es/common.json index 137b45a8e..1ed030031 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1013,8 +1013,8 @@ "email_tagged": "Correo etiquetado", "emails_tagged": "{count} correos etiquetados", "tag_failed": "Error al etiquetar", - "identity_created": "Identidad creada exitosamente", - "identity_updated": "Identidad actualizada exitosamente", + "identity_created": "Identidad creada", + "identity_updated": "Identidad actualizada", "identity_deleted": "Identidad eliminada", "identity_set_primary": "Identidad principal actualizada", "identity_create_failed": "Error al crear identidad: {error}", @@ -1024,10 +1024,10 @@ "identity_not_found": "Identidad no encontrada", "vacation_saved": "Configuración de ausencia guardada", "vacation_save_failed": "Error al guardar la configuración de ausencia", - "filters_saved": "Filtros guardados correctamente", + "filters_saved": "Filtros guardados", "filters_save_failed": "Error al guardar los filtros", "filters_deleted": "Regla de filtrado eliminada", - "templates_exported": "Plantillas exportadas correctamente", + "templates_exported": "Plantillas exportadas", "templates_imported": "{count, plural, one {# plantilla importada} other {# plantillas importadas}}", "templates_import_errors": "Algunas plantillas no se pudieron importar", "templates_import_empty": "No se encontraron plantillas en el archivo", @@ -1114,13 +1114,13 @@ }, "settings": { "title": "Configuración", - "back_to_mail": "Volver al Correo", + "back_to_mail": "Volver al correo", "back_to_settings": "Volver a los ajustes", "save_success": "Configuración guardada exitosamente", "import_success": "Configuración importada exitosamente", "import_error": "Error al importar la configuración", "reset_confirm": "¿Está seguro de que desea restablecer toda la configuración a los valores predeterminados?", - "unsaved_changes": "Tienes cambios sin guardar", + "unsaved_changes": "Tus cambios no se han guardado.", "discard_changes": "¿Descartar los cambios?", "discard": "Descartar", "keep_editing": "Seguir editando", @@ -1352,6 +1352,8 @@ "id_exists": "Este ID de etiqueta ya existe", "edit": "Editar etiqueta", "delete": "Eliminar etiqueta", + "edit_tag": "Editar {name}", + "delete_tag": "Eliminar {name}", "deleted": "Etiqueta eliminada", "save": "Guardar", "add": "Añadir", @@ -1941,7 +1943,7 @@ "app_passwords": { "title": "Contraseñas de aplicación", "description": "Cree contraseñas para aplicaciones que no admiten autenticación de dos factores", - "add": "Agregar", + "new": "Nueva contraseña de aplicación", "create": "Crear", "cancel": "Cancelar", "generate": "Generar", @@ -1966,6 +1968,7 @@ "api_keys": { "title": "Claves API", "description": "Crea claves API para scripts e integraciones que se comunican directamente con el servidor", + "new": "Nueva clave API", "name_label": "Nombre de la clave", "name_placeholder": "p. ej. Script de copia de seguridad, CI runner", "copy_now_warning": "Copia esta clave API ahora - no se mostrará de nuevo.", @@ -2005,7 +2008,7 @@ "jmap_username_label": "Nombre de usuario JMAP", "copy": "Copiar", "copied": "Copiado", - "password_instructions": "Use su nombre de usuario JMAP indicado arriba junto con una contraseña de aplicación para iniciar sesión en su cliente de correo. Cree una contraseña de aplicación en la sección anterior si aún no lo ha hecho." + "password_instructions": "Use su nombre de usuario JMAP indicado arriba junto con una contraseña de aplicación para iniciar sesión en su cliente de correo. Cree una contraseña de aplicación en la sección de abajo si aún no lo ha hecho." }, "link_device": { "title": "Vincular aplicación móvil", @@ -2047,6 +2050,8 @@ "key_placeholder": "Pega aquí tu clave pública", "none": "No hay claves públicas configuradas", "title": "Claves públicas", + "add": "Agregar clave pública", + "add_submit": "Agregar", "added": "Clave pública añadida", "removed": "Clave pública eliminada", "add_error": "No se pudo añadir la clave pública", @@ -2071,7 +2076,7 @@ }, "manage": "Administrar Identidades", "sub_addressing": { - "label": "Sub-Direccionamiento", + "label": "Subdireccionamiento", "description": "Use etiquetas como usuario+etiqueta@dominio.com para organizar el correo entrante", "learn_more": "Más Información" } @@ -2248,7 +2253,7 @@ "description": "Mostrar imágenes de perfil y favicons de remitentes en la carpeta de spam. Desactivado por defecto para no dar apariencia legítima a los intentos de phishing." }, "keyboard_shortcuts": { - "label": "Atajos de Teclado", + "label": "Atajos de teclado", "description": "Ver atajos de teclado disponibles", "button": "Ver Atajos" }, @@ -2330,10 +2335,10 @@ "export_label": "Exportar contactos", "export_description": "Exportar todos los contactos como archivo vCard (.vcf)", "manage_title": "Libretas de direcciones", - "manage_description": "Renombrar tus libretas de direcciones", + "manage_description": "Renombrar o eliminar tus libretas de direcciones", "no_address_books": "No se encontraron libretas de direcciones", "categories_title": "Categorías", - "categories_description": "Renombrar categorías de contactos", + "categories_description": "Renombrar o eliminar categorías de contactos", "no_categories": "No se encontraron categorías", "group_by_letter_description": "Mostrar encabezados alfabéticos en la lista de contactos", "group_by_letter_label": "Agrupar por primera letra", @@ -2411,7 +2416,7 @@ "validation_error": "El script tiene errores", "save": "Guardar", "saving": "Guardando...", - "saved": "Filtros guardados correctamente", + "saved": "Filtros guardados", "save_failed": "Error al guardar los filtros", "loading": "Cargando filtros...", "not_supported": "Su servidor de correo no admite filtros de correo electrónico.", @@ -2479,6 +2484,7 @@ "label_name": "Nombre de la etiqueta", "label_placeholder": "Seleccionar etiqueta", "header_name": "Nombre del encabezado", + "condition_value": "Valor de la condición", "header_placeholder": "ej. X-Mailing-List", "size_bytes": "Tamaño en bytes", "size_placeholder": "ej. 1000000", @@ -2506,6 +2512,10 @@ "validation_empty_name": "El nombre de la regla es obligatorio", "validation_empty_conditions": "Se requiere al menos una condición con un valor", "validation_empty_actions": "Se requiere al menos una acción", + "validation_action_folder": "Selecciona una carpeta para esta acción", + "validation_action_tag": "Selecciona una etiqueta para esta acción", + "validation_action_forward": "Introduce una dirección a la que reenviar", + "validation_action_reject": "Introduce el mensaje de rechazo", "validation_header_name": "Los nombres de encabezado solo pueden contener las letras A-Z, dígitos, guiones y guiones bajos", "templates_section": "Usar una plantilla", "template_newsletters": "Mover newsletters a carpeta", @@ -2518,12 +2528,12 @@ "script_content": "Script Sieve", "valid": "El script es válido", "invalid": "El script tiene errores", - "save_warning": "Guardar sobrescribirá todas las reglas visuales. Esta acción no se puede deshacer. Haga clic en Guardar de nuevo para confirmar.", "validating": "Validando...", "validate": "Validar", "cancel": "Cancelar", "save": "Guardar", - "confirm_save": "Confirmar guardado", + "overwrite_title": "¿Reemplazar las reglas visuales?", + "overwrite_message": "Este script ya no contiene las reglas que creaste en el editor visual. Al guardarlo se reemplazan y no se puede deshacer.", "validation_failed": "Error en la solicitud de validación" }, "rule_summary": { @@ -3022,7 +3032,7 @@ "rename_confirm": "Renombrar" }, "shortcuts": { - "title": "Atajos de Teclado", + "title": "Atajos de teclado", "tip": "Presione ? en cualquier momento para mostrar esta ayuda", "or": "o", "sections": { @@ -3258,8 +3268,8 @@ "empty_filtered_hint": "Ajusta o limpia los filtros", "clear_search": "Borrar búsqueda", "import_vcard": "Importar vCard", - "delete_confirm_title": "Eliminar contacto", - "delete_confirm": "¿Estás seguro de que quieres eliminar este contacto?", + "delete_named_title": "¿Eliminar {name}?", + "delete_details_message": "Se eliminarán las direcciones de correo, los teléfonos, las notas y todos los demás datos. No se puede deshacer.", "local_mode": "Los contactos se almacenan localmente (el servidor no soporta JMAP Contacts)", "back_to_contacts": "Volver a contactos", "back_to_email": "Volver al correo", @@ -3293,6 +3303,7 @@ "share": "Compartir libreta de direcciones", "new_contact_in_book": "Nuevo contacto en esta libreta", "delete": "Eliminar libreta de direcciones", + "delete_title": "¿Eliminar la 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", @@ -3497,7 +3508,7 @@ "edit": "Editar grupo", "empty": "No hay grupos", "delete_confirm_title": "Eliminar grupo", - "delete_confirm": "¿Estás seguro de que quieres eliminar este grupo?", + "delete_message": "Se eliminará el grupo. Los contactos que contiene se conservan.", "name_label": "Nombre del grupo", "name_placeholder": "ej. Equipo, Familia", "name_required": "El nombre del grupo es obligatorio", @@ -3539,13 +3550,13 @@ "selected": "{count, plural, one {1 seleccionado} other {# seleccionados}}", "select_all": "Seleccionar todo", "delete": "Eliminar", - "delete_confirm_title": "Eliminar contactos", "delete_confirm": "¿Eliminar {count, plural, one {1 contacto} other {# contactos}}?", "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}}", + "moving_contacts": "Moviendo {count, plural, one {1 contacto} other {# contactos}}", "added_to_group": "Contactos agregados al grupo", "export": "Exportar", "clear": "Limpiar selección" @@ -3776,6 +3787,10 @@ "recurrence_scope": { "edit_title": "Editar evento recurrente", "delete_title": "Eliminar evento recurrente", + "delete_question": "¿Eliminar evento recurrente?", + "delete_this_message": "Solo se eliminará esta repetición de \"{title}\". No se puede deshacer.", + "delete_following_message": "Se eliminarán esta repetición y todas las siguientes de \"{title}\". No se puede deshacer.", + "delete_all_message": "Se eliminarán todas las repeticiones de \"{title}\". No se puede deshacer.", "description": "Este es un evento recurrente. ¿Qué eventos desea modificar?", "this_event": "Solo este evento", "this_and_future": "Este evento y los siguientes", @@ -3982,13 +3997,14 @@ "color": "Color", "change_color": "Cambiar color", "random_color": "Nuevo color aleatorio", - "add_calendar": "Añadir calendario", + "add_calendar": "Nuevo calendario", "edit": "Editar", "delete": "Eliminar", "save": "Guardar", "create": "Crear", "cancel": "Cancelar", "default": "Predeterminado", + "row_actions": "Acciones para {name}", "confirm_delete": "¿Eliminar \"{name}\"? Se eliminarán todos los eventos de este calendario.", "calendar_created": "Calendario creado", "calendar_updated": "Calendario actualizado", diff --git a/locales/et/common.json b/locales/et/common.json index 95bdc5f0f..10785bcad 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -1013,8 +1013,8 @@ "email_tagged": "E-kiri märgistati", "emails_tagged": "{count} e-kirja märgistati", "tag_failed": "Märgistamine ebaõnnestus", - "identity_created": "Identiteet loodi edukalt", - "identity_updated": "Identiteet uuendati edukalt", + "identity_created": "Identiteet loodud", + "identity_updated": "Identiteet uuendatud", "identity_deleted": "Identiteet kustutati", "identity_set_primary": "Peamine identiteet uuendati", "identity_create_failed": "Identiteedi loomine ebaõnnestus: {error}", @@ -1024,10 +1024,10 @@ "identity_not_found": "Identiteeti ei leitud", "vacation_saved": "Puhkusevastaja seaded salvestati", "vacation_save_failed": "Puhkusevastaja seadete salvestamine ebaõnnestus", - "filters_saved": "Filtrid salvestati edukalt", + "filters_saved": "Filtrid salvestatud", "filters_save_failed": "Filtrite salvestamine ebaõnnestus", "filters_deleted": "Filtrireegel kustutati", - "templates_exported": "Mallid eksporditi edukalt", + "templates_exported": "Mallid eksporditud", "templates_imported": "{count, plural, one {# mall} other {# malli}} imporditi", "templates_import_errors": "Mõnda malli ei õnnestunud importida", "templates_import_empty": "Failist ei leitud ühtegi malli", @@ -1120,8 +1120,8 @@ "import_success": "Seaded imporditud", "import_error": "Seadete importimine ebaõnnestus", "reset_confirm": "Kas soovid kindlasti lähtestada kõik seaded vaikeväärtustele?", - "unsaved_changes": "Sul on salvestamata muudatusi", - "discard_changes": "Kas loobuda salvestamata muudatustest?", + "unsaved_changes": "Teie muudatusi pole salvestatud.", + "discard_changes": "Kas loobuda muudatustest?", "discard": "Loobu", "keep_editing": "Jätka muutmist", "search_placeholder": "Otsi seadetest", @@ -1355,6 +1355,8 @@ "id_exists": "See sildi ID on juba kasutusel", "edit": "Muuda silti", "delete": "Kustuta silt", + "edit_tag": "Muuda {name}", + "delete_tag": "Kustuta {name}", "deleted": "Silt kustutatud", "save": "Salvesta", "add": "Lisa", @@ -1948,7 +1950,7 @@ "app_passwords": { "title": "Rakenduste paroolid", "description": "Loo paroolid rakendustele, mis ei toeta kaheastmelist autentimist", - "add": "Lisa", + "new": "Uus rakenduse parool", "create": "Loo", "cancel": "Tühista", "done": "Valmis", @@ -1973,6 +1975,7 @@ "api_keys": { "title": "API võtmed", "description": "Loo API võtmeid skriptidele ja integratsioonidele, mis suhtlevad serveriga otse", + "new": "Uus API võti", "name_label": "Võtme nimi", "name_placeholder": "nt Varunduse skript, CI runner", "copy_now_warning": "Kopeeri see API võti kohe - seda ei kuvata enam uuesti.", @@ -2013,6 +2016,8 @@ "key_placeholder": "Kleebi oma avalik võti siia", "none": "Ühtegi avalikku võtit pole seadistatud", "title": "Avalikud võtmed", + "add": "Lisa avalik võti", + "add_submit": "Lisa", "added": "Avalik võti lisatud", "removed": "Avalik võti kustutatud", "add_error": "Avalikku võtit ei õnnestunud lisada", @@ -2026,7 +2031,7 @@ "jmap_username_label": "JMAP kasutajanimi", "copy": "Kopeeri", "copied": "Kopeeritud", - "password_instructions": "Kasuta oma e-posti kliendisse sisselogimiseks ülaltoodud JMAP kasutajanime koos rakenduse parooliga. Kui sul veel pole rakenduse parooli, loo see ülaltoodud jaotises." + "password_instructions": "Kasuta oma e-posti kliendisse sisselogimiseks ülaltoodud JMAP kasutajanime koos rakenduse parooliga. Kui sul veel pole rakenduse parooli, loo see allolevas jaotises." }, "link_device": { "title": "Ühenda mobiilirakendus", @@ -2341,10 +2346,10 @@ "export_label": "Ekspordi kontaktid", "export_description": "Ekspordi kõik kontaktid vCard (.vcf) failina", "manage_title": "Aadressiraamatud", - "manage_description": "Nimeta oma aadressiraamatud ümber", + "manage_description": "Nimeta oma aadressiraamatud ümber või kustuta need", "no_address_books": "Aadressiraamatuid ei leitud", "categories_title": "Kategooriad", - "categories_description": "Nimeta kontaktikategooriad ümber", + "categories_description": "Nimeta kontaktikategooriad ümber või kustuta need", "no_categories": "Kategooriaid ei leitud" }, "calendar_management": { @@ -2418,7 +2423,7 @@ "validation_error": "Skriptis on vigu", "save": "Salvesta", "saving": "Salvestamine...", - "saved": "Filtrid salvestatud edukalt", + "saved": "Filtrid salvestatud", "save_failed": "Filtrite salvestamine ebaõnnestus", "loading": "Filtrite laadimine...", "not_supported": "Sinu meiliserver ei toeta e-posti filtreid.", @@ -2486,6 +2491,7 @@ "label_name": "Sildi nimi", "label_placeholder": "Vali silt", "header_name": "Päise nimi", + "condition_value": "Tingimuse väärtus", "header_placeholder": "nt X-Mailing-List", "size_bytes": "Suurus baitides", "size_placeholder": "nt 1000000", @@ -2513,6 +2519,10 @@ "validation_empty_name": "Reegli nimi on kohustuslik", "validation_empty_conditions": "Vajalik on vähemalt üks väärtusega tingimus", "validation_empty_actions": "Vajalik on vähemalt üks tegevus", + "validation_action_folder": "Vali selle toimingu jaoks kaust", + "validation_action_tag": "Vali selle toimingu jaoks silt", + "validation_action_forward": "Sisesta aadress, kuhu edasi saata", + "validation_action_reject": "Sisesta tagasilükkamise sõnum", "validation_header_name": "Päise nimes võivad olla ainult tähed A-Z, numbrid, sidekriipsud ja allkriipsud", "templates_section": "Alusta mallist", "template_newsletters": "Liiguta uudiskirjad kausta", @@ -2525,12 +2535,12 @@ "script_content": "Sieve skript", "valid": "Skript on kehtiv", "invalid": "Skriptis on vigu", - "save_warning": "Salvestamine kirjutab kõik visuaalsed reeglid üle. Seda ei saa tagasi võtta. Kinnitamiseks klõpsa uuesti nuppu Salvesta.", "validating": "Valideerimine...", "validate": "Valideeri", "cancel": "Tühista", "save": "Salvesta", - "confirm_save": "Kinnita salvestamine", + "overwrite_title": "Kas asendada visuaalsed reeglid?", + "overwrite_message": "See skript ei sisalda enam visuaalses redaktoris loodud reegleid. Salvestamine asendab need ja seda ei saa tagasi võtta.", "validation_failed": "Valideerimispäring ebaõnnestus" }, "rule_summary": { @@ -3258,8 +3268,8 @@ "empty_filtered_hint": "Proovi filtreid muuta või need tühjendada", "clear_search": "Tühjenda otsing", "import_vcard": "Impordi vCard", - "delete_confirm_title": "Kustuta kontakt", - "delete_confirm": "Kas oled kindel, et soovid selle kontakti kustutada?", + "delete_named_title": "Kustutada {name}?", + "delete_details_message": "E-posti aadressid, telefoninumbrid, märkmed ja kõik muud andmed kustutatakse. Seda ei saa tagasi võtta.", "local_mode": "Kontaktid salvestatakse kohalikult (server ei toeta JMAP Contacts funktsiooni)", "back_to_contacts": "Tagasi kontaktide juurde", "back_to_email": "Tagasi e-posti juurde", @@ -3295,6 +3305,7 @@ "share": "Jaga aadressiraamatut", "new_contact_in_book": "Uus kontakt selles aadressiraamatus", "delete": "Kustuta aadressiraamat", + "delete_title": "Kas kustutada aadressiraamat?", "confirm_delete": "Kustutada \"{name}\"? Kõik selle aadressiraamatu kontaktid eemaldatakse.", "deleted": "Aadressiraamat kustutatud", "delete_failed": "Aadressiraamatu kustutamine ebaõnnestus", @@ -3499,7 +3510,7 @@ "edit": "Muuda gruppi", "empty": "Gruppe veel pole", "delete_confirm_title": "Kustuta grupp", - "delete_confirm": "Kas oled kindel, et soovid selle grupi kustutada?", + "delete_message": "Grupp kustutatakse. Selles olevad kontaktid jäävad alles.", "name_label": "Grupi nimi", "name_placeholder": "nt meeskond, pere", "name_required": "Grupi nimi on kohustuslik", @@ -3541,13 +3552,13 @@ "selected": "{count, plural, one {1 valitud} other {# valitud}}", "select_all": "Vali kõik", "delete": "Kustuta", - "delete_confirm_title": "Kustuta kontaktid", "delete_confirm": "Kustutada {count, plural, one {1 kontakt} other {# kontakti}}?", "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}}", + "moving_contacts": "Teisaldamine: {count, plural, one {1 kontakt} other {# kontakti}}", "added_to_group": "Kontaktid lisati gruppi", "export": "Eksport", "clear": "Tühista valik" @@ -3776,6 +3787,10 @@ "recurrence_scope": { "edit_title": "Korduva sündmuse muutmine", "delete_title": "Korduva sündmuse kustutamine", + "delete_question": "Kas kustutada korduv sündmus?", + "delete_this_message": "Kustutatakse ainult see \"{title}\" kordus. Seda ei saa tagasi võtta.", + "delete_following_message": "Kustutatakse see ja kõik järgmised \"{title}\" kordused. Seda ei saa tagasi võtta.", + "delete_all_message": "Kustutatakse kõik \"{title}\" kordused. Seda ei saa tagasi võtta.", "description": "See on korduv sündmus. Milliseid sündmusi soovid muuta?", "this_event": "Ainult see sündmus", "this_and_future": "See ja järgnevad sündmused", @@ -4011,13 +4026,14 @@ "color": "Värv", "change_color": "Muuda värvi", "random_color": "Uus juhuslik värv", - "add_calendar": "Lisa kalender", + "add_calendar": "Uus kalender", "edit": "Muuda", "delete": "Kustuta", "save": "Salvesta", "create": "Loo", "cancel": "Tühista", "default": "Vaikimisi", + "row_actions": "Toimingud: {name}", "confirm_delete": "Kustutada \"{name}\"? Kõik selle kalendri sündmused eemaldatakse.", "confirm_clear": "Tühjendada kõik sündmused kalendrist \"{name}\"? Seda ei saa tagasi võtta.", "set_default": "Määra vaikimisi kalendriks", @@ -5044,7 +5060,7 @@ "loading": "Lehe laadimine", "not_available_title": "See leht pole saadaval", "not_available_description": "Pistikprogramm, mis selle lisas, on välja lülitatud või enam paigaldamata.", - "back_to_mail": "Tagasi meili juurde" + "back_to_mail": "Tagasi postkasti" }, "command_palette": { "title": "Käsupalett", diff --git a/locales/fa/common.json b/locales/fa/common.json index d7402fb50..bded27b2c 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -1013,8 +1013,8 @@ "email_tagged": "ایمیل برچسب‌گذاری شد", "emails_tagged": "{count} ایمیل برچسب‌گذاری شد", "tag_failed": "برچسب‌گذاری ناموفق بود", - "identity_created": "هویت با موفقیت ایجاد شد", - "identity_updated": "هویت با موفقیت به‌روزرسانی شد", + "identity_created": "هویت ایجاد شد", + "identity_updated": "هویت به‌روزرسانی شد", "identity_deleted": "هویت حذف شد", "identity_set_primary": "هویت اصلی به‌روزرسانی شد", "identity_create_failed": "ایجاد هویت ناموفق بود: {error}", @@ -1024,10 +1024,10 @@ "identity_not_found": "هویت یافت نشد", "vacation_saved": "تنظیمات غیبت ذخیره شد", "vacation_save_failed": "ذخیره تنظیمات غیبت ناموفق بود", - "filters_saved": "فیلترها با موفقیت ذخیره شدند", + "filters_saved": "فیلترها ذخیره شدند", "filters_save_failed": "ذخیره فیلترها ناموفق بود", "filters_deleted": "قانون فیلتر حذف شد", - "templates_exported": "قالب‌ها با موفقیت خروجی گرفته شدند", + "templates_exported": "قالب‌ها خروجی گرفته شدند", "templates_imported": "{count, plural, one {# قالب} other {# قالب}} وارد شد", "templates_import_errors": "برخی قالب‌ها وارد نشدند", "templates_import_empty": "قالبی در فایل یافت نشد", @@ -1120,8 +1120,8 @@ "import_success": "تنظیمات با موفقیت وارد شد", "import_error": "وارد کردن تنظیمات ناموفق بود", "reset_confirm": "آیا از بازنشانی همه تنظیمات اطمینان دارید؟", - "unsaved_changes": "تغییرات ذخیره نشده دارید", - "discard_changes": "دور انداختن تغییرات ذخیره نشده؟", + "unsaved_changes": "تغییرات شما ذخیره نشده است.", + "discard_changes": "تغییرات دور انداخته شود؟", "discard": "دور انداختن", "keep_editing": "ادامه ویرایش", "search_placeholder": "جستجوی تنظیمات", @@ -1355,6 +1355,8 @@ "id_exists": "این شناسه قبلاً وجود دارد", "edit": "ویرایش برچسب", "delete": "حذف برچسب", + "edit_tag": "ویرایش {name}", + "delete_tag": "حذف {name}", "deleted": "برچسب حذف شد", "save": "ذخیره", "add": "افزودن", @@ -1948,7 +1950,7 @@ "app_passwords": { "title": "رمزهای برنامه", "description": "برای برنامه‌های بدون 2FA", - "add": "افزودن", + "new": "رمز برنامهٔ جدید", "create": "ایجاد", "cancel": "انصراف", "done": "انجام شد", @@ -1973,6 +1975,7 @@ "api_keys": { "title": "کلیدهای API", "description": "برای اسکریپت‌ها", + "new": "کلید API جدید", "name_label": "نام کلید", "name_placeholder": "مثال: اسکریپت پشتیبان", "copy_now_warning": "همین حالا کپی کنید", @@ -2012,7 +2015,7 @@ "jmap_username_label": "نام کاربری JMAP", "copy": "کپی", "copied": "کپی شد", - "password_instructions": "با رمز برنامه استفاده کنید" + "password_instructions": "از نام کاربری JMAP بالا همراه با یک رمز برنامه برای ورود به برنامهٔ ایمیل خود استفاده کنید. اگر هنوز رمز برنامه نساخته‌اید، آن را در بخش زیر بسازید." }, "link_device": { "title": "اتصال موبایل", @@ -2054,6 +2057,8 @@ "key_placeholder": "کلید عمومی خود را اینجا بچسبانید", "none": "هیچ کلید عمومی‌ای تنظیم نشده است", "title": "کلیدهای عمومی", + "add": "افزودن کلید عمومی", + "add_submit": "افزودن", "added": "کلید عمومی افزوده شد", "removed": "کلید عمومی حذف شد", "add_error": "افزودن کلید عمومی ممکن نشد", @@ -2341,10 +2346,10 @@ "export_label": "خروجی مخاطبین", "export_description": "خروجی همه مخاطبین به صورت فایل vCard (.vcf)", "manage_title": "دفترچه‌های آدرس", - "manage_description": "تغییر نام دفترچه‌های آدرس", + "manage_description": "تغییر نام یا حذف دفترچه‌های آدرس", "no_address_books": "دفترچه آدرسی یافت نشد", "categories_title": "دسته‌بندی‌ها", - "categories_description": "تغییر نام دسته‌بندی‌های مخاطبین", + "categories_description": "تغییر نام یا حذف دسته‌بندی‌های مخاطبین", "no_categories": "دسته‌بندی یافت نشد" }, "calendar_management": { @@ -2418,7 +2423,7 @@ "validation_error": "اسکریپت خطا دارد", "save": "ذخیره", "saving": "در حال ذخیره...", - "saved": "فیلترها با موفقیت ذخیره شدند", + "saved": "فیلترها ذخیره شدند", "save_failed": "ذخیره فیلترها ناموفق بود", "loading": "در حال بارگذاری فیلترها...", "not_supported": "سرور ایمیل شما از فیلترهای ایمیل پشتیبانی نمی‌کند.", @@ -2486,6 +2491,7 @@ "label_name": "نام برچسب", "label_placeholder": "انتخاب برچسب", "header_name": "نام هدر", + "condition_value": "مقدار شرط", "header_placeholder": "مثال: X-Mailing-List", "size_bytes": "حجم به بایت", "size_placeholder": "مثال: 1000000", @@ -2513,6 +2519,10 @@ "validation_empty_name": "نام قانون الزامی است", "validation_empty_conditions": "حداقل یک شرط با مقدار الزامی است", "validation_empty_actions": "حداقل یک عملیات الزامی است", + "validation_action_folder": "یک پوشه برای این عمل انتخاب کنید", + "validation_action_tag": "یک برچسب برای این عمل انتخاب کنید", + "validation_action_forward": "نشانی‌ای برای هدایت وارد کنید", + "validation_action_reject": "پیام رد را وارد کنید", "validation_header_name": "نام هدر فقط می‌تواند شامل حروف A-Z، ارقام، خط تیره و زیرخط باشد", "templates_section": "شروع از قالب", "template_newsletters": "انتقال خبرنامه‌ها به پوشه", @@ -2525,12 +2535,12 @@ "script_content": "اسکریپت Sieve", "valid": "اسکریپت معتبر است", "invalid": "اسکریپت خطا دارد", - "save_warning": "ذخیره قوانین بصری را بازنویسی می‌کند. این عملیات قابل بازگشت نیست. برای تأیید دوباره ذخیره را کلیک کنید.", "validating": "در حال اعتبارسنجی...", "validate": "اعتبارسنجی", "cancel": "انصراف", "save": "ذخیره", - "confirm_save": "تأیید ذخیره", + "overwrite_title": "قوانین بصری جایگزین شوند؟", + "overwrite_message": "این اسکریپت دیگر قوانینی را که در ویرایشگر بصری ساخته‌اید ندارد. ذخیرهٔ آن جایگزین آن‌ها می‌شود و قابل بازگشت نیست.", "validation_failed": "درخواست اعتبارسنجی ناموفق بود" }, "rule_summary": { @@ -3258,8 +3268,8 @@ "empty_filtered_hint": "فیلترها را تنظیم یا پاک کنید", "clear_search": "پاک کردن جستجو", "import_vcard": "وارد کردن vCard", - "delete_confirm_title": "حذف مخاطب", - "delete_confirm": "آیا از حذف این مخاطب اطمینان دارید؟", + "delete_named_title": "{name} حذف شود؟", + "delete_details_message": "نشانی‌های ایمیل، شماره‌تلفن‌ها، یادداشت‌ها و همهٔ جزئیات دیگر حذف می‌شوند. این کار قابل بازگشت نیست.", "local_mode": "مخاطبین به صورت محلی ذخیره می‌شوند (سرور از JMAP Contacts پشتیبانی نمی‌کند)", "back_to_contacts": "بازگشت به مخاطبین", "back_to_email": "بازگشت به ایمیل", @@ -3295,6 +3305,7 @@ "share": "اشتراک‌گذاری دفترچه آدرس", "new_contact_in_book": "مخاطب جدید در این دفترچه آدرس", "delete": "حذف دفترچه آدرس", + "delete_title": "دفترچه آدرس حذف شود؟", "confirm_delete": "حذف \"{name}\"؟ همه مخاطبین این دفترچه آدرس حذف خواهند شد.", "deleted": "دفترچه آدرس حذف شد", "delete_failed": "حذف دفترچه آدرس ناموفق بود", @@ -3471,7 +3482,7 @@ "add_phone": "افزودن تلفن", "save": "ذخیره", "cancel": "انصراف", - "discard_title": "تغییرات دور انداخته شوند؟", + "discard_title": "تغییرات دور انداخته شود؟", "discard_message": "تغییرات شما ذخیره نشده است.", "discard": "دور انداختن", "keep_editing": "ادامه ویرایش", @@ -3499,7 +3510,7 @@ "edit": "ویرایش گروه", "empty": "هنوز گروهی نیست", "delete_confirm_title": "حذف گروه", - "delete_confirm": "آیا از حذف این گروه اطمینان دارید؟", + "delete_message": "گروه حذف می‌شود. مخاطبان داخل آن باقی می‌مانند.", "name_label": "نام گروه", "name_placeholder": "مثال: تیم، خانواده", "name_required": "نام گروه الزامی است", @@ -3541,13 +3552,13 @@ "selected": "{count, plural, one {۱ انتخاب شده} other {# انتخاب شده}}", "select_all": "انتخاب همه", "delete": "حذف", - "delete_confirm_title": "حذف مخاطبین", "delete_confirm": "حذف {count, plural, one {۱ مخاطب} other {# مخاطب}}؟", "deleted": "{count, plural, one {۱ مخاطب حذف شد} other {# مخاطب حذف شد}}", "add_to_group": "افزودن به گروه", "choose_group": "انتخاب گروه", "new_group": "گروه جدید…", "adding_contacts": "افزودن {count, plural, one {۱ مخاطب} other {# مخاطب}}", + "moving_contacts": "انتقال {count, plural, one {۱ مخاطب} other {# مخاطب}}", "added_to_group": "مخاطبین به گروه اضافه شدند", "export": "خروجی", "clear": "پاک کردن انتخاب" @@ -3776,6 +3787,10 @@ "recurrence_scope": { "edit_title": "ویرایش رویداد تکراری", "delete_title": "حذف رویداد تکراری", + "delete_question": "رویداد تکراری حذف شود؟", + "delete_this_message": "فقط همین تکرارِ \"{title}\" حذف خواهد شد. این کار قابل بازگشت نیست.", + "delete_following_message": "این تکرار و همه تکرارهای بعدیِ \"{title}\" حذف خواهند شد. این کار قابل بازگشت نیست.", + "delete_all_message": "همه تکرارهای \"{title}\" حذف خواهند شد. این کار قابل بازگشت نیست.", "description": "این یک رویداد تکراری است. کدام رویدادها را می‌خواهید تغییر دهید؟", "this_event": "فقط این رویداد", "this_and_future": "این رویداد و رویدادهای بعدی", @@ -4011,13 +4026,14 @@ "color": "رنگ", "change_color": "تغییر رنگ", "random_color": "رنگ تصادفی جدید", - "add_calendar": "افزودن تقویم", + "add_calendar": "تقویم جدید", "edit": "ویرایش", "delete": "حذف", "save": "ذخیره", "create": "ایجاد", "cancel": "انصراف", "default": "پیش‌فرض", + "row_actions": "کارها برای {name}", "confirm_delete": "حذف \"{name}\"؟ همه رویدادهای این تقویم حذف خواهند شد.", "confirm_clear": "پاک کردن همه رویدادهای \"{name}\"؟ این عملیات قابل بازگشت نیست.", "set_default": "تنظیم به عنوان پیش‌فرض", diff --git a/locales/fr/common.json b/locales/fr/common.json index 6a002eb6e..a53619edf 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1013,8 +1013,8 @@ "email_tagged": "E-mail étiqueté", "emails_tagged": "{count} e-mails étiquetés", "tag_failed": "Échec de l'étiquetage", - "identity_created": "Identité créée avec succès", - "identity_updated": "Identité mise à jour avec succès", + "identity_created": "Identité créée", + "identity_updated": "Identité mise à jour", "identity_deleted": "Identité supprimée", "identity_set_primary": "Identité principale mise à jour", "identity_create_failed": "Échec de la création de l'identité: {error}", @@ -1024,10 +1024,10 @@ "identity_not_found": "Identité introuvable", "vacation_saved": "Paramètres d'absence enregistrés", "vacation_save_failed": "Échec de l'enregistrement des paramètres d'absence", - "filters_saved": "Filtres enregistrés avec succès", + "filters_saved": "Filtres enregistrés", "filters_save_failed": "Échec de l'enregistrement des filtres", "filters_deleted": "Règle de filtrage supprimée", - "templates_exported": "Modèles exportés avec succès", + "templates_exported": "Modèles exportés", "templates_imported": "{count, plural, one {# modèle importé} other {# modèles importés}}", "templates_import_errors": "Certains modèles n'ont pas pu être importés", "templates_import_empty": "Aucun modèle trouvé dans le fichier", @@ -1120,7 +1120,7 @@ "import_success": "Paramètres importés avec succès", "import_error": "Échec de l'importation des paramètres", "reset_confirm": "Êtes-vous sûr de vouloir réinitialiser tous les paramètres ?", - "unsaved_changes": "Vous avez des modifications non enregistrées", + "unsaved_changes": "Vos modifications n'ont pas été enregistrées.", "discard_changes": "Abandonner les modifications ?", "discard": "Abandonner", "keep_editing": "Continuer l'édition", @@ -1352,6 +1352,8 @@ "id_exists": "Cet ID d'étiquette existe déjà", "edit": "Éditer l'étiquette", "delete": "Supprimer l'étiquette", + "edit_tag": "Modifier {name}", + "delete_tag": "Supprimer {name}", "deleted": "Étiquette supprimée", "save": "Enregistrer", "add": "Ajouter", @@ -1941,7 +1943,7 @@ "app_passwords": { "title": "Mots de passe d'application", "description": "Créez des mots de passe pour les applications qui ne prennent pas en charge l'authentification à deux facteurs", - "add": "Ajouter", + "new": "Nouveau mot de passe d'application", "create": "Créer", "cancel": "Annuler", "generate": "Générer", @@ -1966,6 +1968,7 @@ "api_keys": { "title": "Clés API", "description": "Créez des clés API pour les scripts et intégrations qui communiquent directement avec le serveur", + "new": "Nouvelle clé API", "name_label": "Nom de la clé", "name_placeholder": "ex. Script de sauvegarde, CI runner", "copy_now_warning": "Copiez cette clé API maintenant - elle ne sera plus affichée.", @@ -2005,7 +2008,7 @@ "jmap_username_label": "Nom d'utilisateur JMAP", "copy": "Copier", "copied": "Copié", - "password_instructions": "Utilisez votre nom d'utilisateur JMAP ci-dessus avec un mot de passe d'application pour vous connecter à votre client de messagerie. Créez un mot de passe d'application dans la section ci-dessus si vous ne l'avez pas encore fait." + "password_instructions": "Utilisez votre nom d'utilisateur JMAP ci-dessus avec un mot de passe d'application pour vous connecter à votre client de messagerie. Créez un mot de passe d'application dans la section ci-dessous si vous ne l'avez pas encore fait." }, "link_device": { "title": "Associer l'application mobile", @@ -2047,6 +2050,8 @@ "key_placeholder": "Collez votre clé publique ici", "none": "Aucune clé publique configurée", "title": "Clés publiques", + "add": "Ajouter une clé publique", + "add_submit": "Ajouter", "added": "Clé publique ajoutée", "removed": "Clé publique supprimée", "add_error": "Impossible d'ajouter la clé publique", @@ -2330,10 +2335,10 @@ "export_label": "Exporter des contacts", "export_description": "Exporter tous les contacts au format vCard (.vcf)", "manage_title": "Carnets d'adresses", - "manage_description": "Renommer vos carnets d'adresses", + "manage_description": "Renommer ou supprimer vos carnets d'adresses", "no_address_books": "Aucun carnet d'adresses trouvé", "categories_title": "Catégories", - "categories_description": "Renommer les catégories de contacts", + "categories_description": "Renommer ou supprimer les catégories de contacts", "no_categories": "Aucune catégorie trouvée", "group_by_letter_description": "Afficher des en-têtes alphabétiques dans la liste de contacts", "group_by_letter_label": "Grouper par première lettre", @@ -2411,7 +2416,7 @@ "validation_error": "Le script contient des erreurs", "save": "Enregistrer", "saving": "Enregistrement...", - "saved": "Filtres enregistrés avec succès", + "saved": "Filtres enregistrés", "save_failed": "Échec de l'enregistrement des filtres", "loading": "Chargement des filtres...", "not_supported": "Votre serveur de messagerie ne prend pas en charge les filtres de courrier.", @@ -2479,6 +2484,7 @@ "label_name": "Nom de l'étiquette", "label_placeholder": "Sélectionner une étiquette", "header_name": "Nom de l'en-tête", + "condition_value": "Valeur de la condition", "header_placeholder": "ex. X-Mailing-List", "size_bytes": "Taille en octets", "size_placeholder": "ex. 1000000", @@ -2506,6 +2512,10 @@ "validation_empty_name": "Le nom de la règle est requis", "validation_empty_conditions": "Au moins une condition avec une valeur est requise", "validation_empty_actions": "Au moins une action est requise", + "validation_action_folder": "Sélectionnez un dossier pour cette action", + "validation_action_tag": "Sélectionnez une étiquette pour cette action", + "validation_action_forward": "Saisissez une adresse de transfert", + "validation_action_reject": "Saisissez le message de refus", "validation_header_name": "Les noms d'en-tête ne peuvent contenir que les lettres A-Z, des chiffres, des traits d'union et des tirets bas", "templates_section": "Partir d'un modèle", "template_newsletters": "Déplacer les newsletters dans un dossier", @@ -2518,12 +2528,12 @@ "script_content": "Script Sieve", "valid": "Le script est valide", "invalid": "Le script contient des erreurs", - "save_warning": "L'enregistrement écrasera toutes les règles visuelles. Cette action est irréversible. Cliquez à nouveau sur Enregistrer pour confirmer.", "validating": "Validation...", "validate": "Valider", "cancel": "Annuler", "save": "Enregistrer", - "confirm_save": "Confirmer l'enregistrement", + "overwrite_title": "Remplacer vos règles visuelles ?", + "overwrite_message": "Ce script ne contient plus les règles créées dans l'éditeur visuel. L'enregistrer les remplace, et c'est irréversible.", "validation_failed": "Échec de la requête de validation" }, "rule_summary": { @@ -3258,8 +3268,8 @@ "empty_filtered_hint": "Ajustez ou effacez les filtres", "clear_search": "Effacer la recherche", "import_vcard": "Importer vCard", - "delete_confirm_title": "Supprimer le contact", - "delete_confirm": "Êtes-vous sûr de vouloir supprimer ce contact ?", + "delete_named_title": "Supprimer {name} ?", + "delete_details_message": "Les adresses e-mail, numéros de téléphone, notes et toutes les autres informations seront supprimés. Cette action est irréversible.", "local_mode": "Les contacts sont stockés localement (le serveur ne prend pas en charge JMAP Contacts)", "back_to_contacts": "Retour aux contacts", "back_to_email": "Retour à l'e-mail", @@ -3293,6 +3303,7 @@ "share": "Partager le carnet d'adresses", "new_contact_in_book": "Nouveau contact dans ce carnet d'adresses", "delete": "Supprimer le carnet d'adresses", + "delete_title": "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", @@ -3497,7 +3508,7 @@ "edit": "Modifier le groupe", "empty": "Aucun groupe", "delete_confirm_title": "Supprimer le groupe", - "delete_confirm": "Êtes-vous sûr de vouloir supprimer ce groupe ?", + "delete_message": "Le groupe sera supprimé. Les contacts qu'il contient sont conservés.", "name_label": "Nom du groupe", "name_placeholder": "ex. Équipe, Famille", "name_required": "Le nom du groupe est requis", @@ -3539,13 +3550,13 @@ "selected": "{count, plural, one {1 sélectionné} other {# sélectionnés}}", "select_all": "Tout sélectionner", "delete": "Supprimer", - "delete_confirm_title": "Supprimer les contacts", "delete_confirm": "Supprimer {count, plural, one {1 contact} other {# contacts}} ?", "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}}", + "moving_contacts": "Déplacement de {count, plural, one {1 contact} other {# contacts}}", "added_to_group": "Contacts ajoutés au groupe", "export": "Exporter", "clear": "Effacer la sélection" @@ -3776,6 +3787,10 @@ "recurrence_scope": { "edit_title": "Modifier l'événement récurrent", "delete_title": "Supprimer l'événement récurrent", + "delete_question": "Supprimer l'événement récurrent ?", + "delete_this_message": "Seule cette occurrence de « {title} » sera supprimée. Cette action est irréversible.", + "delete_following_message": "Cette occurrence et toutes les suivantes de « {title} » seront supprimées. Cette action est irréversible.", + "delete_all_message": "Toutes les occurrences de « {title} » seront supprimées. Cette action est irréversible.", "description": "Ceci est un événement récurrent. Quels événements souhaitez-vous modifier ?", "this_event": "Cet événement uniquement", "this_and_future": "Cet événement et les suivants", @@ -4008,13 +4023,14 @@ "color": "Couleur", "change_color": "Changer la couleur", "random_color": "Nouvelle couleur aléatoire", - "add_calendar": "Ajouter un calendrier", + "add_calendar": "Nouveau calendrier", "edit": "Modifier", "delete": "Supprimer", "save": "Enregistrer", "create": "Créer", "cancel": "Annuler", "default": "Par défaut", + "row_actions": "Actions pour {name}", "confirm_delete": "Supprimer \"{name}\" ? Tous les événements de ce calendrier seront supprimés.", "calendar_created": "Calendrier créé", "calendar_updated": "Calendrier mis à jour", @@ -5044,7 +5060,7 @@ "loading": "Chargement de la page", "not_available_title": "Cette page n'est pas disponible", "not_available_description": "Le plugin qui l'a ajoutée est désactivé ou n'est plus installé.", - "back_to_mail": "Retour à la messagerie" + "back_to_mail": "Retour aux emails" }, "command_palette": { "title": "Palette de commandes", diff --git a/locales/he/common.json b/locales/he/common.json index 451d3320b..56612662e 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -978,8 +978,8 @@ "email_tagged": "אימייל מתויג", "emails_tagged": "דוא\"ל{count}מתויג", "tag_failed": "התיוג נכשל", - "identity_created": "הזהות נוצרה בהצלחה", - "identity_updated": "הזהות עודכנה בהצלחה", + "identity_created": "הזהות נוצרה", + "identity_updated": "הזהות עודכנה", "identity_deleted": "הזהות נמחקה", "identity_set_primary": "הזהות הראשית עודכנה", "identity_create_failed": "יצירת זהות נכשלה:{error}", @@ -989,10 +989,10 @@ "identity_not_found": "זהות לא נמצאה", "vacation_saved": "הגדרות ההיעדרות נשמרו", "vacation_save_failed": "שמירת הגדרות ההיעדרות נכשלה", - "filters_saved": "המסננים נשמרו בהצלחה", + "filters_saved": "המסננים נשמרו", "filters_save_failed": "שמירת המסננים נכשלה", "filters_deleted": "כלל הסינון נמחק", - "templates_exported": "תבניות יוצאו בהצלחה", + "templates_exported": "התבניות יוצאו", "templates_imported": "יובאו {count, plural, one {תבנית אחת} other {# תבניות}}", "templates_import_errors": "לא ניתן לייבא חלק מהתבניות", "templates_import_empty": "לא נמצאו תבניות בקובץ", @@ -1085,10 +1085,10 @@ "import_success": "ההגדרות יובאו בהצלחה", "import_error": "ייבוא ההגדרות נכשל", "reset_confirm": "האם אתה בטוח שברצונך לאפס את כל ההגדרות לברירות מחדל?", - "unsaved_changes": "יש לך שינויים שלא נשמרו", - "discard_changes": "למחוק שינויים שלא נשמרו?", - "discard": "לזרוק", - "keep_editing": "תמשיך לערוך", + "unsaved_changes": "השינויים שלך לא נשמרו.", + "discard_changes": "לבטל את השינויים?", + "discard": "לבטל שינויים", + "keep_editing": "להמשיך לערוך", "tabs": { "appearance": "מראה", "account": "חשבון", @@ -1317,6 +1317,8 @@ "id_exists": "מזהה מילת מפתח זה כבר קיים", "edit": "ערוך מילת מפתח", "delete": "מחק מילת מפתח", + "edit_tag": "עריכת {name}", + "delete_tag": "מחיקת {name}", "deleted": "מילת המפתח נמחקה", "save": "שמור", "add": "לְהוֹסִיף", @@ -1906,7 +1908,7 @@ "app_passwords": { "title": "סיסמאות לאפליקציה", "description": "צור סיסמאות לאפליקציות שאינן תומכות באימות דו-שלבי", - "add": "לְהוֹסִיף", + "new": "סיסמת אפליקציה חדשה", "create": "צור", "cancel": "לְבַטֵל", "generate": "צור", @@ -1956,11 +1958,12 @@ "jmap_username_label": "שם משתמש JMAP", "copy": "העתק", "copied": "הועתק", - "password_instructions": "השתמש בשם המשתמש שלך ב-JMAP למעלה יחד עם סיסמת אפליקציה כדי להיכנס ללקוח הדוא\"ל שלך. צור סיסמת אפליקציה בסעיף למעלה אם עדיין לא עשית זאת." + "password_instructions": "השתמש בשם המשתמש שלך ב-JMAP למעלה יחד עם סיסמת אפליקציה כדי להיכנס ללקוח הדוא\"ל שלך. צור סיסמת אפליקציה בסעיף למטה אם עדיין לא עשית זאת." }, "api_keys": { "title": "מפתחות API", "description": "צור מפתחות API עבור סקריפטים ואינטגרציות המדברות ישירות לשרת", + "new": "מפתח API חדש", "name_label": "שם מפתח", "name_placeholder": "למשל סקריפט גיבוי, פעיל CI", "copy_now_warning": "העתק מפתח API זה כעת - הוא לא יוצג שוב.", @@ -2012,6 +2015,8 @@ "key_placeholder": "הדבק כאן את המפתח הציבורי שלך", "none": "לא הוגדרו מפתחות ציבוריים", "title": "מפתחות ציבוריים", + "add": "הוספת מפתח ציבורי", + "add_submit": "הוסף", "added": "המפתח הציבורי נוסף", "removed": "המפתח הציבורי נמחק", "add_error": "לא ניתן היה להוסיף את המפתח הציבורי", @@ -2296,10 +2301,10 @@ "export_label": "ייצוא אנשי קשר", "export_description": "ייצא את כל אנשי הקשר כקובץ vCard (.vcf).", "manage_title": "ספרי כתובות", - "manage_description": "שנה את שם פנקסי הכתובות שלך", + "manage_description": "שנה את השם של פנקסי הכתובות שלך או מחק אותם", "no_address_books": "לא נמצאו פנקסי כתובות", "categories_title": "קטגוריות", - "categories_description": "שנה שם של קטגוריות אנשי קשר", + "categories_description": "שנה שם של קטגוריות אנשי קשר או מחק אותן", "no_categories": "לא נמצאו קטגוריות", "group_by_letter_label": "קבוצה לפי אות ראשונה", "sort_by_last_name_label": "מיון לפי שם משפחה", @@ -2333,7 +2338,7 @@ "validation_error": "בסקריפט יש שגיאות", "save": "שמירה", "saving": "שומר...", - "saved": "המסננים נשמרו בהצלחה", + "saved": "המסננים נשמרו", "save_failed": "שמירת המסננים נכשלה", "loading": "טוען מסננים...", "not_supported": "שרת הדואר שלך אינו תומך במסנני דואר אלקטרוני.", @@ -2399,6 +2404,7 @@ "label_name": "שם התווית", "label_placeholder": "למשל, חשוב", "header_name": "שם הכותרת", + "condition_value": "ערך התנאי", "header_placeholder": "למשל, X-Mailing-list", "size_bytes": "גודל בבתים", "size_placeholder": "למשל, 1000000", @@ -2426,6 +2432,10 @@ "validation_empty_name": "נדרש שם כלל", "validation_empty_conditions": "נדרש לפחות תנאי אחד עם ערך", "validation_empty_actions": "נדרשת לפחות פעולה אחת", + "validation_action_folder": "בחר תיקייה לפעולה הזו", + "validation_action_tag": "בחר תגית לפעולה הזו", + "validation_action_forward": "הזן כתובת להעברה", + "validation_action_reject": "הזן את הודעת הדחייה", "validation_header_name": "שמות כותרות יכולים להכיל רק את האותיות A-Z, ספרות, מקפים וקווים תחתונים", "templates_section": "התחל מהתבנית", "template_newsletters": "העבר ניוזלטרים לתיקיה", @@ -2438,12 +2448,12 @@ "script_content": "תסריט מסננת", "valid": "הסקריפט תקף", "invalid": "בסקריפט יש שגיאות", - "save_warning": "שמירה תחליף את כל הכללים החזותיים. לא ניתן לבטל זאת. לחץ שוב על שמור כדי לאשר.", "validating": "מאמת...", "validate": "אימות", "cancel": "לְבַטֵל", "save": "שמור", - "confirm_save": "אשר את השמירה", + "overwrite_title": "להחליף את הכללים החזותיים?", + "overwrite_message": "הסקריפט הזה כבר לא מכיל את הכללים שיצרת בעורך החזותי. שמירה תחליף אותם, ואי אפשר לבטל זאת.", "validation_failed": "בקשת האימות נכשלה" }, "rule_summary": { @@ -3174,8 +3184,8 @@ "empty_search_hint": "נסה מונח חיפוש אחר", "clear_search": "נקה חיפוש", "import_vcard": "ייבוא vCard", - "delete_confirm_title": "מחק איש קשר", - "delete_confirm": "האם אתה בטוח שברצונך למחוק איש קשר זה?", + "delete_named_title": "למחוק את {name}?", + "delete_details_message": "כתובות הדוא״ל, מספרי הטלפון, ההערות וכל שאר הפרטים יימחקו. לא ניתן לבטל פעולה זו.", "local_mode": "אנשי הקשר מאוחסנים באופן מקומי (שרת אינו תומך באנשי קשר ב-JMAP)", "back_to_contacts": "חזור לאנשי קשר", "tabs": { @@ -3208,6 +3218,7 @@ "share": "שתף ספר כתובות", "new_contact_in_book": "איש קשר חדש בספר כתובות זה", "delete": "מחק ספר כתובות", + "delete_title": "למחוק את ספר הכתובות?", "confirm_delete": "מחק \"{name}\"? כל אנשי הקשר בספר כתובות זה יוסרו.", "deleted": "ספר כתובות נמחק", "delete_failed": "נכשל במחיקת ספר כתובות", @@ -3375,8 +3386,8 @@ "cancel": "לְבַטֵל", "discard_title": "לבטל את השינויים?", "discard_message": "השינויים שלך לא נשמרו.", - "discard": "בטל", - "keep_editing": "המשך לערוך", + "discard": "לבטל שינויים", + "keep_editing": "להמשיך לערוך", "remove_entry": "הסר", "close": "סגור", "remove_category": "הסר את {name}", @@ -3401,7 +3412,7 @@ "edit": "ערוך קבוצה", "empty": "עדיין אין קבוצות", "delete_confirm_title": "מחק את הקבוצה", - "delete_confirm": "האם אתה בטוח שברצונך למחוק את הקבוצה הזו?", + "delete_message": "הקבוצה תימחק. אנשי הקשר שבה יישמרו.", "name_label": "שם הקבוצה", "name_placeholder": "למשל, צוות, משפחה", "name_required": "נדרש שם הקבוצה", @@ -3443,13 +3454,13 @@ "selected": "{count, plural, one {נבחר אחד} other {נבחרו #}}", "select_all": "בחר הכל", "delete": "לִמְחוֹק", - "delete_confirm_title": "מחק אנשי קשר", "delete_confirm": "למחוק {count, plural, one {איש קשר אחד} other {# אנשי קשר}}?", "deleted": "{count, plural, one {נמחק איש קשר אחד} other {נמחקו # אנשי קשר}}", "add_to_group": "הוסף לקבוצה", "choose_group": "בחר קבוצה", "new_group": "קבוצה חדשה…", "adding_contacts": "מוסיף {count, plural, one {איש קשר אחד} other {# אנשי קשר}}", + "moving_contacts": "מעביר {count, plural, one {איש קשר אחד} other {# אנשי קשר}}", "added_to_group": "אנשי קשר נוספו לקבוצה", "export": "ייצוא", "clear": "נקה בחירה" @@ -3694,6 +3705,10 @@ "recurrence_scope": { "edit_title": "ערוך אירוע חוזר", "delete_title": "מחק אירוע חוזר", + "delete_question": "למחוק את האירוע החוזר?", + "delete_this_message": "רק המופע הזה של \"{title}\" יימחק. לא ניתן לבטל פעולה זו.", + "delete_following_message": "המופע הזה וכל המופעים הבאים של \"{title}\" יימחקו. לא ניתן לבטל פעולה זו.", + "delete_all_message": "כל המופעים של \"{title}\" יימחקו. לא ניתן לבטל פעולה זו.", "description": "זהו אירוע שחוזר על עצמו. אילו אירועים תרצה לשנות?", "this_event": "אירוע זה בלבד", "this_and_future": "זה והאירועים הבאים", @@ -3890,13 +3905,14 @@ "name_placeholder": "שם לוח שנה", "color": "צבע", "change_color": "שנה צבע", - "add_calendar": "הוסף לוח שנה", + "add_calendar": "לוח שנה חדש", "edit": "ערוך", "delete": "לִמְחוֹק", "save": "שמור", "create": "צור", "cancel": "לְבַטֵל", "default": "ברירת מחדל", + "row_actions": "פעולות עבור {name}", "confirm_delete": "למחוק את \"{name}\"? כל האירועים בלוח השנה הזה יוסרו.", "confirm_clear": "לנקות את כל האירועים מ-\"{name}\"? לא ניתן לבטל זאת.", "clear_events": "נקה אירועים", diff --git a/locales/hu/common.json b/locales/hu/common.json index 17e37ce90..13fda52c1 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -1013,8 +1013,8 @@ "email_tagged": "E-mail címkézve", "emails_tagged": "{count} e-mail címkézve", "tag_failed": "Címkézés sikertelen", - "identity_created": "Identitás sikeresen létrehozva", - "identity_updated": "Identitás sikeresen frissítve", + "identity_created": "Identitás létrehozva", + "identity_updated": "Identitás frissítve", "identity_deleted": "Identitás törölve", "identity_set_primary": "Elsődleges identitás frissítve", "identity_create_failed": "Nem sikerült létrehozni az identitást: {error}", @@ -1024,10 +1024,10 @@ "identity_not_found": "Identitás nem található", "vacation_saved": "Távolléti beállítások elmentve", "vacation_save_failed": "Nem sikerült menteni a távolléti beállításokat", - "filters_saved": "Szűrők sikeresen elmentve", + "filters_saved": "Szűrők elmentve", "filters_save_failed": "Nem sikerült menteni a szűrőket", "filters_deleted": "Szűrő szabály törölve", - "templates_exported": "Sablonok sikeresen exportálva", + "templates_exported": "Sablonok exportálva", "templates_imported": "{count, plural, one {# sablon} other {# sablon}} importálva", "templates_import_errors": "Néhány sablon importálása sikertelen", "templates_import_empty": "Nem található sablon a fájlban", @@ -1120,8 +1120,8 @@ "import_success": "Beállítások sikeresen importálva", "import_error": "Nem sikerült importálni a beállításokat", "reset_confirm": "Biztosan vissza akarod állítani az összes beállítást az alapértelmezettre?", - "unsaved_changes": "Nem mentett változtatások vannak", - "discard_changes": "Nem mentett változtatások elvetése?", + "unsaved_changes": "A változtatásai nincsenek mentve.", + "discard_changes": "Elveti a változtatásokat?", "discard": "Elvetés", "keep_editing": "Szerkesztés folytatása", "search_placeholder": "Beállítások keresése", @@ -1355,6 +1355,8 @@ "id_exists": "Ez a címke azonosító már létezik", "edit": "Címke szerkesztése", "delete": "Címke törlése", + "edit_tag": "{name} szerkesztése", + "delete_tag": "{name} törlése", "deleted": "Címke törölve", "save": "Mentés", "add": "Hozzáadás", @@ -1944,7 +1946,7 @@ "app_passwords": { "title": "Alkalmazás jelszavak", "description": "Jelszavak létrehozása olyan alkalmazásokhoz, amelyek nem támogatják a kétfaktoros hitelesítést", - "add": "Hozzáadás", + "new": "Új alkalmazásjelszó", "create": "Létrehozás", "cancel": "Mégse", "done": "Kész", @@ -1969,6 +1971,7 @@ "api_keys": { "title": "API kulcsok", "description": "API kulcsok létrehozása szkriptekhez és integrációkhoz, amelyek közvetlenül a szerverrel kommunikálnak", + "new": "Új API-kulcs", "name_label": "Kulcs neve", "name_placeholder": "pl. Biztonsági mentés szkript, CI futató", "copy_now_warning": "Másold ki most ezt az API kulcsot - nem lesz újra megjelenítve.", @@ -2008,7 +2011,7 @@ "jmap_username_label": "JMAP felhasználónév", "copy": "Másolás", "copied": "Másolva", - "password_instructions": "Használd a fenti JMAP felhasználóneved egy alkalmazás jelszóval együtt az e-mail kliensbe való bejelentkezéshez. Hozz létre egy alkalmazás jelszót a fenti részben, ha még nem tetted." + "password_instructions": "Használd a fenti JMAP felhasználóneved egy alkalmazás jelszóval együtt az e-mail kliensbe való bejelentkezéshez. Hozz létre egy alkalmazás jelszót a lenti részben, ha még nem tetted." }, "link_device": { "title": "Mobilalkalmazás összekapcsolása", @@ -2050,6 +2053,8 @@ "key_placeholder": "Illessze be ide a nyilvános kulcsát", "none": "Nincs beállítva nyilvános kulcs", "title": "Nyilvános kulcsok", + "add": "Nyilvános kulcs hozzáadása", + "add_submit": "Hozzáadás", "added": "Nyilvános kulcs hozzáadva", "removed": "Nyilvános kulcs törölve", "add_error": "Nem sikerült hozzáadni a nyilvános kulcsot", @@ -2337,10 +2342,10 @@ "export_label": "Névjegyek exportálása", "export_description": "Összes névjegy exportálása vCard (.vcf) fájlként", "manage_title": "Címjegyzékek", - "manage_description": "Címjegyzékeid átnevezése", + "manage_description": "Címjegyzékeid átnevezése vagy törlése", "no_address_books": "Nincs címjegyzék", "categories_title": "Kategóriák", - "categories_description": "Névjegy kategóriák átnevezése", + "categories_description": "Névjegy kategóriák átnevezése vagy törlése", "no_categories": "Nincs kategória" }, "calendar_management": { @@ -2414,7 +2419,7 @@ "validation_error": "A szkript hibákat tartalmaz", "save": "Mentés", "saving": "Mentés...", - "saved": "Szűrők sikeresen elmentve", + "saved": "Szűrők elmentve", "save_failed": "Nem sikerült menteni a szűrőket", "loading": "Szűrők betöltése...", "not_supported": "A levelezőszervered nem támogatja az e-mail szűrőket.", @@ -2482,6 +2487,7 @@ "label_name": "Címke neve", "label_placeholder": "Címke kiválasztása", "header_name": "Fejléc neve", + "condition_value": "Feltétel értéke", "header_placeholder": "pl. X-Mailing-List", "size_bytes": "Méret bájtokban", "size_placeholder": "pl. 1000000", @@ -2509,6 +2515,10 @@ "validation_empty_name": "A szabály neve kötelező", "validation_empty_conditions": "Legalább egy feltétel szükséges értékkel", "validation_empty_actions": "Legalább egy művelet szükséges", + "validation_action_folder": "Válasszon mappát ehhez a művelethez", + "validation_action_tag": "Válasszon címkét ehhez a művelethez", + "validation_action_forward": "Adja meg a továbbítási címet", + "validation_action_reject": "Adja meg az elutasító üzenetet", "validation_header_name": "A fejlécnevek csak az A-Z betűket, számjegyeket, kötőjeleket és aláhúzásjeleket tartalmazhatják", "templates_section": "Kezdés sablonból", "template_newsletters": "Hírlevelek áthelyezése mappába", @@ -2521,12 +2531,12 @@ "script_content": "Sieve szkript", "valid": "A szkript érvényes", "invalid": "A szkript hibákat tartalmaz", - "save_warning": "A mentés felülír minden vizuális szabályt. Ez nem vonható vissza. Kattints újra a Mentés gombra a megerősítéshez.", "validating": "Ellenőrzés...", "validate": "Ellenőrzés", "cancel": "Mégse", "save": "Mentés", - "confirm_save": "Mentés megerősítése", + "overwrite_title": "Lecseréli a vizuális szabályokat?", + "overwrite_message": "Ez a szkript már nem tartalmazza a vizuális szerkesztőben létrehozott szabályokat. Mentéskor felülírja őket, és ez nem vonható vissza.", "validation_failed": "Ellenőrzési kérés sikertelen" }, "rule_summary": { @@ -3258,8 +3268,8 @@ "empty_filtered_hint": "Próbáld módosítani vagy törölni a szűrőket", "clear_search": "Keresés törlése", "import_vcard": "vCard importálása", - "delete_confirm_title": "Névjegy törlése", - "delete_confirm": "Biztosan törölni szeretnéd ezt a névjegyet?", + "delete_named_title": "Törlöd: {name}?", + "delete_details_message": "Az e-mail-címek, telefonszámok, jegyzetek és minden más adat törlődik. Ez nem vonható vissza.", "local_mode": "A névjegyek helyben tárolódnak (a szerver nem támogatja a JMAP Névjegyeket)", "back_to_contacts": "Vissza a névjegyekhez", "back_to_email": "Vissza az e-mailekhez", @@ -3295,6 +3305,7 @@ "share": "Címjegyzék megosztása", "new_contact_in_book": "Új névjegy ebben a címjegyzékben", "delete": "Címjegyzék törlése", + "delete_title": "Törli a címjegyzéket?", "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", @@ -3499,7 +3510,7 @@ "edit": "Csoport szerkesztése", "empty": "Még nincsenek csoportok", "delete_confirm_title": "Csoport törlése", - "delete_confirm": "Biztosan törölni szeretnéd ezt a csoportot?", + "delete_message": "A csoport törlődik. A benne lévő névjegyek megmaradnak.", "name_label": "Csoport neve", "name_placeholder": "pl. Csapat, Család", "name_required": "A csoport neve kötelező", @@ -3541,13 +3552,13 @@ "selected": "{count, plural, one {1 kijelölve} other {# kijelölve}}", "select_all": "Összes kijelölése", "delete": "Törlés", - "delete_confirm_title": "Névjegyek törlése", "delete_confirm": "Törlöd {count, plural, one {az 1 névjegyet} other {a # névjegyet}}?", "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", + "moving_contacts": "{count, plural, one {1 névjegy} other {# névjegy}} áthelyezése", "added_to_group": "Névjegyek hozzáadva a csoporthoz", "export": "Exportálás", "clear": "Kijelölés megszüntetése" @@ -3776,6 +3787,10 @@ "recurrence_scope": { "edit_title": "Ismétlődő esemény szerkesztése", "delete_title": "Ismétlődő esemény törlése", + "delete_question": "Törli az ismétlődő eseményt?", + "delete_this_message": "Csak a(z) \"{title}\" esemény ezen alkalma törlődik. Ez nem vonható vissza.", + "delete_following_message": "A(z) \"{title}\" esemény ezen és minden későbbi alkalma törlődik. Ez nem vonható vissza.", + "delete_all_message": "A(z) \"{title}\" esemény minden alkalma törlődik. Ez nem vonható vissza.", "description": "Ez egy ismétlődő esemény. Mely eseményeket szeretnéd módosítani?", "this_event": "Csak ez az esemény", "this_and_future": "Ez és a következő események", @@ -4011,13 +4026,14 @@ "color": "Szín", "change_color": "Szín módosítása", "random_color": "Véletlenszerű szín", - "add_calendar": "Naptár hozzáadása", + "add_calendar": "Új naptár", "edit": "Szerkesztés", "delete": "Törlés", "save": "Mentés", "create": "Létrehozás", "cancel": "Mégse", "default": "Alapértelmezett", + "row_actions": "{name} – műveletek", "confirm_delete": "Törlöd a \"{name}\" naptárat? Az összes esemény eltávolításra kerül.", "confirm_clear": "Törlöd az összes eseményt a \"{name}\" naptárból? Ez nem vonható vissza.", "set_default": "Beállítás alapértelmezettként", diff --git a/locales/it/common.json b/locales/it/common.json index 33cc42fd2..20172e4fb 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1013,8 +1013,8 @@ "email_tagged": "Email etichettata", "emails_tagged": "{count} email etichettate", "tag_failed": "Etichettatura fallita", - "identity_created": "Identità creata con successo", - "identity_updated": "Identità aggiornata con successo", + "identity_created": "Identità creata", + "identity_updated": "Identità aggiornata", "identity_deleted": "Identità eliminata", "identity_set_primary": "Identità principale aggiornata", "identity_create_failed": "Impossibile creare l'identità: {error}", @@ -1024,10 +1024,10 @@ "identity_not_found": "Identità non trovata", "vacation_saved": "Impostazioni di assenza salvate", "vacation_save_failed": "Impossibile salvare le impostazioni di assenza", - "filters_saved": "Filtri salvati con successo", + "filters_saved": "Filtri salvati", "filters_save_failed": "Impossibile salvare i filtri", "filters_deleted": "Regola di filtraggio eliminata", - "templates_exported": "Modelli esportati con successo", + "templates_exported": "Modelli esportati", "templates_imported": "{count, plural, one {# modello importato} other {# modelli importati}}", "templates_import_errors": "Alcuni modelli non sono stati importati", "templates_import_empty": "Nessun modello trovato nel file", @@ -1120,9 +1120,9 @@ "import_success": "Impostazioni importate con successo", "import_error": "Impossibile importare le impostazioni", "reset_confirm": "Sei sicuro di voler ripristinare tutte le impostazioni ai valori predefiniti?", - "unsaved_changes": "Hai modifiche non salvate", + "unsaved_changes": "Le modifiche non sono state salvate.", "discard_changes": "Annullare le modifiche?", - "discard": "Annulla", + "discard": "Annulla modifiche", "keep_editing": "Continua a modificare", "tabs": { "appearance": "Aspetto", @@ -1352,6 +1352,8 @@ "id_exists": "Questo ID etichetta esiste già", "edit": "Modifica etichetta", "delete": "Elimina etichetta", + "edit_tag": "Modifica {name}", + "delete_tag": "Elimina {name}", "deleted": "Etichetta eliminata", "save": "Salva", "add": "Aggiungi", @@ -1941,7 +1943,7 @@ "app_passwords": { "title": "Password per le app", "description": "Crea password per le app che non supportano l'autenticazione a due fattori", - "add": "Aggiungi", + "new": "Nuova password per le app", "create": "Crea", "cancel": "Annulla", "generate": "Genera", @@ -1966,6 +1968,7 @@ "api_keys": { "title": "Chiavi API", "description": "Crea chiavi API per script e integrazioni che comunicano direttamente con il server", + "new": "Nuova chiave API", "name_label": "Nome chiave", "name_placeholder": "es. Script di backup, CI runner", "copy_now_warning": "Copia subito questa chiave API - non verrà mostrata di nuovo.", @@ -2005,7 +2008,7 @@ "jmap_username_label": "Nome utente JMAP", "copy": "Copia", "copied": "Copiato", - "password_instructions": "Usa il tuo nome utente JMAP sopra indicato insieme a una password dell'app per accedere al tuo client e-mail. Crea una password dell'app nella sezione sopra se non l'hai ancora fatto." + "password_instructions": "Usa il tuo nome utente JMAP sopra indicato insieme a una password dell'app per accedere al tuo client e-mail. Crea una password dell'app nella sezione sotto se non l'hai ancora fatto." }, "link_device": { "title": "Collega l'app mobile", @@ -2047,6 +2050,8 @@ "key_placeholder": "Incolla qui la tua chiave pubblica", "none": "Nessuna chiave pubblica configurata", "title": "Chiavi pubbliche", + "add": "Aggiungi chiave pubblica", + "add_submit": "Aggiungi", "added": "Chiave pubblica aggiunta", "removed": "Chiave pubblica eliminata", "add_error": "Impossibile aggiungere la chiave pubblica", @@ -2330,10 +2335,10 @@ "export_label": "Esporta contatti", "export_description": "Esporta tutti i contatti come file vCard (.vcf)", "manage_title": "Rubriche", - "manage_description": "Rinomina le tue rubriche", + "manage_description": "Rinomina o elimina le tue rubriche", "no_address_books": "Nessuna rubrica trovata", "categories_title": "Categorie", - "categories_description": "Rinomina le categorie dei contatti", + "categories_description": "Rinomina o elimina le categorie dei contatti", "no_categories": "Nessuna categoria trovata", "group_by_letter_description": "Mostra intestazioni alfabetiche nell'elenco dei contatti", "group_by_letter_label": "Raggruppa per prima lettera", @@ -2411,7 +2416,7 @@ "validation_error": "Lo script contiene errori", "save": "Salva", "saving": "Salvataggio...", - "saved": "Filtri salvati con successo", + "saved": "Filtri salvati", "save_failed": "Impossibile salvare i filtri", "loading": "Caricamento filtri...", "not_supported": "Il tuo server di posta non supporta i filtri email.", @@ -2479,6 +2484,7 @@ "label_name": "Nome dell'etichetta", "label_placeholder": "Seleziona etichetta", "header_name": "Nome dell'intestazione", + "condition_value": "Valore della condizione", "header_placeholder": "es. X-Mailing-List", "size_bytes": "Dimensione in byte", "size_placeholder": "es. 1000000", @@ -2506,6 +2512,10 @@ "validation_empty_name": "Il nome della regola è obbligatorio", "validation_empty_conditions": "È necessaria almeno una condizione con un valore", "validation_empty_actions": "È necessaria almeno un'azione", + "validation_action_folder": "Seleziona una cartella per questa azione", + "validation_action_tag": "Seleziona un'etichetta per questa azione", + "validation_action_forward": "Inserisci un indirizzo a cui inoltrare", + "validation_action_reject": "Inserisci il messaggio di rifiuto", "validation_header_name": "I nomi delle intestazioni possono contenere solo le lettere A-Z, cifre, trattini e trattini bassi", "templates_section": "Parti da un modello", "template_newsletters": "Sposta newsletter in cartella", @@ -2518,12 +2528,12 @@ "script_content": "Script Sieve", "valid": "Lo script è valido", "invalid": "Lo script contiene errori", - "save_warning": "Il salvataggio sovrascriverà tutte le regole visuali. Questa azione non può essere annullata. Fai clic su Salva di nuovo per confermare.", "validating": "Validazione...", "validate": "Valida", "cancel": "Annulla", "save": "Salva", - "confirm_save": "Conferma salvataggio", + "overwrite_title": "Sostituire le regole visive?", + "overwrite_message": "Questo script non contiene più le regole create nell'editor visivo. Salvandolo le sostituisci, e non si può annullare.", "validation_failed": "Richiesta di validazione non riuscita" }, "rule_summary": { @@ -3258,8 +3268,8 @@ "empty_filtered_hint": "Modifica o cancella i filtri", "clear_search": "Cancella ricerca", "import_vcard": "Importa vCard", - "delete_confirm_title": "Elimina contatto", - "delete_confirm": "Sei sicuro di voler eliminare questo contatto?", + "delete_named_title": "Eliminare {name}?", + "delete_details_message": "Indirizzi email, numeri di telefono, note e tutti gli altri dati verranno eliminati. L'operazione non può essere annullata.", "local_mode": "I contatti sono salvati localmente (il server non supporta JMAP Contacts)", "back_to_contacts": "Torna ai contatti", "back_to_email": "Torna all'e-mail", @@ -3293,6 +3303,7 @@ "share": "Condividi rubrica", "new_contact_in_book": "Nuovo contatto in questa rubrica", "delete": "Elimina rubrica", + "delete_title": "Eliminare la rubrica?", "confirm_delete": "Eliminare \"{name}\"? Tutti i contatti in questa rubrica verranno rimossi.", "deleted": "Rubrica eliminata", "delete_failed": "Impossibile eliminare la rubrica", @@ -3469,9 +3480,9 @@ "add_phone": "Aggiungi telefono", "save": "Salva", "cancel": "Annulla", - "discard_title": "Eliminare le modifiche?", + "discard_title": "Annullare le modifiche?", "discard_message": "Le modifiche non sono state salvate.", - "discard": "Elimina", + "discard": "Annulla modifiche", "keep_editing": "Continua a modificare", "remove_entry": "Rimuovi", "close": "Chiudi", @@ -3497,7 +3508,7 @@ "edit": "Modifica gruppo", "empty": "Nessun gruppo", "delete_confirm_title": "Elimina gruppo", - "delete_confirm": "Sei sicuro di voler eliminare questo gruppo?", + "delete_message": "Il gruppo verrà eliminato. I contatti che contiene vengono mantenuti.", "name_label": "Nome del gruppo", "name_placeholder": "es. Team, Famiglia", "name_required": "Il nome del gruppo è obbligatorio", @@ -3539,13 +3550,13 @@ "selected": "{count, plural, one {1 selezionato} other {# selezionati}}", "select_all": "Seleziona tutto", "delete": "Elimina", - "delete_confirm_title": "Elimina contatti", "delete_confirm": "Eliminare {count, plural, one {1 contatto} other {# contatti}}?", "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}}", + "moving_contacts": "Spostamento di {count, plural, one {1 contatto} other {# contatti}}", "added_to_group": "Contatti aggiunti al gruppo", "export": "Esporta", "clear": "Cancella selezione" @@ -3776,6 +3787,10 @@ "recurrence_scope": { "edit_title": "Modifica evento ricorrente", "delete_title": "Elimina evento ricorrente", + "delete_question": "Eliminare l'evento ricorrente?", + "delete_this_message": "Verrà eliminata solo questa occorrenza di \"{title}\". L'operazione non può essere annullata.", + "delete_following_message": "Verranno eliminate questa occorrenza e tutte le successive di \"{title}\". L'operazione non può essere annullata.", + "delete_all_message": "Verranno eliminate tutte le occorrenze di \"{title}\". L'operazione non può essere annullata.", "description": "Questo è un evento ricorrente. Quali eventi desideri modificare?", "this_event": "Solo questo evento", "this_and_future": "Questo evento e i seguenti", @@ -3982,13 +3997,14 @@ "color": "Colore", "change_color": "Cambia colore", "random_color": "Nuovo colore casuale", - "add_calendar": "Aggiungi calendario", + "add_calendar": "Nuovo calendario", "edit": "Modifica", "delete": "Elimina", "save": "Salva", "create": "Crea", "cancel": "Annulla", "default": "Predefinito", + "row_actions": "Azioni per {name}", "confirm_delete": "Eliminare \"{name}\"? Tutti gli eventi in questo calendario verranno rimossi.", "calendar_created": "Calendario creato", "calendar_updated": "Calendario aggiornato", diff --git a/locales/ja/common.json b/locales/ja/common.json index 42c50b0b6..06b365826 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1120,7 +1120,7 @@ "import_success": "設定をインポートしました", "import_error": "設定のインポートに失敗しました", "reset_confirm": "すべての設定をデフォルトにリセットしてもよろしいですか?", - "unsaved_changes": "保存されていない変更があります", + "unsaved_changes": "変更はまだ保存されていません。", "discard_changes": "変更を破棄しますか?", "discard": "破棄", "keep_editing": "編集を続ける", @@ -1352,6 +1352,8 @@ "id_exists": "このラベルIDは既に存在します", "edit": "ラベルを編集", "delete": "ラベルを削除", + "edit_tag": "{name} を編集", + "delete_tag": "{name} を削除", "deleted": "ラベルを削除しました", "save": "保存", "add": "追加", @@ -1941,7 +1943,7 @@ "app_passwords": { "title": "アプリパスワード", "description": "二要素認証に対応していないアプリ用のパスワードを作成", - "add": "追加", + "new": "新しいアプリパスワード", "create": "作成", "cancel": "キャンセル", "generate": "生成", @@ -1966,6 +1968,7 @@ "api_keys": { "title": "APIキー", "description": "サーバーと直接通信するスクリプトや連携用にAPIキーを作成します", + "new": "新しいAPIキー", "name_label": "キー名", "name_placeholder": "例: バックアップスクリプト、CIランナー", "copy_now_warning": "このAPIキーを今すぐコピーしてください - 二度と表示されません。", @@ -2005,7 +2008,7 @@ "jmap_username_label": "JMAPユーザー名", "copy": "コピー", "copied": "コピー済み", - "password_instructions": "上記のJMAPユーザー名とアプリパスワードを使用してメールクライアントにサインインします。まだ作成していない場合は、上のセクションでアプリパスワードを作成してください。" + "password_instructions": "上記のJMAPユーザー名とアプリパスワードを使用してメールクライアントにサインインします。まだ作成していない場合は、下のセクションでアプリパスワードを作成してください。" }, "link_device": { "title": "モバイルアプリを連携", @@ -2047,6 +2050,8 @@ "key_placeholder": "ここに公開鍵を貼り付けてください", "none": "公開鍵が設定されていません", "title": "公開鍵", + "add": "公開鍵を追加", + "add_submit": "追加", "added": "公開鍵を追加しました", "removed": "公開鍵を削除しました", "add_error": "公開鍵を追加できませんでした", @@ -2330,10 +2335,10 @@ "export_label": "連絡先をエクスポート", "export_description": "すべての連絡先をvCardファイル(.vcf)としてエクスポート", "manage_title": "アドレス帳", - "manage_description": "アドレス帳の名前を変更", + "manage_description": "アドレス帳の名前を変更または削除", "no_address_books": "アドレス帳が見つかりません", "categories_title": "カテゴリ", - "categories_description": "連絡先カテゴリの名前を変更", + "categories_description": "連絡先カテゴリの名前を変更または削除", "no_categories": "カテゴリが見つかりません", "group_by_letter_description": "連絡先リストにアルファベット順のセクション見出しを表示", "group_by_letter_label": "頭文字でグループ化", @@ -2479,6 +2484,7 @@ "label_name": "ラベル名", "label_placeholder": "ラベルを選択", "header_name": "ヘッダー名", + "condition_value": "条件の値", "header_placeholder": "例:X-Mailing-List", "size_bytes": "サイズ(バイト)", "size_placeholder": "例:1000000", @@ -2506,6 +2512,10 @@ "validation_empty_name": "ルール名は必須です", "validation_empty_conditions": "値を持つ条件が少なくとも1つ必要です", "validation_empty_actions": "少なくとも1つのアクションが必要です", + "validation_action_folder": "この操作のフォルダーを選択してください", + "validation_action_tag": "この操作のタグを選択してください", + "validation_action_forward": "転送先のアドレスを入力してください", + "validation_action_reject": "拒否メッセージを入力してください", "validation_header_name": "ヘッダー名に使用できるのは英字(A-Z)、数字、ハイフン、アンダースコアのみです", "templates_section": "テンプレートから作成", "template_newsletters": "ニュースレターをフォルダに移動", @@ -2518,12 +2528,12 @@ "script_content": "Sieveスクリプト", "valid": "スクリプトは有効です", "invalid": "スクリプトにエラーがあります", - "save_warning": "保存するとすべてのビジュアルルールが上書きされます。この操作は元に戻せません。もう一度「保存」をクリックして確認してください。", "validating": "検証中...", "validate": "検証", "cancel": "キャンセル", "save": "保存", - "confirm_save": "保存を確認", + "overwrite_title": "ビジュアルルールを置き換えますか?", + "overwrite_message": "このスクリプトには、ビジュアルエディターで作成したルールが含まれていません。保存するとそれらが置き換えられ、元に戻せません。", "validation_failed": "検証リクエストに失敗しました" }, "rule_summary": { @@ -3258,8 +3268,8 @@ "empty_filtered_hint": "フィルターを調整またはクリアしてください", "clear_search": "検索をクリア", "import_vcard": "vCardをインポート", - "delete_confirm_title": "連絡先を削除", - "delete_confirm": "この連絡先を削除してもよろしいですか?", + "delete_named_title": "「{name}」を削除しますか?", + "delete_details_message": "メールアドレス、電話番号、メモなど、すべての情報が削除されます。この操作は元に戻せません。", "local_mode": "連絡先はローカルに保存されています(サーバーがJMAPコンタクトをサポートしていません)", "back_to_contacts": "連絡先に戻る", "back_to_email": "メールに戻る", @@ -3293,6 +3303,7 @@ "share": "アドレス帳を共有", "new_contact_in_book": "このアドレス帳に新規連絡先", "delete": "アドレス帳を削除", + "delete_title": "アドレス帳を削除しますか?", "confirm_delete": "「{name}」を削除しますか?このアドレス帳のすべての連絡先が削除されます。", "deleted": "アドレス帳を削除しました", "delete_failed": "アドレス帳の削除に失敗しました", @@ -3470,7 +3481,7 @@ "save": "保存", "cancel": "キャンセル", "discard_title": "変更を破棄しますか?", - "discard_message": "変更は保存されていません。", + "discard_message": "変更はまだ保存されていません。", "discard": "破棄", "keep_editing": "編集を続ける", "remove_entry": "削除", @@ -3497,7 +3508,7 @@ "edit": "グループを編集", "empty": "グループがありません", "delete_confirm_title": "グループを削除", - "delete_confirm": "このグループを削除してもよろしいですか?", + "delete_message": "グループは削除されます。グループ内の連絡先は残ります。", "name_label": "グループ名", "name_placeholder": "例:チーム、家族", "name_required": "グループ名は必須です", @@ -3539,13 +3550,13 @@ "selected": "{count, plural, other {#件選択中}}", "select_all": "すべて選択", "delete": "削除", - "delete_confirm_title": "連絡先を削除", "delete_confirm": "{count, plural, other {#件の連絡先}}を削除しますか?", "deleted": "{count, plural, other {#件の連絡先を削除しました}}", "add_to_group": "グループに追加", "choose_group": "グループを選択", "new_group": "新しいグループ…", "adding_contacts": "{count, plural, other {#件の連絡先}}を追加", + "moving_contacts": "{count, plural, other {#件の連絡先}}を移動", "added_to_group": "連絡先をグループに追加しました", "export": "エクスポート", "clear": "選択を解除" @@ -3776,6 +3787,10 @@ "recurrence_scope": { "edit_title": "繰り返しイベントを編集", "delete_title": "繰り返しイベントを削除", + "delete_question": "繰り返しの予定を削除しますか?", + "delete_this_message": "「{title}」のこの回だけを削除します。元に戻すことはできません。", + "delete_following_message": "「{title}」のこの回以降をすべて削除します。元に戻すことはできません。", + "delete_all_message": "「{title}」のすべての回を削除します。元に戻すことはできません。", "description": "これは繰り返しイベントです。どのイベントを変更しますか?", "this_event": "このイベントのみ", "this_and_future": "これ以降のすべてのイベント", @@ -3982,13 +3997,14 @@ "color": "色", "change_color": "色を変更", "random_color": "新しいランダムな色", - "add_calendar": "カレンダーを追加", + "add_calendar": "新しいカレンダー", "edit": "編集", "delete": "削除", "save": "保存", "create": "作成", "cancel": "キャンセル", "default": "デフォルト", + "row_actions": "{name} の操作", "confirm_delete": "\"{name}\"を削除しますか?このカレンダーのすべてのイベントが削除されます。", "calendar_created": "カレンダーを作成しました", "calendar_updated": "カレンダーを更新しました", diff --git a/locales/ko/common.json b/locales/ko/common.json index 1ccbc9264..19dc6a8e4 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1027,7 +1027,7 @@ "filters_saved": "필터가 저장되었어요", "filters_save_failed": "필터 저장에 실패했어요", "filters_deleted": "필터 규칙이 삭제되었어요", - "templates_exported": "템플릿을 성공적으로 내보냈어요", + "templates_exported": "템플릿을 내보냈어요", "templates_imported": "{count, plural, other {#개의 템플릿}}을 가져왔어요", "templates_import_errors": "일부 템플릿을 가져오지 못했어요", "templates_import_empty": "파일에서 템플릿을 찾을 수 없어요", @@ -1120,9 +1120,9 @@ "import_success": "설정을 성공적으로 가져왔어요", "import_error": "설정 가져오기에 실패했어요", "reset_confirm": "모든 설정을 기본값으로 되돌릴까요?", - "unsaved_changes": "저장되지 않은 변경 사항이 있어요", - "discard_changes": "변경 사항을 취소할까요?", - "discard": "취소하기", + "unsaved_changes": "변경 사항이 아직 저장되지 않았습니다.", + "discard_changes": "변경 사항을 버릴까요?", + "discard": "버리기", "keep_editing": "계속 수정하기", "tabs": { "appearance": "화면 설정", @@ -1352,6 +1352,8 @@ "id_exists": "이 태그 ID는 이미 존재합니다", "edit": "태그 편집", "delete": "태그 삭제", + "edit_tag": "{name} 편집", + "delete_tag": "{name} 삭제", "deleted": "태그가 삭제되었어요", "save": "저장", "add": "추가", @@ -1941,7 +1943,7 @@ "app_passwords": { "title": "앱 비밀번호", "description": "2단계 인증을 지원하지 않는 앱을 위한 전용 비밀번호를 만드세요", - "add": "추가", + "new": "새 앱 비밀번호", "create": "만들기", "cancel": "취소", "generate": "생성", @@ -1966,6 +1968,7 @@ "api_keys": { "title": "API 키", "description": "서버와 직접 통신하는 스크립트 및 통합용 API 키를 만듭니다", + "new": "새 API 키", "name_label": "키 이름", "name_placeholder": "예: 백업 스크립트, CI 러너", "copy_now_warning": "이 API 키를 지금 복사하세요 - 다시 표시되지 않습니다.", @@ -2005,7 +2008,7 @@ "jmap_username_label": "JMAP 사용자 이름", "copy": "복사", "copied": "복사되었어요", - "password_instructions": "위의 JMAP 사용자 이름과 앱 비밀번호를 사용해서 이메일 클라이언트에 로그인하세요. 아직 만들지 않았다면 위에서 앱 비밀번호를 먼저 만들어 주세요." + "password_instructions": "위의 JMAP 사용자 이름과 앱 비밀번호를 사용해서 이메일 클라이언트에 로그인하세요. 아직 만들지 않았다면 아래에서 앱 비밀번호를 먼저 만들어 주세요." }, "link_device": { "title": "모바일 앱 연결", @@ -2047,6 +2050,8 @@ "key_placeholder": "여기에 공개 키를 붙여넣으세요", "none": "설정된 공개 키가 없어요", "title": "공개 키", + "add": "공개 키 추가", + "add_submit": "추가", "added": "공개 키를 추가했어요", "removed": "공개 키를 삭제했어요", "add_error": "공개 키를 추가하지 못했어요", @@ -2330,10 +2335,10 @@ "export_label": "연락처 내보내기", "export_description": "모든 연락처를 vCard(.vcf) 파일로 저장해요", "manage_title": "주소록", - "manage_description": "주소록 이름 변경", + "manage_description": "주소록 이름 변경 또는 삭제", "no_address_books": "주소록을 찾을 수 없음", "categories_title": "카테고리", - "categories_description": "연락처 카테고리 이름 변경", + "categories_description": "연락처 카테고리 이름 변경 또는 삭제", "no_categories": "카테고리를 찾을 수 없음", "group_by_letter_description": "연락처 목록에 알파벳순 섹션 헤더 표시", "group_by_letter_label": "첫 글자로 그룹화", @@ -2411,7 +2416,7 @@ "validation_error": "스크립트에 오류가 있어요", "save": "저장", "saving": "저장 중...", - "saved": "필터가 성공적으로 저장되었어요", + "saved": "필터가 저장되었어요", "save_failed": "필터를 저장하지 못했어요", "loading": "필터를 불러오는 중...", "not_supported": "사용 중인 메일 서버는 이메일 필터를 지원하지 않아요.", @@ -2479,6 +2484,7 @@ "label_name": "태그 이름", "label_placeholder": "태그 선택", "header_name": "헤더 이름", + "condition_value": "조건 값", "header_placeholder": "예: X-Mailing-List", "size_bytes": "크기 (바이트)", "size_placeholder": "예: 1000000", @@ -2506,6 +2512,10 @@ "validation_empty_name": "규칙 이름을 입력해 주세요", "validation_empty_conditions": "값을 포함한 조건이 하나 이상 필요해요", "validation_empty_actions": "동작이 하나 이상 필요해요", + "validation_action_folder": "이 작업에 사용할 폴더를 선택하세요", + "validation_action_tag": "이 작업에 사용할 태그를 선택하세요", + "validation_action_forward": "전달할 주소를 입력하세요", + "validation_action_reject": "거부 메시지를 입력하세요", "validation_header_name": "헤더 이름에는 영문자(A-Z), 숫자, 하이픈, 밑줄만 쓸 수 있어요", "templates_section": "템플릿으로 시작하기", "template_newsletters": "뉴스레터를 특정 폴더로 이동", @@ -2518,12 +2528,12 @@ "script_content": "Sieve 스크립트", "valid": "유효한 스크립트예요", "invalid": "스크립트에 오류가 있어요", - "save_warning": "저장하면 시각적 규칙이 덮어쓰기 되며, 이 작업은 되돌릴 수 없어요. 확인을 위해 한 번 더 저장을 눌러주세요.", "validating": "검증 중...", "validate": "검증", "cancel": "취소", "save": "저장", - "confirm_save": "저장 확인", + "overwrite_title": "시각적 규칙을 바꿀까요?", + "overwrite_message": "이 스크립트에는 시각적 편집기에서 만든 규칙이 더 이상 없어요. 저장하면 그 규칙이 바뀌며 되돌릴 수 없어요.", "validation_failed": "검증 요청에 실패했어요" }, "rule_summary": { @@ -3258,8 +3268,8 @@ "empty_filtered_hint": "필터를 조정하거나 지워보세요", "clear_search": "검색 지우기", "import_vcard": "vCard 가져오기", - "delete_confirm_title": "연락처 삭제", - "delete_confirm": "정말 이 연락처를 삭제할까요?", + "delete_named_title": "{name}을(를) 삭제할까요?", + "delete_details_message": "이메일 주소, 전화번호, 메모 등 모든 정보가 삭제됩니다. 되돌릴 수 없습니다.", "local_mode": "연락처가 로컬에 저장돼요 (서버가 JMAP Contacts를 지원하지 않아요)", "back_to_contacts": "연락처로 돌아가기", "back_to_email": "이메일로 돌아가기", @@ -3293,6 +3303,7 @@ "share": "주소록 공유", "new_contact_in_book": "이 주소록에 새 연락처", "delete": "주소록 삭제", + "delete_title": "주소록을 삭제할까요?", "confirm_delete": "\"{name}\"을(를) 삭제하시겠습니까? 이 주소록의 모든 연락처가 삭제됩니다.", "deleted": "주소록이 삭제되었습니다", "delete_failed": "주소록 삭제에 실패했습니다", @@ -3469,10 +3480,10 @@ "add_phone": "전화번호 추가", "save": "저장", "cancel": "취소", - "discard_title": "변경 사항을 삭제할까요?", - "discard_message": "변경 사항이 저장되지 않았습니다.", - "discard": "삭제", - "keep_editing": "계속 편집", + "discard_title": "변경 사항을 버릴까요?", + "discard_message": "변경 사항이 아직 저장되지 않았습니다.", + "discard": "버리기", + "keep_editing": "계속 수정하기", "remove_entry": "제거", "close": "닫기", "remove_category": "{name} 제거", @@ -3497,7 +3508,7 @@ "edit": "그룹 수정", "empty": "아직 그룹이 없어요", "delete_confirm_title": "그룹 삭제", - "delete_confirm": "정말 이 그룹을 삭제할까요?", + "delete_message": "그룹이 삭제됩니다. 그룹에 있던 연락처는 유지됩니다.", "name_label": "그룹 이름", "name_placeholder": "예: 팀, 가족", "name_required": "그룹 이름이 필요해요", @@ -3539,13 +3550,13 @@ "selected": "{count}개 선택됨", "select_all": "모두 선택", "delete": "삭제", - "delete_confirm_title": "연락처 삭제", "delete_confirm": "선택한 {count}개의 연락처를 삭제할까요?", "deleted": "{count}개의 연락처가 삭제되었어요", "add_to_group": "그룹에 추가", "choose_group": "그룹 선택", "new_group": "새 그룹…", "adding_contacts": "{count}개의 연락처를 추가하는 중...", + "moving_contacts": "연락처 {count}개 이동", "added_to_group": "연락처가 그룹에 추가되었어요", "export": "내보내기", "clear": "선택 취소" @@ -3776,6 +3787,10 @@ "recurrence_scope": { "edit_title": "반복 일정 수정", "delete_title": "반복 일정 삭제", + "delete_question": "반복 일정을 삭제할까요?", + "delete_this_message": "\"{title}\"의 이번 일정만 삭제됩니다. 되돌릴 수 없습니다.", + "delete_following_message": "\"{title}\"의 이번 일정과 이후 모든 일정이 삭제됩니다. 되돌릴 수 없습니다.", + "delete_all_message": "\"{title}\"의 모든 일정이 삭제됩니다. 되돌릴 수 없습니다.", "description": "반복되는 일정이에요. 어떤 일정을 수정할까요?", "this_event": "이 일정만", "this_and_future": "이 일정 및 향후 일정 모두", @@ -3982,13 +3997,14 @@ "color": "색상", "change_color": "색상 변경", "random_color": "새 무작위 색상", - "add_calendar": "캘린더 추가", + "add_calendar": "새 캘린더", "edit": "수정", "delete": "삭제", "save": "저장", "create": "만들기", "cancel": "취소", "default": "기본", + "row_actions": "{name} 작업", "confirm_delete": "\"{name}\" 캘린더를 삭제할까요? 캘린더 안의 모든 일정이 함께 지워져요.", "confirm_clear": "\"{name}\" 캘린더의 모든 일정을 지울까요? 이 작업은 되돌릴 수 없어요.", "set_default": "기본으로 설정", diff --git a/locales/lv/common.json b/locales/lv/common.json index ea7667558..2c6256504 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1013,8 +1013,8 @@ "email_tagged": "Vēstulei pievienots tags", "emails_tagged": "{count} vēstulēm pievienots tags", "tag_failed": "Tagu pievienošanas kļūda", - "identity_created": "Identitāte veiksmīgi izveidota", - "identity_updated": "Identitāte veiksmīgi atjaunināta", + "identity_created": "Identitāte izveidota", + "identity_updated": "Identitāte atjaunināta", "identity_deleted": "Identitāte izdzēsta", "identity_set_primary": "Galvenā identitāte atjaunināta", "identity_create_failed": "Neizdevās izveidot identitāti: {error}", @@ -1024,10 +1024,10 @@ "identity_not_found": "Identitāte netika atrasta", "vacation_saved": "Prombūtnes iestatījumi saglabāti", "vacation_save_failed": "Neizdevās saglabāt prombūtnes iestatījumus", - "filters_saved": "Filtri veiksmīgi saglabāti", + "filters_saved": "Filtri saglabāti", "filters_save_failed": "Neizdevās saglabāt filtrus", "filters_deleted": "Filtra nosacījums izdzēsts", - "templates_exported": "Veidnes veiksmīgi eksportētas", + "templates_exported": "Veidnes eksportētas", "templates_imported": "Importētas {count, plural, one {# veidne} other {# veidnes}}", "templates_import_errors": "Dažas veidnes neizdevās importēt", "templates_import_empty": "Failā netika atrastas veidnes", @@ -1120,8 +1120,8 @@ "import_success": "Iestatījumi veiksmīgi importēti", "import_error": "Neizdevās importēt iestatījumus", "reset_confirm": "Vai tiešām vēlaties atiestatīt visus iestatījumus uz noklusējuma vērtībām?", - "unsaved_changes": "Jums ir nesaglabātas izmaiņas", - "discard_changes": "Atmest nesaglabātās izmaiņas?", + "unsaved_changes": "Jūsu izmaiņas nav saglabātas.", + "discard_changes": "Atmest izmaiņas?", "discard": "Atmest", "keep_editing": "Turpināt rediģēšanu", "tabs": { @@ -1352,6 +1352,8 @@ "id_exists": "Šāds taga identifikators jau pastāv", "edit": "Rediģēt tagu", "delete": "Dzēst tagu", + "edit_tag": "Rediģēt {name}", + "delete_tag": "Dzēst {name}", "deleted": "Tags izdzēsts", "save": "Saglabāt", "add": "Pievienot", @@ -1941,7 +1943,7 @@ "app_passwords": { "title": "Lietotņu paroles", "description": "Izveidojiet paroles lietotnēm, kas neatbalsta 2FA", - "add": "Pievienot", + "new": "Jauna lietotnes parole", "create": "Izveidot", "cancel": "Atcelt", "generate": "Ģenerēt", @@ -1966,6 +1968,7 @@ "api_keys": { "title": "API atslēgas", "description": "Izveidojiet API atslēgas skriptiem un integrācijām, kas tieši sazinās ar serveri", + "new": "Jauna API atslēga", "name_label": "Atslēgas nosaukums", "name_placeholder": "piem. Dublēšanas skripts, CI palaidējs", "copy_now_warning": "Kopējiet šo API atslēgu tagad - tā vairs netiks parādīta.", @@ -2005,7 +2008,7 @@ "jmap_username_label": "JMAP lietotājvārds", "copy": "Kopēt", "copied": "Nokopēts", - "password_instructions": "Izmantojiet norādīto JMAP lietotājvārdu kopā ar lietotnes paroli. Izveidojiet lietotnes paroli sadaļā augstāk." + "password_instructions": "Izmantojiet augstāk norādīto JMAP lietotājvārdu kopā ar lietotnes paroli, lai pierakstītos e-pasta klientā. Ja vēl neesat to izdarījis, izveidojiet lietotnes paroli sadaļā zemāk." }, "link_device": { "title": "Saistīt mobilo lietotni", @@ -2047,6 +2050,8 @@ "key_placeholder": "Ielīmējiet šeit savu publisko atslēgu", "none": "Nav konfigurēta neviena publiskā atslēga", "title": "Publiskās atslēgas", + "add": "Pievienot publisko atslēgu", + "add_submit": "Pievienot", "added": "Publiskā atslēga pievienota", "removed": "Publiskā atslēga izdzēsta", "add_error": "Neizdevās pievienot publisko atslēgu", @@ -2330,10 +2335,10 @@ "export_label": "Kontaktu eksports", "export_description": "Eksportēt visus kontaktus vCard failā (.vcf)", "manage_title": "Adrešu grāmatas", - "manage_description": "Pārdēvēt savas adrešu grāmatas", + "manage_description": "Pārdēvēt vai dzēst savas adrešu grāmatas", "no_address_books": "Adrešu grāmatas nav atrastas", "categories_title": "Kategorijas", - "categories_description": "Pārdēvēt kontaktu kategorijas", + "categories_description": "Pārdēvēt vai dzēst kontaktu kategorijas", "no_categories": "Kategorijas nav atrastas", "group_by_letter_description": "Rādīt alfabētiskos sadaļu virsrakstus kontaktu sarakstā", "group_by_letter_label": "Grupēt pēc pirmā burta", @@ -2411,7 +2416,7 @@ "validation_error": "Skriptā ir kļūdas", "save": "Saglabāt", "saving": "Saglabā...", - "saved": "Filtri veiksmīgi saglabāti", + "saved": "Filtri saglabāti", "save_failed": "Neizdevās saglabāt filtrus", "loading": "Ielādē filtrus...", "not_supported": "Jūsu pasta serveris neatbalsta filtrus.", @@ -2479,6 +2484,7 @@ "label_name": "Taga nosaukums", "label_placeholder": "Izvēlēties tagu", "header_name": "Galvenes nosaukums", + "condition_value": "Nosacījuma vērtība", "header_placeholder": "piem., X-Mailing-List", "size_bytes": "Izmērs baitos", "size_placeholder": "piem., 1000000", @@ -2506,6 +2512,10 @@ "validation_empty_name": "Nepieciešams noteikuma nosaukums", "validation_empty_conditions": "Nepieciešams vismaz viens nosacījums ar vērtību", "validation_empty_actions": "Nepieciešama vismaz viena darbība", + "validation_action_folder": "Atlasiet mapi šai darbībai", + "validation_action_tag": "Atlasiet birku šai darbībai", + "validation_action_forward": "Ievadiet adresi, uz kuru pārsūtīt", + "validation_action_reject": "Ievadiet noraidīšanas ziņojumu", "validation_header_name": "Galvenes nosaukumā drīkst būt tikai burti A-Z, cipari, defises un pasvītras", "templates_section": "Sākt no veidnes", "template_newsletters": "Pārvietot ziņu izdevumus uz mapi", @@ -2518,12 +2528,12 @@ "script_content": "Sieve skripts", "valid": "Skripts ir derīgs", "invalid": "Skriptā ir kļūdas", - "save_warning": "Saglabāšana pārrakstīs vizuālos noteikumus. To nevar atcelt. Nospiediet \"Saglabāt\" vēlreiz.", "validating": "Pārbauda...", "validate": "Pārbaudīt", "cancel": "Atcelt", "save": "Saglabāt", - "confirm_save": "Apstiprināt saglabāšanu", + "overwrite_title": "Aizstāt vizuālās kārtulas?", + "overwrite_message": "Šajā skriptā vairs nav vizuālajā redaktorā izveidoto kārtulu. Saglabājot tās tiks aizstātas, un to nevar atsaukt.", "validation_failed": "Pārbaudes pieprasījuma kļūda" }, "rule_summary": { @@ -3250,8 +3260,8 @@ "empty_filtered_hint": "Pielāgojiet vai notīriet filtrus", "clear_search": "Notīrīt meklēšanu", "import_vcard": "vCard imports", - "delete_confirm_title": "Dzēst kontaktu", - "delete_confirm": "Vai tiešām vēlaties dzēst šo kontaktu?", + "delete_named_title": "Vai dzēst {name}?", + "delete_details_message": "Tiks dzēstas e-pasta adreses, tālruņa numuri, piezīmes un visa pārējā informācija. To nevar atsaukt.", "local_mode": "Kontakti tiek glabāti lokāli (serveris neatbalsta JMAP Contacts)", "back_to_contacts": "Atpakaļ pie kontaktiem", "back_to_email": "Atpakaļ pie e-pasta", @@ -3285,6 +3295,7 @@ "share": "Kopīgot adrešu grāmatu", "new_contact_in_book": "Jauns kontakts šajā adrešu grāmatā", "delete": "Dzēst adrešu grāmatu", + "delete_title": "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", @@ -3489,7 +3500,7 @@ "edit": "Rediģēt grupu", "empty": "Grupu vēl nav", "delete_confirm_title": "Dzēst grupu", - "delete_confirm": "Vai tiešām vēlaties dzēst šo grupu?", + "delete_message": "Grupa tiks dzēsta. Tajā esošie kontakti tiks saglabāti.", "name_label": "Grupas nosaukums", "name_placeholder": "piem., Komanda, Ģimene", "name_required": "Grupas nosaukums ir obligāts", @@ -3531,13 +3542,13 @@ "selected": "Atlasīts {count, plural, one {1 kontakts} other {# kontakti}}", "select_all": "Atlasīt visus", "delete": "Dzēst", - "delete_confirm_title": "Dzēst kontaktus", "delete_confirm": "Vai dzēst {count, plural, one {1 kontaktu} other {# kontaktus}}?", "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}}", + "moving_contacts": "Pārvieto {count, plural, one {1 kontaktu} other {# kontaktus}}", "added_to_group": "Kontakti pievienoti grupai", "export": "Eksportēt", "clear": "Notīrīt atlasi" @@ -3775,6 +3786,10 @@ "recurrence_scope": { "edit_title": "Rediģēt atkārtotu pasākumu", "delete_title": "Dzēst atkārtotu pasākumu", + "delete_question": "Dzēst atkārtotu notikumu?", + "delete_this_message": "Tiks dzēsta tikai šī «{title}» reize. To nevar atsaukt.", + "delete_following_message": "Tiks dzēsta šī un visas nākamās «{title}» reizes. To nevar atsaukt.", + "delete_all_message": "Tiks dzēstas visas «{title}» reizes. To nevar atsaukt.", "description": "Šis ir atkārtots pasākums. Kuras instances vēlaties mainīt?", "this_event": "Tikai šo pasākumu", "this_and_future": "Šo un turpmākos", @@ -3981,13 +3996,14 @@ "color": "Krāsa", "change_color": "Mainīt krāsu", "random_color": "Jauna nejauša krāsa", - "add_calendar": "Pievienot kalendāru", + "add_calendar": "Jauns kalendārs", "edit": "Rediģēt", "delete": "Dzēst", "save": "Saglabāt", "create": "Izveidot", "cancel": "Atcelt", "default": "Noklusējuma", + "row_actions": "Darbības: {name}", "confirm_delete": "Dzēst «{name}»? Visi pasākumi šajā kalendārā tiks dzēsti.", "confirm_clear": "Iztīrīt visus pasākumus no «{name}»? Šo darbību nevar atcelt.", "set_default": "Iestatīt kā noklusējumu", @@ -5044,7 +5060,7 @@ "loading": "Ielādē lapu", "not_available_title": "Šī lapa nav pieejama", "not_available_description": "Spraudnis, kas to pievienoja, ir izslēgts vai vairs nav instalēts.", - "back_to_mail": "Atpakaļ uz pastu" + "back_to_mail": "Atgriezties pie pasta" }, "command_palette": { "title": "Komandu palete", diff --git a/locales/mn/common.json b/locales/mn/common.json index f382171ac..e3a2e1f98 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -1013,8 +1013,8 @@ "email_tagged": "И-мэйл хаяглагдсан", "emails_tagged": "{count} мэйл хаяглагдсан", "tag_failed": "Табга хийж чадсангүй", - "identity_created": "Таниулбарыг амжилттай үүсгэлээ", - "identity_updated": "Таниулбарыг амжилттай шинэчиллээ", + "identity_created": "Таниулбарыг үүсгэлээ", + "identity_updated": "Таниулбарыг шинэчиллээ", "identity_deleted": "Таниулбарыг устгалаа", "identity_set_primary": "Үндсэн таниулбар шинэчлэгдсэн", "identity_create_failed": "Таниулбар үүсгэж чадсангүй: {error}", @@ -1024,10 +1024,10 @@ "identity_not_found": "Таниулбар олдсонгүй", "vacation_saved": "Эзгүй байх тохиргоог хадгалсан", "vacation_save_failed": "Эзгүй байх тохиргоог хадгалж чадсангүй", - "filters_saved": "Шүүлтүүрийг амжилттай хадгаллаа", + "filters_saved": "Шүүлтүүрийг хадгаллаа", "filters_save_failed": "Шүүлтүүрийг хадгалж чадсангүй", "filters_deleted": "Шүүлтүүрийн дүрмийг устгасан", - "templates_exported": "Загваруудыг амжилттай экспортоллоо", + "templates_exported": "Загваруудыг экспортоллоо", "templates_imported": "{count, plural, one {# загвар} other {# загвар}}импортолсон", "templates_import_errors": "Зарим загварыг импортлох боломжгүй байна", "templates_import_empty": "Файлд ямар ч загвар олдсонгүй", @@ -1120,10 +1120,10 @@ "import_success": "Тохиргоог амжилттай импортоллоо", "import_error": "Тохиргоог оруулж чадсангүй", "reset_confirm": "Та бүх тохиргоог өгөгдмөл рүү буцаахдаа итгэлтэй байна уу?", - "unsaved_changes": "Танд хадгалагдаагүй өөрчлөлтүүд байна", - "discard_changes": "Хадгалагдаагүй өөрчлөлтүүдийг устгах уу?", - "discard": "Хасах", - "keep_editing": "Засварлаж байгаарай", + "unsaved_changes": "Таны өөрчлөлтүүд хадгалагдаагүй байна.", + "discard_changes": "Өөрчлөлтүүдийг хаях уу?", + "discard": "Хаях", + "keep_editing": "Үргэлжлүүлэн засах", "search_placeholder": "Хайлтын тохиргоо", "search_clear": "Хайлтыг арилгах", "search_no_results": "Тохирох тохиргоо байхгүй", @@ -1355,6 +1355,8 @@ "id_exists": "Энэ хаягийн ID аль хэдийн байна", "edit": "Таг засах", "delete": "Тагыг устгах", + "edit_tag": "{name}-г засах", + "delete_tag": "{name}-г устгах", "deleted": "Таг устгасан", "save": "Хадгалах", "add": "Нэмэх", @@ -1948,7 +1950,7 @@ "app_passwords": { "title": "Аппын нууц үг", "description": "Хоёр хүчин зүйлийн баталгаажуулалтыг дэмждэггүй програмуудад нууц үг үүсгэх", - "add": "Нэмэх", + "new": "Шинэ аппын нууц үг", "create": "Үүсгэх", "cancel": "Цуцлах", "done": "Дууссан", @@ -1973,6 +1975,7 @@ "api_keys": { "title": "API түлхүүрүүд", "description": "Сервертэй шууд харьцдаг скрипт болон интеграцид зориулсан API түлхүүрүүдийг үүсгэх", + "new": "Шинэ API түлхүүр", "name_label": "Түлхүүр нэр", "name_placeholder": "жишээ нь. Нөөц скрипт, CI runner", "copy_now_warning": "Энэ API түлхүүрийг одоо хуулна уу - энэ нь дахиж харагдахгүй.", @@ -2013,6 +2016,8 @@ "key_placeholder": "Нийтийн түлхүүрээ энд буулгана уу", "none": "Нийтийн түлхүүр тохируулаагүй", "title": "Нийтийн түлхүүрүүд", + "add": "Нийтийн түлхүүр нэмэх", + "add_submit": "Нэмэх", "added": "Нийтийн түлхүүр нэмсэн", "removed": "Нийтийн түлхүүр устгасан", "add_error": "Нийтийн түлхүүр нэмж чадсангүй", @@ -2026,7 +2031,7 @@ "jmap_username_label": "JMAP хэрэглэгчийн нэр", "copy": "Хуулбар", "copied": "Хуулбарласан", - "password_instructions": "Имэйл клиентдээ нэвтрэхийн тулд дээрх JMAP хэрэглэгчийн нэрээ програмын нууц үгийн хамт ашиглана уу. Хэрэв та хийгээгүй бол дээрх хэсэгт програмын нууц үг үүсгэнэ үү." + "password_instructions": "Имэйл клиентдээ нэвтрэхийн тулд дээрх JMAP хэрэглэгчийн нэрээ програмын нууц үгийн хамт ашиглана уу. Хэрэв та хийгээгүй бол доорх хэсэгт програмын нууц үг үүсгэнэ үү." }, "link_device": { "title": "Мобайл програмыг холбоно уу", @@ -2341,10 +2346,10 @@ "export_label": "Харилцагчдыг экспортлох", "export_description": "Бүх харилцагчдыг vCard (.vcf) файл болгон экспортлох", "manage_title": "Хаягийн дэвтэр", - "manage_description": "Хаягийн дэвтэрийнхээ нэрийг өөрчлөх", + "manage_description": "Хаягийн дэвтрийнхээ нэрийг өөрчлөх эсвэл устгах", "no_address_books": "Хаягийн дэвтэр олдсонгүй", "categories_title": "Ангилал", - "categories_description": "Харилцах ангиллын нэрийг өөрчлөх", + "categories_description": "Харилцагчийн ангиллын нэрийг өөрчлөх эсвэл устгах", "no_categories": "Ангилал олдсонгүй" }, "calendar_management": { @@ -2418,7 +2423,7 @@ "validation_error": "Script алдаатай байна", "save": "Хадгалах", "saving": "Хадгалж байна...", - "saved": "Шүүлтүүрийг амжилттай хадгаллаа", + "saved": "Шүүлтүүрийг хадгаллаа", "save_failed": "Шүүлтүүрийг хадгалж чадсангүй", "loading": "Шүүлтүүрийг ачаалж байна...", "not_supported": "Таны мэйл сервер имэйл шүүлтүүрийг дэмждэггүй.", @@ -2486,6 +2491,7 @@ "label_name": "Тагийн нэр", "label_placeholder": "Таг сонгох", "header_name": "Толгойн нэр", + "condition_value": "Нөхцлийн утга", "header_placeholder": "жишээ нь, X-мэйл хаягийн жагсаалт", "size_bytes": "Хэмжээ байтаар", "size_placeholder": "жишээ нь, 1000000", @@ -2513,6 +2519,10 @@ "validation_empty_name": "Дүрмийн нэр шаардлагатай", "validation_empty_conditions": "Дор хаяж нэг утга бүхий нөхцөл шаардлагатай", "validation_empty_actions": "Дор хаяж нэг үйлдэл хийх шаардлагатай", + "validation_action_folder": "Энэ үйлдэлд хавтас сонгоно уу", + "validation_action_tag": "Энэ үйлдэлд шошго сонгоно уу", + "validation_action_forward": "Дамжуулах хаягаа оруулна уу", + "validation_action_reject": "Татгалзах мессежээ оруулна уу", "validation_header_name": "Толгойн нэрэнд зөвхөн A-Z үсэг, тоо, зураас, доогуур зураас ашиглаж болно", "templates_section": "Загвар", "template_newsletters": "Мэдээллийн хуудсыг хавтас руу зөөх", @@ -2525,12 +2535,12 @@ "script_content": "Шигшүүрийн скрипт", "valid": "Скрипт хүчинтэй", "invalid": "Script-д алдаа байна", - "save_warning": "Хадгалснаар ямар ч харааны дүрмийг дарж бичнэ. Үүнийг буцаах боломжгүй. Баталгаажуулахын тулд Save дахин дарна уу.", "validating": "Баталгаажуулж байна...", "validate": "Баталгаажуулах", "cancel": "Цуцлах", "save": "Хадгалах", - "confirm_save": "Хадгалахыг баталгаажуулах", + "overwrite_title": "Харааны дүрмүүдийг солих уу?", + "overwrite_message": "Энэ скриптэд харааны засварлагчид үүсгэсэн дүрмүүд байхгүй болсон. Хадгалбал тэдгээр солигдох бөгөөд буцаах боломжгүй.", "validation_failed": "Баталгаажуулах хүсэлт амжилтгүй боллоо" }, "rule_summary": { @@ -3258,8 +3268,8 @@ "empty_filtered_hint": "Шүүлтүүрийг тохируулж эсвэл цэвэрлэж үзнэ үү", "clear_search": "Хайлтыг арилгах", "import_vcard": "vCard импортлох", - "delete_confirm_title": "Харилцагчийг устгах", - "delete_confirm": "Та энэ харилцагчийг устгахдаа итгэлтэй байна уу?", + "delete_named_title": "{name}-г устгах уу?", + "delete_details_message": "Имэйл хаяг, утасны дугаар, тэмдэглэл болон бусад бүх мэдээлэл устгагдана. Үүнийг буцаах боломжгүй.", "local_mode": "Харилцагчдыг дотооддоо хадгалдаг (сервер нь JMAP Contacts-ийг дэмждэггүй)", "back_to_contacts": "Харилцагч руу буцах", "back_to_email": "Имэйл рүү буцах", @@ -3295,6 +3305,7 @@ "share": "Хаягийн дэвтэр хуваалцах", "new_contact_in_book": "Энэ хаягийн дэвтэрт шинэ харилцагч", "delete": "Хаягийн дэвтэр устгах", + "delete_title": "Хаягийн дэвтрийг устгах уу?", "confirm_delete": "\"{name}\"-г устгах уу? Энэ хаягийн дэвтэр дэх бүх харилцагчийг устгах болно.", "deleted": "Хаягийн дэвтэр устсан", "delete_failed": "Хаягийн дэвтрийг устгаж чадсангүй", @@ -3471,8 +3482,8 @@ "add_phone": "Утас нэмэх", "save": "Хадгалах", "cancel": "Цуцлах", - "discard_title": "Өөрчлөлтийг хаях уу?", - "discard_message": "Таны өөрчлөлт хадгалагдаагүй байна.", + "discard_title": "Өөрчлөлтүүдийг хаях уу?", + "discard_message": "Таны өөрчлөлтүүд хадгалагдаагүй байна.", "discard": "Хаях", "keep_editing": "Үргэлжлүүлэн засах", "remove_entry": "Хасах", @@ -3499,7 +3510,7 @@ "edit": "Засварлах групп", "empty": "Одоохондоо бүлэг байхгүй", "delete_confirm_title": "Бүлгийг устгах", - "delete_confirm": "Та энэ бүлгийг устгахдаа итгэлтэй байна уу?", + "delete_message": "Бүлэг устгагдана. Доторх харилцагчид хадгалагдана.", "name_label": "Бүлгийн нэр", "name_placeholder": "жишээ нь, Баг, Гэр бүл", "name_required": "Бүлгийн нэр шаардлагатай", @@ -3541,13 +3552,13 @@ "selected": "{count, plural, one {1 сонгогдсон} other {# сонгогдсон}}", "select_all": "Бүгдийг нь сонго", "delete": "Устгах", - "delete_confirm_title": "Харилцагчдыг устгах", "delete_confirm": "{count, plural, one {1 контакт} other {# контакт}} устгах уу?", "deleted": "{count, plural, one {1 контакт устгагдлаа} other {# контакт устгагдлаа}}", "add_to_group": "Бүлэгт нэмэх", "choose_group": "Бүлэг сонгох", "new_group": "Шинэ бүлэг…", "adding_contacts": "{count, plural, one {1 контакт} other {# контакт}} нэмэж байна", + "moving_contacts": "{count, plural, one {1 контакт} other {# контакт}} зөөж байна", "added_to_group": "Харилцагчдыг группт нэмсэн", "export": "Экспорт хийх", "clear": "Сонголтыг арилгах" @@ -3776,6 +3787,10 @@ "recurrence_scope": { "edit_title": "Давтах үйл явдлыг засах", "delete_title": "Дахин давтагдах үйл явдлыг устгах", + "delete_question": "Давтагдах арга хэмжээг устгах уу?", + "delete_this_message": "\"{title}\"-ийн зөвхөн энэ удаагийнх устгагдана. Үүнийг буцаах боломжгүй.", + "delete_following_message": "\"{title}\"-ийн энэ болон дараагийн бүх удаагийнх устгагдана. Үүнийг буцаах боломжгүй.", + "delete_all_message": "\"{title}\"-ийн бүх удаагийнх устгагдана. Үүнийг буцаах боломжгүй.", "description": "Энэ бол давтагдах үйл явдал. Та ямар үйл явдлыг өөрчлөхийг хүсэж байна вэ?", "this_event": "Зөвхөн энэ арга хэмжээ", "this_and_future": "Энэ болон дараах үйл явдлууд", @@ -4011,13 +4026,14 @@ "color": "Өнгө", "change_color": "Өнгийг өөрчлөх", "random_color": "Шинэ санамсаргүй өнгө", - "add_calendar": "Хуанли нэмэх", + "add_calendar": "Шинэ хуанли", "edit": "Засварлах", "delete": "Устгах", "save": "Хадгалах", "create": "Бүтээх", "cancel": "Цуцлах", "default": "Өгөгдмөл", + "row_actions": "{name}-ийн үйлдлүүд", "confirm_delete": "\"{name}\"-г устгах уу? Энэ хуанли дахь бүх үйл явдлууд устах болно.", "confirm_clear": "\"{name}\"-с бүх үйл явдлыг арилгах уу? Үүнийг буцаах боломжгүй.", "set_default": "Өгөгдмөлөөр тохируулах", diff --git a/locales/nb/common.json b/locales/nb/common.json index 2bcccb24a..0b53b4ddb 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -1120,8 +1120,8 @@ "import_success": "Innstillingene er importert", "import_error": "Kunne ikke importere innstillinger", "reset_confirm": "Vil du tilbakestille alle innstillingene?", - "unsaved_changes": "Du har ulagrede endringer", - "discard_changes": "Vil du forkaste ulagrede endringer?", + "unsaved_changes": "Endringene dine er ikke lagret.", + "discard_changes": "Vil du forkaste endringene?", "discard": "Forkast", "keep_editing": "Fortsett å redigere", "search_placeholder": "Søk i innstillinger", @@ -1355,6 +1355,8 @@ "id_exists": "Denne etikett-ID-en finnes allerede", "edit": "Rediger etikett", "delete": "Slett etikett", + "edit_tag": "Rediger {name}", + "delete_tag": "Slett {name}", "deleted": "Etikett slettet", "save": "Lagre", "add": "Legg til", @@ -1948,7 +1950,7 @@ "app_passwords": { "title": "App-passord", "description": "Lag passord for apper som ikke støtter tofaktorautentisering", - "add": "Legg til", + "new": "Nytt app-passord", "create": "Opprett", "cancel": "Avbryt", "done": "Ferdig", @@ -1973,6 +1975,7 @@ "api_keys": { "title": "API-nøkler", "description": "Lag API-nøkler for skript og integrasjoner som kommuniserer direkte med serveren", + "new": "Ny API-nøkkel", "name_label": "Nøkkelnavn", "name_placeholder": "f.eks. sikkerhetskopieringsskript, CI-løper", "copy_now_warning": "Kopier denne API-nøkkelen nå – den vises ikke igjen.", @@ -2013,6 +2016,8 @@ "key_placeholder": "Lim inn den offentlige nøkkelen din her", "none": "Ingen offentlige nøkler er lagt til", "title": "Offentlige nøkler", + "add": "Legg til offentlig nøkkel", + "add_submit": "Legg til", "added": "Offentlig nøkkel lagt til", "removed": "Offentlig nøkkel slettet", "add_error": "Kunne ikke legge til den offentlige nøkkelen", @@ -2026,7 +2031,7 @@ "jmap_username_label": "JMAP-brukernavn", "copy": "Kopier", "copied": "Kopiert", - "password_instructions": "Bruk JMAP-brukernavnet ovenfor sammen med et app-passord for å logge på e-postklienten. Opprett et app-passord i delen ovenfor hvis du ikke allerede har gjort det." + "password_instructions": "Bruk JMAP-brukernavnet ovenfor sammen med et app-passord for å logge på e-postklienten. Opprett et app-passord i delen nedenfor hvis du ikke allerede har gjort det." }, "link_device": { "title": "Koble til mobilappen", @@ -2341,10 +2346,10 @@ "export_label": "Eksporter kontakter", "export_description": "Eksporter alle kontakter som en vCard-fil (.vcf).", "manage_title": "Adressebøker", - "manage_description": "Gi nytt navn til adressebøkene dine", + "manage_description": "Gi nytt navn til eller slett adressebøkene dine", "no_address_books": "Ingen adressebøker funnet", "categories_title": "Kategorier", - "categories_description": "Gi nytt navn til kontaktkategorier", + "categories_description": "Gi nytt navn til eller slett kontaktkategorier", "no_categories": "Ingen kategorier funnet" }, "calendar_management": { @@ -2486,6 +2491,7 @@ "label_name": "Etikettnavn", "label_placeholder": "Velg etikett", "header_name": "Navn på meldingshode", + "condition_value": "Verdi for betingelsen", "header_placeholder": "f.eks. X-Mailing-List", "size_bytes": "Størrelse i byte", "size_placeholder": "f.eks. 1000000", @@ -2513,6 +2519,10 @@ "validation_empty_name": "Regelnavn må fylles ut", "validation_empty_conditions": "Minst ett vilkår må ha en verdi", "validation_empty_actions": "Du må legge til minst én handling", + "validation_action_folder": "Velg en mappe for denne handlingen", + "validation_action_tag": "Velg en etikett for denne handlingen", + "validation_action_forward": "Skriv inn en adresse det skal videresendes til", + "validation_action_reject": "Skriv inn avvisningsmeldingen", "validation_header_name": "Navn på meldingshoder kan bare inneholde bokstavene A-Z, sifre, bindestreker og understreker", "templates_section": "Start med en mal", "template_newsletters": "Flytt nyhetsbrev til mappe", @@ -2525,12 +2535,12 @@ "script_content": "Sieve-skript", "valid": "Skriptet er gyldig", "invalid": "Skriptet har feil", - "save_warning": "Lagring overskriver eventuelle visuelle regler. Dette kan ikke angres. Klikk på «Lagre» igjen for å bekrefte.", "validating": "Validerer...", "validate": "Valider", "cancel": "Avbryt", "save": "Lagre", - "confirm_save": "Bekreft lagring", + "overwrite_title": "Erstatte de visuelle reglene?", + "overwrite_message": "Dette skriptet inneholder ikke lenger reglene du laget i den visuelle redigereren. Hvis du lagrer det, erstattes de, og det kan ikke angres.", "validation_failed": "Kunne ikke validere skriptet" }, "rule_summary": { @@ -3258,8 +3268,8 @@ "empty_filtered_hint": "Prøv å endre eller fjerne filtrene", "clear_search": "Tøm søket", "import_vcard": "Importer vCard", - "delete_confirm_title": "Slett kontakt", - "delete_confirm": "Er du sikker på at du vil slette denne kontakten?", + "delete_named_title": "Vil du slette {name}?", + "delete_details_message": "E-postadresser, telefonnumre, notater og alle andre opplysninger blir slettet. Dette kan ikke angres.", "local_mode": "Kontakter lagres lokalt (serveren støtter ikke JMAP-kontakter)", "back_to_contacts": "Tilbake til kontaktene", "back_to_email": "Tilbake til e-post", @@ -3295,6 +3305,7 @@ "share": "Del adressebok", "new_contact_in_book": "Ny kontakt i denne adresseboken", "delete": "Slett adressebok", + "delete_title": "Vil du slette adresseboken?", "confirm_delete": "Vil du slette «{name}»? Alle kontaktene i denne adresseboken fjernes.", "deleted": "Adresseboken er slettet", "delete_failed": "Kunne ikke slette adresseboken", @@ -3499,7 +3510,7 @@ "edit": "Rediger gruppe", "empty": "Ingen grupper ennå", "delete_confirm_title": "Slett gruppe", - "delete_confirm": "Er du sikker på at du vil slette denne gruppen?", + "delete_message": "Gruppen blir slettet. Kontaktene i den beholdes.", "name_label": "Gruppenavn", "name_placeholder": "f.eks. team, familie", "name_required": "Gruppenavn må fylles ut", @@ -3541,13 +3552,13 @@ "selected": "{count, plural, one {1 valgt} other {# valgte}}", "select_all": "Velg alle", "delete": "Slett", - "delete_confirm_title": "Slett kontakter", "delete_confirm": "Vil du slette {count, plural, one {1 kontakt} other {# kontakter}}?", "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}}", + "moving_contacts": "Flytter {count, plural, one {1 kontakt} other {# kontakter}}", "added_to_group": "Kontaktene er lagt til i gruppen", "export": "Eksporter", "clear": "Fjern valg" @@ -3776,6 +3787,10 @@ "recurrence_scope": { "edit_title": "Rediger gjentakende hendelse", "delete_title": "Slett gjentakende hendelse", + "delete_question": "Vil du slette den gjentakende hendelsen?", + "delete_this_message": "Bare denne forekomsten av «{title}» blir slettet. Dette kan ikke angres.", + "delete_following_message": "Denne og alle senere forekomster av «{title}» blir slettet. Dette kan ikke angres.", + "delete_all_message": "Alle forekomster av «{title}» blir slettet. Dette kan ikke angres.", "description": "Dette er en gjentakende hendelse. Hvilke hendelser vil du endre?", "this_event": "Bare denne hendelsen", "this_and_future": "Dette og følgende hendelser", @@ -4011,13 +4026,14 @@ "color": "Farge", "change_color": "Endre farge", "random_color": "Ny tilfeldig farge", - "add_calendar": "Legg til kalender", + "add_calendar": "Ny kalender", "edit": "Rediger", "delete": "Slett", "save": "Lagre", "create": "Opprett", "cancel": "Avbryt", "default": "Standard", + "row_actions": "Handlinger for {name}", "confirm_delete": "Vil du slette «{name}»? Alle hendelsene i kalenderen fjernes.", "confirm_clear": "Vil du fjerne alle hendelser fra «{name}»? Dette kan ikke angres.", "set_default": "Sett som standard", diff --git a/locales/nl/common.json b/locales/nl/common.json index e50f1948b..ec34b3877 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1013,8 +1013,8 @@ "email_tagged": "E-mail gelabeld", "emails_tagged": "{count} e-mails gelabeld", "tag_failed": "Labelen mislukt", - "identity_created": "Identiteit succesvol aangemaakt", - "identity_updated": "Identiteit succesvol bijgewerkt", + "identity_created": "Identiteit aangemaakt", + "identity_updated": "Identiteit bijgewerkt", "identity_deleted": "Identiteit verwijderd", "identity_set_primary": "Primaire identiteit bijgewerkt", "identity_create_failed": "Kan identiteit niet aanmaken: {error}", @@ -1024,10 +1024,10 @@ "identity_not_found": "Identiteit niet gevonden", "vacation_saved": "Afwezigheidsinstellingen opgeslagen", "vacation_save_failed": "Kan afwezigheidsinstellingen niet opslaan", - "filters_saved": "Filters succesvol opgeslagen", + "filters_saved": "Filters opgeslagen", "filters_save_failed": "Filters opslaan mislukt", "filters_deleted": "Filterregel verwijderd", - "templates_exported": "Sjablonen succesvol geëxporteerd", + "templates_exported": "Sjablonen geëxporteerd", "templates_imported": "{count, plural, one {# sjabloon geïmporteerd} other {# sjablonen geïmporteerd}}", "templates_import_errors": "Sommige sjablonen konden niet worden geïmporteerd", "templates_import_empty": "Geen sjablonen gevonden in het bestand", @@ -1114,13 +1114,13 @@ }, "settings": { "title": "Instellingen", - "back_to_mail": "Terug naar Mail", + "back_to_mail": "Terug naar mail", "back_to_settings": "Terug naar instellingen", "save_success": "Instellingen succesvol opgeslagen", "import_success": "Instellingen succesvol geïmporteerd", "import_error": "Kan instellingen niet importeren", "reset_confirm": "Weet je zeker dat je alle instellingen wilt resetten naar de standaardwaarden?", - "unsaved_changes": "U heeft niet-opgeslagen wijzigingen", + "unsaved_changes": "Je wijzigingen zijn niet opgeslagen.", "discard_changes": "Wijzigingen verwerpen?", "discard": "Verwerpen", "keep_editing": "Doorgaan met bewerken", @@ -1352,6 +1352,8 @@ "id_exists": "Deze label-ID bestaat al", "edit": "Label bewerken", "delete": "Label verwijderen", + "edit_tag": "{name} bewerken", + "delete_tag": "{name} verwijderen", "deleted": "Label verwijderd", "save": "Opslaan", "add": "Toevoegen", @@ -1941,7 +1943,7 @@ "app_passwords": { "title": "App-wachtwoorden", "description": "Maak wachtwoorden aan voor apps die geen tweefactorauthenticatie ondersteunen", - "add": "Toevoegen", + "new": "Nieuw app-wachtwoord", "create": "Aanmaken", "cancel": "Annuleren", "generate": "Genereren", @@ -1966,6 +1968,7 @@ "api_keys": { "title": "API-sleutels", "description": "Maak API-sleutels voor scripts en integraties die rechtstreeks met de server praten", + "new": "Nieuwe API-sleutel", "name_label": "Sleutelnaam", "name_placeholder": "bijv. Back-upscript, CI-runner", "copy_now_warning": "Kopieer deze API-sleutel nu - hij wordt niet opnieuw getoond.", @@ -2005,7 +2008,7 @@ "jmap_username_label": "JMAP-gebruikersnaam", "copy": "Kopiëren", "copied": "Gekopieerd", - "password_instructions": "Gebruik uw hierboven vermelde JMAP-gebruikersnaam samen met een app-wachtwoord om in te loggen bij uw e-mailclient. Maak een app-wachtwoord aan in het bovenstaande gedeelte als u dat nog niet heeft gedaan." + "password_instructions": "Gebruik uw hierboven vermelde JMAP-gebruikersnaam samen met een app-wachtwoord om in te loggen bij uw e-mailclient. Maak een app-wachtwoord aan in het gedeelte hieronder als u dat nog niet heeft gedaan." }, "link_device": { "title": "Mobiele app koppelen", @@ -2047,6 +2050,8 @@ "key_placeholder": "Plak hier uw openbare sleutel", "none": "Geen openbare sleutels geconfigureerd", "title": "Openbare sleutels", + "add": "Openbare sleutel toevoegen", + "add_submit": "Toevoegen", "added": "Openbare sleutel toegevoegd", "removed": "Openbare sleutel verwijderd", "add_error": "Kan openbare sleutel niet toevoegen", @@ -2330,10 +2335,10 @@ "export_label": "Contacten exporteren", "export_description": "Alle contacten exporteren als vCard-bestand (.vcf)", "manage_title": "Adresboeken", - "manage_description": "Hernoem uw adresboeken", + "manage_description": "Hernoem of verwijder uw adresboeken", "no_address_books": "Geen adresboeken gevonden", "categories_title": "Categorieën", - "categories_description": "Contactcategorieën hernoemen", + "categories_description": "Contactcategorieën hernoemen of verwijderen", "no_categories": "Geen categorieën gevonden", "group_by_letter_description": "Toon alfabetische sectiekoppen in de contactenlijst", "group_by_letter_label": "Groeperen op eerste letter", @@ -2411,7 +2416,7 @@ "validation_error": "Het script bevat fouten", "save": "Opslaan", "saving": "Opslaan...", - "saved": "Filters succesvol opgeslagen", + "saved": "Filters opgeslagen", "save_failed": "Filters opslaan mislukt", "loading": "Filters laden...", "not_supported": "Uw mailserver ondersteunt geen e-mailfilters.", @@ -2479,6 +2484,7 @@ "label_name": "Labelnaam", "label_placeholder": "Label kiezen", "header_name": "Headernaam", + "condition_value": "Waarde van de voorwaarde", "header_placeholder": "bijv. X-Mailing-List", "size_bytes": "Grootte in bytes", "size_placeholder": "bijv. 1000000", @@ -2506,6 +2512,10 @@ "validation_empty_name": "Regelnaam is verplicht", "validation_empty_conditions": "Minstens één voorwaarde met een waarde is vereist", "validation_empty_actions": "Minstens één actie is vereist", + "validation_action_folder": "Kies een map voor deze actie", + "validation_action_tag": "Kies een label voor deze actie", + "validation_action_forward": "Vul een adres in om naar door te sturen", + "validation_action_reject": "Vul het afwijzingsbericht in", "validation_header_name": "Headernamen mogen alleen de letters A-Z, cijfers, koppeltekens en underscores bevatten", "templates_section": "Starten vanuit sjabloon", "template_newsletters": "Nieuwsbrieven naar map verplaatsen", @@ -2518,12 +2528,12 @@ "script_content": "Sieve-script", "valid": "Script is geldig", "invalid": "Script bevat fouten", - "save_warning": "Opslaan overschrijft alle visuele regels. Dit kan niet ongedaan worden gemaakt. Klik nogmaals op Opslaan om te bevestigen.", "validating": "Valideren...", "validate": "Valideren", "cancel": "Annuleren", "save": "Opslaan", - "confirm_save": "Opslaan bevestigen", + "overwrite_title": "Je visuele regels vervangen?", + "overwrite_message": "Dit script bevat de regels die je in de visuele editor hebt gemaakt niet meer. Opslaan vervangt ze, en dit kan niet ongedaan worden gemaakt.", "validation_failed": "Validatieverzoek mislukt" }, "rule_summary": { @@ -3258,8 +3268,8 @@ "empty_filtered_hint": "Pas filters aan of wis ze", "clear_search": "Zoekopdracht wissen", "import_vcard": "vCard importeren", - "delete_confirm_title": "Contact verwijderen", - "delete_confirm": "Weet u zeker dat u dit contact wilt verwijderen?", + "delete_named_title": "{name} verwijderen?", + "delete_details_message": "E-mailadressen, telefoonnummers, notities en alle andere gegevens worden verwijderd. Dit kan niet ongedaan worden gemaakt.", "local_mode": "Contacten worden lokaal opgeslagen (server ondersteunt geen JMAP Contacts)", "back_to_contacts": "Terug naar contacten", "back_to_email": "Terug naar e-mail", @@ -3293,6 +3303,7 @@ "share": "Adresboek delen", "new_contact_in_book": "Nieuw contact in dit adresboek", "delete": "Adresboek verwijderen", + "delete_title": "Adresboek verwijderen?", "confirm_delete": "\"{name}\" verwijderen? Alle contacten in dit adresboek worden verwijderd.", "deleted": "Adresboek verwijderd", "delete_failed": "Adresboek kon niet worden verwijderd", @@ -3497,7 +3508,7 @@ "edit": "Groep bewerken", "empty": "Geen groepen", "delete_confirm_title": "Groep verwijderen", - "delete_confirm": "Weet u zeker dat u deze groep wilt verwijderen?", + "delete_message": "De groep wordt verwijderd. De contacten erin blijven bewaard.", "name_label": "Groepsnaam", "name_placeholder": "bijv. Team, Familie", "name_required": "Groepsnaam is vereist", @@ -3539,13 +3550,13 @@ "selected": "{count, plural, one {1 geselecteerd} other {# geselecteerd}}", "select_all": "Alles selecteren", "delete": "Verwijderen", - "delete_confirm_title": "Contacten verwijderen", "delete_confirm": "{count, plural, one {1 contact} other {# contacten}} verwijderen?", "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", + "moving_contacts": "{count, plural, one {1 contact} other {# contacten}} verplaatsen", "added_to_group": "Contacten aan groep toegevoegd", "export": "Exporteren", "clear": "Selectie wissen" @@ -3776,6 +3787,10 @@ "recurrence_scope": { "edit_title": "Terugkerend evenement bewerken", "delete_title": "Terugkerend evenement verwijderen", + "delete_question": "Terugkerende afspraak verwijderen?", + "delete_this_message": "Alleen deze keer van \"{title}\" wordt verwijderd. Dit kan niet ongedaan worden gemaakt.", + "delete_following_message": "Deze en alle volgende keren van \"{title}\" worden verwijderd. Dit kan niet ongedaan worden gemaakt.", + "delete_all_message": "Alle keren van \"{title}\" worden verwijderd. Dit kan niet ongedaan worden gemaakt.", "description": "Dit is een terugkerend evenement. Welke evenementen wilt u wijzigen?", "this_event": "Alleen dit evenement", "this_and_future": "Dit en volgende evenementen", @@ -3982,13 +3997,14 @@ "color": "Kleur", "change_color": "Kleur wijzigen", "random_color": "Nieuwe willekeurige kleur", - "add_calendar": "Agenda toevoegen", + "add_calendar": "Nieuwe agenda", "edit": "Bewerken", "delete": "Verwijderen", "save": "Opslaan", "create": "Aanmaken", "cancel": "Annuleren", "default": "Standaard", + "row_actions": "Acties voor {name}", "confirm_delete": "\"{name}\" verwijderen? Alle afspraken in deze agenda worden verwijderd.", "calendar_created": "Agenda aangemaakt", "calendar_updated": "Agenda bijgewerkt", @@ -5044,7 +5060,7 @@ "loading": "Pagina laden", "not_available_title": "Deze pagina is niet beschikbaar", "not_available_description": "De plug-in die hem heeft toegevoegd, staat uit of is niet meer geïnstalleerd.", - "back_to_mail": "Terug naar e-mail" + "back_to_mail": "Terug naar mail" }, "command_palette": { "title": "Opdrachtenpalet", diff --git a/locales/pl/common.json b/locales/pl/common.json index 88c8a4fde..98ea7d843 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1013,8 +1013,8 @@ "email_tagged": "Wiadomość oznaczona etykietą", "emails_tagged": "{count} wiadomości oznaczonych etykietą", "tag_failed": "Oznaczanie etykietą nie powiodło się", - "identity_created": "Tożsamość utworzona pomyślnie", - "identity_updated": "Tożsamość zaktualizowana pomyślnie", + "identity_created": "Tożsamość utworzona", + "identity_updated": "Tożsamość zaktualizowana", "identity_deleted": "Tożsamość usunięta", "identity_set_primary": "Główna tożsamość zaktualizowana", "identity_create_failed": "Nie udało się utworzyć tożsamości: {error}", @@ -1024,10 +1024,10 @@ "identity_not_found": "Nie znaleziono tożsamości", "vacation_saved": "Ustawienia nieobecności zapisane", "vacation_save_failed": "Nie udało się zapisać ustawień nieobecności", - "filters_saved": "Filtry zapisane pomyślnie", + "filters_saved": "Filtry zapisane", "filters_save_failed": "Nie udało się zapisać filtrów", "filters_deleted": "Reguła filtra usunięta", - "templates_exported": "Szablony wyeksportowane pomyślnie", + "templates_exported": "Szablony wyeksportowane", "templates_imported": "{count, plural, one {# szablon zaimportowany} other {# szablonów zaimportowanych}}", "templates_import_errors": "Niektóre szablony nie mogły zostać zaimportowane", "templates_import_empty": "Nie znaleziono szablonów w pliku", @@ -1120,8 +1120,8 @@ "import_success": "Ustawienia zostały zaimportowane", "import_error": "Nie udało się zaimportować ustawień", "reset_confirm": "Czy na pewno chcesz przywrócić domyślne ustawienia?", - "unsaved_changes": "Masz niezapisane zmiany", - "discard_changes": "Odrzucić niezapisane zmiany?", + "unsaved_changes": "Twoje zmiany nie zostały zapisane.", + "discard_changes": "Odrzucić zmiany?", "discard": "Odrzuć", "keep_editing": "Kontynuuj edycję", "tabs": { @@ -1352,6 +1352,8 @@ "id_exists": "Ten ID etykiety już istnieje", "edit": "Edytuj etykietę", "delete": "Usuń etykietę", + "edit_tag": "Edytuj {name}", + "delete_tag": "Usuń {name}", "deleted": "Etykieta usunięta", "save": "Zapisz", "add": "Dodaj", @@ -1941,7 +1943,7 @@ "app_passwords": { "title": "Hasła aplikacji", "description": "Twórz hasła dla aplikacji, które nie obsługują uwierzytelniania dwuskładnikowego", - "add": "Dodaj", + "new": "Nowe hasło aplikacji", "create": "Utwórz", "cancel": "Anuluj", "generate": "Generuj", @@ -1966,6 +1968,7 @@ "api_keys": { "title": "Klucze API", "description": "Twórz klucze API dla skryptów i integracji komunikujących się bezpośrednio z serwerem", + "new": "Nowy klucz API", "name_label": "Nazwa klucza", "name_placeholder": "np. Skrypt kopii zapasowej, CI runner", "copy_now_warning": "Skopiuj ten klucz API teraz - nie zostanie pokazany ponownie.", @@ -2005,7 +2008,7 @@ "jmap_username_label": "Nazwa użytkownika JMAP", "copy": "Kopiuj", "copied": "Skopiowano", - "password_instructions": "Użyj powyższej nazwy użytkownika JMAP wraz z hasłem aplikacji, aby zalogować się w kliencie poczty. Utwórz hasło aplikacji w sekcji powyżej, jeśli jeszcze tego nie zrobiłeś." + "password_instructions": "Użyj powyższej nazwy użytkownika JMAP wraz z hasłem aplikacji, aby zalogować się w kliencie poczty. Utwórz hasło aplikacji w sekcji poniżej, jeśli jeszcze tego nie zrobiłeś." }, "link_device": { "title": "Połącz aplikację mobilną", @@ -2047,6 +2050,8 @@ "key_placeholder": "Wklej tutaj swój klucz publiczny", "none": "Nie skonfigurowano żadnych kluczy publicznych", "title": "Klucze publiczne", + "add": "Dodaj klucz publiczny", + "add_submit": "Dodaj", "added": "Dodano klucz publiczny", "removed": "Usunięto klucz publiczny", "add_error": "Nie udało się dodać klucza publicznego", @@ -2330,10 +2335,10 @@ "export_label": "Eksportuj kontakty", "export_description": "Eksportuj wszystkie kontakty jako plik vCard (.vcf)", "manage_title": "Książki adresowe", - "manage_description": "Zmień nazwy swoich książek adresowych", + "manage_description": "Zmień nazwy swoich książek adresowych lub je usuń", "no_address_books": "Nie znaleziono książek adresowych", "categories_title": "Kategorie", - "categories_description": "Zmień nazwy kategorii kontaktów", + "categories_description": "Zmień nazwy kategorii kontaktów lub je usuń", "no_categories": "Nie znaleziono kategorii", "group_by_letter_description": "Pokaż alfabetyczne nagłówki sekcji na liście kontaktów", "group_by_letter_label": "Grupuj według pierwszej litery", @@ -2411,7 +2416,7 @@ "validation_error": "Skrypt zawiera błędy", "save": "Zapisz", "saving": "Zapisywanie...", - "saved": "Filtry zostały zapisane", + "saved": "Filtry zapisane", "save_failed": "Nie udało się zapisać filtrów", "loading": "Ładowanie filtrów...", "not_supported": "Twój serwer pocztowy nie obsługuje filtrów wiadomości e-mail.", @@ -2479,6 +2484,7 @@ "label_name": "Nazwa etykiety", "label_placeholder": "Wybierz etykietę", "header_name": "Nazwa nagłówka", + "condition_value": "Wartość warunku", "header_placeholder": "np. X-Mailing-List", "size_bytes": "Rozmiar w bajtach", "size_placeholder": "np. 1000000", @@ -2506,6 +2512,10 @@ "validation_empty_name": "Nazwa reguły jest wymagana", "validation_empty_conditions": "Wymagany jest co najmniej jeden warunek z wartością", "validation_empty_actions": "Wymagana jest co najmniej jedna akcja", + "validation_action_folder": "Wybierz folder dla tej akcji", + "validation_action_tag": "Wybierz etykietę dla tej akcji", + "validation_action_forward": "Wpisz adres, na który przekazywać", + "validation_action_reject": "Wpisz wiadomość odrzucenia", "validation_header_name": "Nazwy nagłówków mogą zawierać tylko litery A-Z, cyfry, łączniki i podkreślenia", "templates_section": "Rozpocznij od szablonu", "template_newsletters": "Przenieś newslettery do folderu", @@ -2518,12 +2528,12 @@ "script_content": "Skrypt Sieve", "valid": "Skrypt jest poprawny", "invalid": "Skrypt zawiera błędy", - "save_warning": "Zapisanie nadpisze wszystkie reguły wizualne. Nie można tego cofnąć. Kliknij Zapisz ponownie, aby potwierdzić.", "validating": "Walidowanie...", "validate": "Waliduj", "cancel": "Anuluj", "save": "Zapisz", - "confirm_save": "Potwierdź zapis", + "overwrite_title": "Zastąpić reguły wizualne?", + "overwrite_message": "Ten skrypt nie zawiera już reguł utworzonych w edytorze wizualnym. Zapisanie go zastąpi je i nie można tego cofnąć.", "validation_failed": "Żądanie walidacji nie powiodło się" }, "rule_summary": { @@ -3258,8 +3268,8 @@ "empty_filtered_hint": "Dostosuj lub wyczyść filtry", "clear_search": "Wyczyść wyszukiwanie", "import_vcard": "Importuj vCard", - "delete_confirm_title": "Usuń kontakt", - "delete_confirm": "Czy na pewno chcesz usunąć ten kontakt?", + "delete_named_title": "Usunąć {name}?", + "delete_details_message": "Adresy e-mail, numery telefonów, notatki i wszystkie inne dane zostaną usunięte. Tej operacji nie można cofnąć.", "local_mode": "Kontakty są przechowywane lokalnie (serwer nie obsługuje JMAP Contacts)", "back_to_contacts": "Powrót do kontaktów", "back_to_email": "Powrót do wiadomości", @@ -3293,6 +3303,7 @@ "share": "Udostępnij książkę adresową", "new_contact_in_book": "Nowy kontakt w tej książce adresowej", "delete": "Usuń książkę adresową", + "delete_title": "Usunąć 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", @@ -3497,7 +3508,7 @@ "edit": "Edytuj grupę", "empty": "Brak grup", "delete_confirm_title": "Usuń grupę", - "delete_confirm": "Czy na pewno chcesz usunąć tę grupę?", + "delete_message": "Grupa zostanie usunięta. Kontakty w niej pozostaną.", "name_label": "Nazwa grupy", "name_placeholder": "np. Zespół, Rodzina", "name_required": "Nazwa grupy jest wymagana", @@ -3539,13 +3550,13 @@ "selected": "{count, plural, one {1 zaznaczony} other {# zaznaczonych}}", "select_all": "Zaznacz wszystko", "delete": "Usuń", - "delete_confirm_title": "Usuń kontakty", "delete_confirm": "Usunąć {count, plural, one {1 kontakt} other {# kontaktów}}?", "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}}", + "moving_contacts": "Przenoszenie {count, plural, one {1 kontaktu} other {# kontaktów}}", "added_to_group": "Kontakty dodano do grupy", "export": "Eksportuj", "clear": "Wyczyść zaznaczenie" @@ -3776,6 +3787,10 @@ "recurrence_scope": { "edit_title": "Edytuj wydarzenie cykliczne", "delete_title": "Usuń wydarzenie cykliczne", + "delete_question": "Usunąć wydarzenie cykliczne?", + "delete_this_message": "Zostanie usunięte tylko to wystąpienie \"{title}\". Tej operacji nie można cofnąć.", + "delete_following_message": "Zostaną usunięte to i wszystkie następne wystąpienia \"{title}\". Tej operacji nie można cofnąć.", + "delete_all_message": "Zostaną usunięte wszystkie wystąpienia \"{title}\". Tej operacji nie można cofnąć.", "description": "To jest wydarzenie cykliczne. Które wydarzenia chcesz zmodyfikować?", "this_event": "Tylko to wydarzenie", "this_and_future": "To i następne wydarzenia", @@ -3982,13 +3997,14 @@ "color": "Kolor", "change_color": "Zmień kolor", "random_color": "Nowy losowy kolor", - "add_calendar": "Dodaj kalendarz", + "add_calendar": "Nowy kalendarz", "edit": "Edytuj", "delete": "Usuń", "save": "Zapisz", "create": "Utwórz", "cancel": "Anuluj", "default": "Domyślny", + "row_actions": "Działania dla {name}", "confirm_delete": "Usunąć \"{name}\"? Wszystkie wydarzenia w tym kalendarzu zostaną usunięte.", "confirm_clear": "Wyczyścić wszystkie wydarzenia z \"{name}\"? Tej operacji nie można cofnąć.", "set_default": "Ustaw jako domyślny", @@ -5044,7 +5060,7 @@ "loading": "Wczytywanie strony", "not_available_title": "Ta strona jest niedostępna", "not_available_description": "Wtyczka, która ją dodała, jest wyłączona lub nie jest już zainstalowana.", - "back_to_mail": "Wróć do poczty" + "back_to_mail": "Powrót do poczty" }, "command_palette": { "title": "Paleta poleceń", diff --git a/locales/pt/common.json b/locales/pt/common.json index dc97c9cc8..81d238d47 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1013,8 +1013,8 @@ "email_tagged": "E-mail etiquetado", "emails_tagged": "{count} e-mails etiquetados", "tag_failed": "Falha ao etiquetar", - "identity_created": "Identidade criada com sucesso", - "identity_updated": "Identidade atualizada com sucesso", + "identity_created": "Identidade criada", + "identity_updated": "Identidade atualizada", "identity_deleted": "Identidade excluída", "identity_set_primary": "Identidade principal atualizada", "identity_create_failed": "Falha ao criar identidade: {error}", @@ -1024,10 +1024,10 @@ "identity_not_found": "Identidade não encontrada", "vacation_saved": "Configurações de ausência salvas", "vacation_save_failed": "Falha ao salvar configurações de ausência", - "filters_saved": "Filtros salvos com sucesso", + "filters_saved": "Filtros salvos", "filters_save_failed": "Falha ao salvar os filtros", "filters_deleted": "Regra de filtragem eliminada", - "templates_exported": "Modelos exportados com sucesso", + "templates_exported": "Modelos exportados", "templates_imported": "{count, plural, one {# modelo importado} other {# modelos importados}}", "templates_import_errors": "Alguns modelos não puderam ser importados", "templates_import_empty": "Nenhum modelo encontrado no arquivo", @@ -1114,13 +1114,13 @@ }, "settings": { "title": "Configurações", - "back_to_mail": "Voltar para E-mails", + "back_to_mail": "Voltar para e-mails", "back_to_settings": "Voltar às definições", "save_success": "Configurações salvas com sucesso", "import_success": "Configurações importadas com sucesso", "import_error": "Falha ao importar configurações", "reset_confirm": "Tem certeza de que deseja redefinir todas as configurações para os padrões?", - "unsaved_changes": "Você tem alterações não salvas", + "unsaved_changes": "Suas alterações não foram salvas.", "discard_changes": "Descartar alterações?", "discard": "Descartar", "keep_editing": "Continuar editando", @@ -1352,6 +1352,8 @@ "id_exists": "Este ID de etiqueta já existe", "edit": "Editar etiqueta", "delete": "Eliminar etiqueta", + "edit_tag": "Editar {name}", + "delete_tag": "Excluir {name}", "deleted": "Etiqueta eliminada", "save": "Salvar", "add": "Adicionar", @@ -1941,7 +1943,7 @@ "app_passwords": { "title": "Senhas de aplicativo", "description": "Crie senhas para aplicativos que não suportam autenticação de dois fatores", - "add": "Adicionar", + "new": "Nova senha de aplicativo", "create": "Criar", "cancel": "Cancelar", "generate": "Gerar", @@ -1966,6 +1968,7 @@ "api_keys": { "title": "Chaves de API", "description": "Crie chaves de API para scripts e integrações que se comunicam diretamente com o servidor", + "new": "Nova chave de API", "name_label": "Nome da chave", "name_placeholder": "ex. Script de backup, CI runner", "copy_now_warning": "Copie esta chave de API agora - ela não será mostrada novamente.", @@ -2005,7 +2008,7 @@ "jmap_username_label": "Nome de Usuário JMAP", "copy": "Copiar", "copied": "Copiado", - "password_instructions": "Use seu nome de usuário JMAP acima junto com uma senha de aplicativo para entrar no seu cliente de e-mail. Crie uma senha de aplicativo na seção acima se ainda não tiver uma." + "password_instructions": "Use seu nome de usuário JMAP acima junto com uma senha de aplicativo para entrar no seu cliente de e-mail. Crie uma senha de aplicativo na seção abaixo se ainda não tiver uma." }, "link_device": { "title": "Vincular aplicativo móvel", @@ -2047,6 +2050,8 @@ "key_placeholder": "Cole aqui a sua chave pública", "none": "Nenhuma chave pública configurada", "title": "Chaves públicas", + "add": "Adicionar chave pública", + "add_submit": "Adicionar", "added": "Chave pública adicionada", "removed": "Chave pública eliminada", "add_error": "Não foi possível adicionar a chave pública", @@ -2071,7 +2076,7 @@ }, "manage": "Gerenciar Identidades", "sub_addressing": { - "label": "Sub-Endereçamento", + "label": "Subendereçamento", "description": "Use tags como usuario+tag@dominio.com para organizar e-mails recebidos", "learn_more": "Saiba Mais" } @@ -2248,7 +2253,7 @@ "description": "Exibir imagens de perfil e favicons dos remetentes na pasta de spam. Desativado por padrão para não dar aparência legítima a tentativas de phishing." }, "keyboard_shortcuts": { - "label": "Atalhos de Teclado", + "label": "Atalhos de teclado", "description": "Visualizar atalhos de teclado disponíveis", "button": "Ver Atalhos" }, @@ -2330,10 +2335,10 @@ "export_label": "Exportar contatos", "export_description": "Exportar todos os contatos como arquivo vCard (.vcf)", "manage_title": "Catálogos de endereços", - "manage_description": "Renomeie seus catálogos de endereços", + "manage_description": "Renomeie ou exclua seus catálogos de endereços", "no_address_books": "Nenhum catálogo de endereços encontrado", "categories_title": "Categorias", - "categories_description": "Renomear categorias de contatos", + "categories_description": "Renomear ou excluir categorias de contatos", "no_categories": "Nenhuma categoria encontrada", "group_by_letter_description": "Mostrar cabeçalhos de seção alfabéticos na lista de contatos", "group_by_letter_label": "Agrupar pela primeira letra", @@ -2411,7 +2416,7 @@ "validation_error": "O script contém erros", "save": "Salvar", "saving": "Salvando...", - "saved": "Filtros salvos com sucesso", + "saved": "Filtros salvos", "save_failed": "Falha ao salvar os filtros", "loading": "Carregando filtros...", "not_supported": "Seu servidor de e-mail não suporta filtros de e-mail.", @@ -2479,6 +2484,7 @@ "label_name": "Nome da etiqueta", "label_placeholder": "Selecionar etiqueta", "header_name": "Nome do cabeçalho", + "condition_value": "Valor da condição", "header_placeholder": "ex. X-Mailing-List", "size_bytes": "Tamanho em bytes", "size_placeholder": "ex. 1000000", @@ -2506,6 +2512,10 @@ "validation_empty_name": "O nome da regra é obrigatório", "validation_empty_conditions": "É necessária pelo menos uma condição com um valor", "validation_empty_actions": "É necessária pelo menos uma ação", + "validation_action_folder": "Selecione uma pasta para esta ação", + "validation_action_tag": "Selecione uma etiqueta para esta ação", + "validation_action_forward": "Digite um endereço para encaminhar", + "validation_action_reject": "Digite a mensagem de rejeição", "validation_header_name": "Os nomes de cabeçalho só podem conter as letras A-Z, algarismos, hífenes e sublinhados", "templates_section": "Começar com modelo", "template_newsletters": "Mover newsletters para pasta", @@ -2518,12 +2528,12 @@ "script_content": "Script Sieve", "valid": "O script é válido", "invalid": "O script contém erros", - "save_warning": "Guardar irá substituir todas as regras visuais. Esta ação não pode ser desfeita. Clique em Guardar novamente para confirmar.", "validating": "A validar...", "validate": "Validar", "cancel": "Cancelar", "save": "Guardar", - "confirm_save": "Confirmar guardar", + "overwrite_title": "Substituir as regras visuais?", + "overwrite_message": "Este script não contém mais as regras criadas no editor visual. Ao salvá-lo, elas serão substituídas, e isso não pode ser desfeito.", "validation_failed": "Falha no pedido de validação" }, "rule_summary": { @@ -3022,7 +3032,7 @@ "rename_confirm": "Renomear" }, "shortcuts": { - "title": "Atalhos de Teclado", + "title": "Atalhos de teclado", "tip": "Pressione ? a qualquer momento para mostrar esta ajuda", "or": "ou", "sections": { @@ -3258,8 +3268,8 @@ "empty_filtered_hint": "Ajuste ou limpe os filtros", "clear_search": "Limpar pesquisa", "import_vcard": "Importar vCard", - "delete_confirm_title": "Excluir contato", - "delete_confirm": "Tem certeza de que deseja excluir este contato?", + "delete_named_title": "Excluir {name}?", + "delete_details_message": "Endereços de e-mail, telefones, notas e todos os outros dados serão excluídos. Não é possível desfazer.", "local_mode": "Os contatos são armazenados localmente (o servidor não suporta JMAP Contacts)", "back_to_contacts": "Voltar aos contatos", "back_to_email": "Voltar ao e-mail", @@ -3293,6 +3303,7 @@ "share": "Compartilhar lista de contatos", "new_contact_in_book": "Novo contato nesta lista", "delete": "Excluir lista de contatos", + "delete_title": "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", @@ -3497,7 +3508,7 @@ "edit": "Editar grupo", "empty": "Nenhum grupo", "delete_confirm_title": "Excluir grupo", - "delete_confirm": "Tem certeza de que deseja excluir este grupo?", + "delete_message": "O grupo será excluído. Os contatos nele são mantidos.", "name_label": "Nome do grupo", "name_placeholder": "ex. Equipe, Família", "name_required": "O nome do grupo é obrigatório", @@ -3539,13 +3550,13 @@ "selected": "{count, plural, one {1 selecionado} other {# selecionados}}", "select_all": "Selecionar tudo", "delete": "Excluir", - "delete_confirm_title": "Excluir contatos", "delete_confirm": "Excluir {count, plural, one {1 contato} other {# contatos}}?", "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}}", + "moving_contacts": "Movendo {count, plural, one {1 contato} other {# contatos}}", "added_to_group": "Contatos adicionados ao grupo", "export": "Exportar", "clear": "Limpar seleção" @@ -3776,6 +3787,10 @@ "recurrence_scope": { "edit_title": "Editar evento recorrente", "delete_title": "Excluir evento recorrente", + "delete_question": "Excluir evento recorrente?", + "delete_this_message": "Somente esta ocorrência de \"{title}\" será excluída. Isso não pode ser desfeito.", + "delete_following_message": "Esta e todas as ocorrências seguintes de \"{title}\" serão excluídas. Isso não pode ser desfeito.", + "delete_all_message": "Todas as ocorrências de \"{title}\" serão excluídas. Isso não pode ser desfeito.", "description": "Este é um evento recorrente. Quais eventos você deseja modificar?", "this_event": "Apenas este evento", "this_and_future": "Este evento e os seguintes", @@ -4008,13 +4023,14 @@ "color": "Cor", "change_color": "Alterar cor", "random_color": "Nova cor aleatória", - "add_calendar": "Adicionar calendário", + "add_calendar": "Novo calendário", "edit": "Editar", "delete": "Excluir", "save": "Salvar", "create": "Criar", "cancel": "Cancelar", "default": "Padrão", + "row_actions": "Ações para {name}", "confirm_delete": "Excluir \"{name}\"? Todos os eventos neste calendário serão removidos.", "calendar_created": "Calendário criado", "calendar_updated": "Calendário atualizado", @@ -5044,7 +5060,7 @@ "loading": "Carregando a página", "not_available_title": "Esta página não está disponível", "not_available_description": "O plugin que a adicionou está desativado ou não está mais instalado.", - "back_to_mail": "Voltar ao e-mail" + "back_to_mail": "Voltar para e-mails" }, "command_palette": { "title": "Paleta de comandos", diff --git a/locales/ro/common.json b/locales/ro/common.json index e778bb4cc..ad9f6a5bb 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -1013,8 +1013,8 @@ "email_tagged": "E-mail etichetat", "emails_tagged": "{count} e-mailuri etichetate", "tag_failed": "Etichetarea a eșuat", - "identity_created": "Identitatea a fost creată cu succes", - "identity_updated": "Identitatea a fost actualizată cu succes", + "identity_created": "Identitatea a fost creată", + "identity_updated": "Identitatea a fost actualizată", "identity_deleted": "Identitate ștearsă", "identity_set_primary": "Identitate principală actualizată", "identity_create_failed": "Nu s-a putut crea identitatea: {error}", @@ -1024,10 +1024,10 @@ "identity_not_found": "Identitate neidentificată", "vacation_saved": "Setările de absență au fost salvate", "vacation_save_failed": "Nu s-au putut salva setările de absență", - "filters_saved": "Filtrele au fost salvate cu succes", + "filters_saved": "Filtrele au fost salvate", "filters_save_failed": "Nu s-au putut salva filtrele", "filters_deleted": "Regula de filtrare a fost ștersă", - "templates_exported": "Șabloanele au fost exportate cu succes", + "templates_exported": "Șabloanele au fost exportate", "templates_imported": "{count, plural, one {# șablon} few {# șabloane} other {# de șabloane}} importat", "templates_import_errors": "Unele șabloane nu au putut fi importate", "templates_import_empty": "Nu s-au găsit șabloane în fișier", @@ -1114,15 +1114,15 @@ }, "settings": { "title": "Setări", - "back_to_mail": "Înapoi la Mail", + "back_to_mail": "Înapoi la e-mail", "back_to_settings": "Înapoi la setări", "save_success": "Setările au fost salvate cu succes", "import_success": "Setările au fost importate cu succes", "import_error": "Nu s-au putut importa setările", "reset_confirm": "Sunteți sigur că doriți să resetați toate setările la valorile implicite?", - "unsaved_changes": "Aveți modificări nesalvate", - "discard_changes": "Doriți să renunțați la modificările nesalvate?", - "discard": "Ignorați", + "unsaved_changes": "Modificările dvs. nu au fost salvate.", + "discard_changes": "Renunțați la modificări?", + "discard": "Renunțați", "keep_editing": "Continuați editarea", "search_placeholder": "Setări de căutare", "search_clear": "Șterge căutarea", @@ -1355,6 +1355,8 @@ "id_exists": "Această etichetă ID există deja", "edit": "Editați eticheta", "delete": "Șterge eticheta", + "edit_tag": "Editați {name}", + "delete_tag": "Ștergeți {name}", "deleted": "Eticheta a fost ștearsă", "save": "Salvare", "add": "Adăugați", @@ -1948,7 +1950,7 @@ "app_passwords": { "title": "Parole pentru aplicații", "description": "Creați parole pentru aplicațiile care nu acceptă autentificarea în doi pași", - "add": "Adăugați", + "new": "Parolă nouă pentru aplicație", "create": "Creați", "cancel": "Anulează", "done": "Gata", @@ -1973,6 +1975,7 @@ "api_keys": { "title": "API Chei", "description": "Creați chei de tip „API” pentru scripturi și integrări care comunică direct cu serverul", + "new": "Cheie API nouă", "name_label": "Numele cheii", "name_placeholder": "de ex. script de backup, CI runner", "copy_now_warning": "Copiați acum această cheie „API” – nu va mai fi afișată din nou.", @@ -2012,7 +2015,7 @@ "jmap_username_label": "JMAP Nume de utilizator", "copy": "Copiere", "copied": "Copiat", - "password_instructions": "Folosiți numele de utilizator de la JMAP menționat mai sus, împreună cu o parolă de aplicație, pentru a vă autentifica în clientul de e-mail. Creați o parolă de aplicație în secțiunea de mai sus, dacă nu ați făcut-o deja." + "password_instructions": "Folosiți numele de utilizator de la JMAP menționat mai sus, împreună cu o parolă de aplicație, pentru a vă autentifica în clientul de e-mail. Creați o parolă de aplicație în secțiunea de mai jos, dacă nu ați făcut-o deja." }, "link_device": { "title": "Aplicație mobilă", @@ -2054,6 +2057,8 @@ "key_placeholder": "Lipiți aici cheia dvs. publică", "none": "Nicio cheie publică configurată", "title": "Chei publice", + "add": "Adăugați cheie publică", + "add_submit": "Adăugați", "added": "Cheie publică adăugată", "removed": "Cheie publică ștearsă", "add_error": "Cheia publică nu a putut fi adăugată", @@ -2341,10 +2346,10 @@ "export_label": "Exportați contactele", "export_description": "Exportați toate contactele ca fișier VCF (vCard).", "manage_title": "Agende de adrese", - "manage_description": "Redenumiți agendele de adrese", + "manage_description": "Redenumiți sau ștergeți agendele de adrese", "no_address_books": "Nu s-au găsit agende de adrese", "categories_title": "Categorii", - "categories_description": "Redenumiți categoriile de contacte", + "categories_description": "Redenumiți sau ștergeți categoriile de contacte", "no_categories": "Nu s-au găsit categorii" }, "calendar_management": { @@ -2418,7 +2423,7 @@ "validation_error": "Scriptul conține erori", "save": "Salvare", "saving": "Se salvează...", - "saved": "Filtrele au fost salvate cu succes", + "saved": "Filtrele au fost salvate", "save_failed": "Nu s-au putut salva filtrele", "loading": "Se încarcă filtrele...", "not_supported": "Serverul dvs. de e-mail nu acceptă filtre de e-mail.", @@ -2486,6 +2491,7 @@ "label_name": "Numele etichetei", "label_placeholder": "Selectați eticheta", "header_name": "Numele antetului", + "condition_value": "Valoarea condiției", "header_placeholder": "de ex., X-Mailing-List", "size_bytes": "Dimensiune în octeți", "size_placeholder": "de exemplu, 1000000", @@ -2513,6 +2519,10 @@ "validation_empty_name": "Numele regulii este obligatoriu", "validation_empty_conditions": "Este necesară cel puțin o condiție cu o valoare", "validation_empty_actions": "Este necesară cel puțin o acțiune", + "validation_action_folder": "Selectați un dosar pentru această acțiune", + "validation_action_tag": "Selectați o etichetă pentru această acțiune", + "validation_action_forward": "Introduceți o adresă către care să se redirecționeze", + "validation_action_reject": "Introduceți mesajul de respingere", "validation_header_name": "Numele antetelor pot conține doar literele A-Z, cifre, cratime și liniuțe de subliniere", "templates_section": "Începeți de la șablon", "template_newsletters": "Mutați buletinele informative în dosar", @@ -2525,12 +2535,12 @@ "script_content": "Sieve script", "valid": "Scriptul este valid", "invalid": "Scriptul conține erori", - "save_warning": "Salvarea va suprascrie orice reguli vizuale. Această acțiune nu poate fi anulată. Faceți clic din nou pe Salvare pentru a confirma.", "validating": "Se validează...", "validate": "Validați", "cancel": "Anulează", "save": "Salvare", - "confirm_save": "Confirmare Salvare", + "overwrite_title": "Înlocuiți regulile vizuale?", + "overwrite_message": "Acest script nu mai conține regulile create în editorul vizual. Salvarea lui le înlocuiește și nu poate fi anulată.", "validation_failed": "Solicitarea de validare a eșuat" }, "rule_summary": { @@ -3258,8 +3268,8 @@ "empty_filtered_hint": "Încercați să modificați sau să ștergeți filtrele", "clear_search": "Șterge căutarea", "import_vcard": "ImportvCard", - "delete_confirm_title": "Șterge contactul", - "delete_confirm": "Sunteți sigur că doriți să ștergeți acest contact?", + "delete_named_title": "Șterge {name}?", + "delete_details_message": "Adresele de e-mail, numerele de telefon, notițele și toate celelalte detalii vor fi șterse. Acțiunea nu poate fi anulată.", "local_mode": "Contactele sunt stocate local (serverul nu acceptă contactele dJMAP)", "back_to_contacts": "Înapoi la contacte", "back_to_email": "Înapoi la e-mail", @@ -3295,6 +3305,7 @@ "share": "Partajați agenda de adrese", "new_contact_in_book": "Contact nou în această agendă", "delete": "Șterge agenda de adrese", + "delete_title": "Ștergeți 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", @@ -3499,7 +3510,7 @@ "edit": "Editare grup", "empty": "Nu există încă grupuri", "delete_confirm_title": "Șterge grupul", - "delete_confirm": "Sunteți sigur că doriți să ștergeți acest grup?", + "delete_message": "Grupul va fi șters. Contactele din el se păstrează.", "name_label": "Numele grupului", "name_placeholder": "de exemplu, Echipa, Familia", "name_required": "Numele grupului este obligatoriu", @@ -3541,13 +3552,13 @@ "selected": "{count, plural, one {1 selectat} few {# selectat} other {# de selectat}}", "select_all": "Selectează tot", "delete": "Șterge", - "delete_confirm_title": "Ștergeți contactele", "delete_confirm": "Șterge {count, plural, one {1 contact} few {# contacte} other {# de contacte}}?", "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}}", + "moving_contacts": "Mutare {count, plural, one {1 contact} few {# contacte} other {# de contacte}}", "added_to_group": "Contacte adăugate la grup", "export": "Export", "clear": "Șterge selecția" @@ -3776,6 +3787,10 @@ "recurrence_scope": { "edit_title": "Editați evenimentul recurent", "delete_title": "Șterge evenimentul recurent", + "delete_question": "Ștergeți evenimentul recurent?", + "delete_this_message": "Va fi ștearsă doar această apariție a „{title}”. Acțiunea nu poate fi anulată.", + "delete_following_message": "Vor fi șterse această apariție și toate cele următoare ale „{title}”. Acțiunea nu poate fi anulată.", + "delete_all_message": "Vor fi șterse toate aparițiile „{title}”. Acțiunea nu poate fi anulată.", "description": "Acesta este un eveniment recurent. Ce evenimente doriți să modificați?", "this_event": "Numai acest eveniment", "this_and_future": "Acest eveniment și evenimentele următoare", @@ -4011,13 +4026,14 @@ "color": "Culoare", "change_color": "Schimbă culoarea", "random_color": "Culoare aleatorie nouă", - "add_calendar": "Adăugați calendar", + "add_calendar": "Calendar nou", "edit": "Editare", "delete": "Șterge", "save": "Salvare", "create": "Creați", "cancel": "Anulează", "default": "Implicit", + "row_actions": "Acțiuni pentru {name}", "confirm_delete": "Doriți să ștergeți „{name}”? Toate evenimentele din acest calendar vor fi eliminate.", "confirm_clear": "Ștergeți toate evenimentele din „{name}”? Această acțiune nu poate fi anulată.", "set_default": "Setați ca implicită", diff --git a/locales/ru/common.json b/locales/ru/common.json index 325197823..dee1315a5 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1013,8 +1013,8 @@ "email_tagged": "Письмо помечено тегом", "emails_tagged": "{count} писем помечено тегом", "tag_failed": "Ошибка тегирования", - "identity_created": "Удостоверение успешно создано", - "identity_updated": "Удостоверение успешно обновлено", + "identity_created": "Удостоверение создано", + "identity_updated": "Удостоверение обновлено", "identity_deleted": "Удостоверение удалено", "identity_set_primary": "Основное удостоверение обновлено", "identity_create_failed": "Не удалось создать удостоверение: {error}", @@ -1024,10 +1024,10 @@ "identity_not_found": "Удостоверение не найдено", "vacation_saved": "Настройки отсутствия сохранены", "vacation_save_failed": "Не удалось сохранить настройки отсутствия", - "filters_saved": "Фильтры успешно сохранены", + "filters_saved": "Фильтры сохранены", "filters_save_failed": "Не удалось сохранить фильтры", "filters_deleted": "Правило фильтра удалено", - "templates_exported": "Шаблоны успешно экспортированы", + "templates_exported": "Шаблоны экспортированы", "templates_imported": "{count, plural, one {# шаблон} other {# шаблонов}} импортировано", "templates_import_errors": "Некоторые шаблоны не удалось импортировать", "templates_import_empty": "В файле не найдено шаблонов", @@ -1120,9 +1120,9 @@ "import_success": "Настройки успешно импортированы", "import_error": "Не удалось импортировать настройки", "reset_confirm": "Вы уверены, что хотите сбросить все настройки до значений по умолчанию?", - "unsaved_changes": "У вас есть несохранённые изменения", - "discard_changes": "Отменить несохранённые изменения?", - "discard": "Отменить", + "unsaved_changes": "Ваши изменения не сохранены.", + "discard_changes": "Отменить изменения?", + "discard": "Отменить изменения", "keep_editing": "Продолжить редактирование", "tabs": { "appearance": "Внешний вид", @@ -1352,6 +1352,8 @@ "id_exists": "Этот идентификатор тега уже существует", "edit": "Редактировать тег", "delete": "Удалить тег", + "edit_tag": "Изменить «{name}»", + "delete_tag": "Удалить «{name}»", "deleted": "Тег удалён", "save": "Сохранить", "add": "Добавить", @@ -1941,7 +1943,7 @@ "app_passwords": { "title": "Пароли приложений", "description": "Создавайте пароли для приложений, не поддерживающих двухфакторную аутентификацию", - "add": "Добавить", + "new": "Новый пароль приложения", "create": "Создать", "cancel": "Отмена", "generate": "Сгенерировать", @@ -1966,6 +1968,7 @@ "api_keys": { "title": "API-ключи", "description": "Создавайте API-ключи для скриптов и интеграций, обращающихся к серверу напрямую", + "new": "Новый API-ключ", "name_label": "Название ключа", "name_placeholder": "напр. Скрипт резервного копирования, CI runner", "copy_now_warning": "Скопируйте этот API-ключ сейчас – он больше не будет показан.", @@ -2005,7 +2008,7 @@ "jmap_username_label": "Имя пользователя JMAP", "copy": "Копировать", "copied": "Скопировано", - "password_instructions": "Используйте указанное выше имя пользователя JMAP вместе с паролем приложения для входа в почтовый клиент. Создайте пароль приложения в разделе выше, если вы ещё этого не сделали." + "password_instructions": "Используйте указанное выше имя пользователя JMAP вместе с паролем приложения для входа в почтовый клиент. Создайте пароль приложения в разделе ниже, если вы ещё этого не сделали." }, "link_device": { "title": "Привязать мобильное приложение", @@ -2047,6 +2050,8 @@ "key_placeholder": "Вставьте сюда свой открытый ключ", "none": "Открытые ключи не настроены", "title": "Открытые ключи", + "add": "Добавить открытый ключ", + "add_submit": "Добавить", "added": "Открытый ключ добавлен", "removed": "Открытый ключ удалён", "add_error": "Не удалось добавить открытый ключ", @@ -2330,10 +2335,10 @@ "export_label": "Экспорт контактов", "export_description": "Экспортировать все контакты в виде файла vCard (.vcf)", "manage_title": "Адресные книги", - "manage_description": "Переименуйте ваши адресные книги", + "manage_description": "Переименуйте или удалите ваши адресные книги", "no_address_books": "Адресные книги не найдены", "categories_title": "Категории", - "categories_description": "Переименование категорий контактов", + "categories_description": "Переименование и удаление категорий контактов", "no_categories": "Категории не найдены", "group_by_letter_description": "Показывать алфавитные заголовки разделов в списке контактов", "group_by_letter_label": "Группировать по первой букве", @@ -2411,7 +2416,7 @@ "validation_error": "Скрипт содержит ошибки", "save": "Сохранить", "saving": "Сохранение...", - "saved": "Фильтры успешно сохранены", + "saved": "Фильтры сохранены", "save_failed": "Не удалось сохранить фильтры", "loading": "Загрузка фильтров...", "not_supported": "Ваш почтовый сервер не поддерживает фильтры.", @@ -2479,6 +2484,7 @@ "label_name": "Название тега", "label_placeholder": "Выбрать тег", "header_name": "Имя заголовка", + "condition_value": "Значение условия", "header_placeholder": "напр., X-Mailing-List", "size_bytes": "Размер в байтах", "size_placeholder": "напр., 1000000", @@ -2506,6 +2512,10 @@ "validation_empty_name": "Требуется название правила", "validation_empty_conditions": "Требуется хотя бы одно условие со значением", "validation_empty_actions": "Требуется хотя бы одно действие", + "validation_action_folder": "Выберите папку для этого действия", + "validation_action_tag": "Выберите метку для этого действия", + "validation_action_forward": "Введите адрес для пересылки", + "validation_action_reject": "Введите сообщение об отклонении", "validation_header_name": "Имя заголовка может содержать только буквы A-Z, цифры, дефисы и подчёркивания", "templates_section": "Начать с шаблона", "template_newsletters": "Перемещать рассылки в папку", @@ -2518,12 +2528,12 @@ "script_content": "Sieve-скрипт", "valid": "Скрипт корректен", "invalid": "Скрипт содержит ошибки", - "save_warning": "Сохранение перезапишет визуальные правила. Это нельзя отменить. Нажмите «Сохранить» ещё раз для подтверждения.", "validating": "Проверка...", "validate": "Проверить", "cancel": "Отмена", "save": "Сохранить", - "confirm_save": "Подтвердить сохранение", + "overwrite_title": "Заменить визуальные правила?", + "overwrite_message": "В этом скрипте больше нет правил, созданных в визуальном редакторе. При сохранении они будут заменены, и это нельзя отменить.", "validation_failed": "Ошибка запроса проверки" }, "rule_summary": { @@ -3258,8 +3268,8 @@ "empty_filtered_hint": "Измените или очистите фильтры", "clear_search": "Очистить поиск", "import_vcard": "Импорт vCard", - "delete_confirm_title": "Удалить контакт", - "delete_confirm": "Вы уверены, что хотите удалить этот контакт?", + "delete_named_title": "Удалить {name}?", + "delete_details_message": "Адреса электронной почты, номера телефонов, заметки и все остальные данные будут удалены. Это действие нельзя отменить.", "local_mode": "Контакты хранятся локально (сервер не поддерживает JMAP Contacts)", "back_to_contacts": "Вернуться к контактам", "back_to_email": "Вернуться к письму", @@ -3293,6 +3303,7 @@ "share": "Поделиться адресной книгой", "new_contact_in_book": "Новый контакт в этой адресной книге", "delete": "Удалить адресную книгу", + "delete_title": "Удалить адресную книгу?", "confirm_delete": "Удалить «{name}»? Все контакты в этой адресной книге будут удалены.", "deleted": "Адресная книга удалена", "delete_failed": "Не удалось удалить адресную книгу", @@ -3471,7 +3482,7 @@ "cancel": "Отмена", "discard_title": "Отменить изменения?", "discard_message": "Ваши изменения не сохранены.", - "discard": "Отменить", + "discard": "Отменить изменения", "keep_editing": "Продолжить редактирование", "remove_entry": "Убрать", "close": "Закрыть", @@ -3497,7 +3508,7 @@ "edit": "Редактировать группу", "empty": "Групп пока нет", "delete_confirm_title": "Удалить группу", - "delete_confirm": "Вы уверены, что хотите удалить эту группу?", + "delete_message": "Группа будет удалена. Контакты из неё сохранятся.", "name_label": "Название группы", "name_placeholder": "напр., Команда, Семья", "name_required": "Название группы обязательно", @@ -3539,13 +3550,13 @@ "selected": "{count, plural, one {1 выбран} other {# выбрано}}", "select_all": "Выбрать все", "delete": "Удалить", - "delete_confirm_title": "Удалить контакты", "delete_confirm": "Удалить {count, plural, one {1 контакт} other {# контактов}}?", "deleted": "{count, plural, one {1 контакт удалён} other {# контактов удалено}}", "add_to_group": "Добавить в группу", "choose_group": "Выбрать группу", "new_group": "Новая группа…", "adding_contacts": "Добавляем {count, plural, one {1 контакт} other {# контактов}}", + "moving_contacts": "Перемещение {count, plural, one {# контакта} other {# контактов}}", "added_to_group": "Контакты добавлены в группу", "export": "Экспорт", "clear": "Очистить выбор" @@ -3776,6 +3787,10 @@ "recurrence_scope": { "edit_title": "Редактировать повторяющееся событие", "delete_title": "Удалить повторяющееся событие", + "delete_question": "Удалить повторяющееся событие?", + "delete_this_message": "Будет удалено только это повторение «{title}». Это действие нельзя отменить.", + "delete_following_message": "Будут удалены это и все следующие повторения «{title}». Это действие нельзя отменить.", + "delete_all_message": "Будут удалены все повторения «{title}». Это действие нельзя отменить.", "description": "Это повторяющееся событие. Какие события вы хотите изменить?", "this_event": "Только это событие", "this_and_future": "Это и последующие события", @@ -3982,13 +3997,14 @@ "color": "Цвет", "change_color": "Изменить цвет", "random_color": "Новый случайный цвет", - "add_calendar": "Добавить календарь", + "add_calendar": "Новый календарь", "edit": "Редактировать", "delete": "Удалить", "save": "Сохранить", "create": "Создать", "cancel": "Отмена", "default": "По умолчанию", + "row_actions": "Действия для «{name}»", "confirm_delete": "Удалить «{name}»? Все события в этом календаре будут удалены.", "confirm_clear": "Очистить все события из «{name}»? Это действие нельзя отменить.", "set_default": "Сделать по умолчанию", diff --git a/locales/sk/common.json b/locales/sk/common.json index 829a19768..fe9108bce 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -1013,8 +1013,8 @@ "email_tagged": "E-mail bol označený štítkom", "emails_tagged": "{count} e-mailov bolo označených štítkom", "tag_failed": "Označovanie štítkom zlyhalo", - "identity_created": "Identita bola úspešne vytvorená", - "identity_updated": "Identita bola úspešne aktualizovaná", + "identity_created": "Identita bola vytvorená", + "identity_updated": "Identita bola aktualizovaná", "identity_deleted": "Identita bola odstránená", "identity_set_primary": "Hlavná identita bola aktualizovaná", "identity_create_failed": "Vytvorenie identity zlyhalo: {error}", @@ -1024,10 +1024,10 @@ "identity_not_found": "Identita sa nenašla", "vacation_saved": "Nastavenia neprítomnosti boli uložené", "vacation_save_failed": "Uloženie nastavení neprítomnosti zlyhalo", - "filters_saved": "Filtre boli úspešne uložené", + "filters_saved": "Filtre uložené", "filters_save_failed": "Uloženie filtrov zlyhalo", "filters_deleted": "Pravidlo filtra bolo odstránené", - "templates_exported": "Šablóny boli úspešne exportované", + "templates_exported": "Šablóny boli exportované", "templates_imported": "{count, plural, one {# šablóna} other {# šablón}} importovaných", "templates_import_errors": "Niektoré šablóny sa nepodarilo importovať", "templates_import_empty": "V súbore neboli nájdené žiadne šablóny", @@ -1120,9 +1120,9 @@ "import_success": "Nastavenia boli úspešne importované", "import_error": "Import nastavení zlyhal", "reset_confirm": "Naozaj chcete obnoviť všetky nastavenia na predvolené?", - "unsaved_changes": "Máte neuložené zmeny", - "discard_changes": "Zahoditť neuložené zmeny?", - "discard": "Zahoditť", + "unsaved_changes": "Vaše zmeny neboli uložené.", + "discard_changes": "Zahodiť zmeny?", + "discard": "Zahodiť", "keep_editing": "Pokračovať v úprave", "search_placeholder": "Hľadať nastavenia", "search_clear": "Vymazať hľadanie", @@ -1355,6 +1355,8 @@ "id_exists": "Toto ID štítku už existuje", "edit": "Upraviť štítok", "delete": "Odstrániť štítok", + "edit_tag": "Upraviť {name}", + "delete_tag": "Zmazať {name}", "deleted": "Štítok bol odstránený", "save": "Uložiť", "add": "Pridať", @@ -1948,7 +1950,7 @@ "app_passwords": { "title": "Heslá aplikácií", "description": "Vytvorte heslá pre aplikácie, ktoré nepodporujú dvojfaktorové overenie", - "add": "Pridať", + "new": "Nové heslo aplikácie", "create": "Vytvoriť", "cancel": "Zrušiť", "done": "Hotovo", @@ -1973,6 +1975,7 @@ "api_keys": { "title": "API kľúče", "description": "Vytvorte API kľúče pre skripty a integrácie.", + "new": "Nový API kľúč", "name_label": "Názov kľúča", "name_placeholder": "napr. Zálohovací skript, CI runner", "copy_now_warning": "Skopírujte tento API kľúč hneď – už sa znova nezobrazí.", @@ -2012,7 +2015,7 @@ "jmap_username_label": "Používateľské meno JMAP", "copy": "Kopírovať", "copied": "Skopírované", - "password_instructions": "Použite vyššie uvedené používateľské meno JMAP spolu s heslom aplikácie na prihlásenie v poštovom klientovi." + "password_instructions": "Použite vyššie uvedené používateľské meno JMAP spolu s heslom aplikácie na prihlásenie v poštovom klientovi. Ak ste tak ešte neurobili, vytvorte si heslo aplikácie v sekcii nižšie." }, "link_device": { "title": "Prepojiť mobilnú aplikáciu", @@ -2054,6 +2057,8 @@ "key_placeholder": "Sem vložte svoj verejný kľúč", "none": "Nie sú nastavené žiadne verejné kľúče", "title": "Verejné kľúče", + "add": "Pridať verejný kľúč", + "add_submit": "Pridať", "added": "Verejný kľúč bol pridaný", "removed": "Verejný kľúč bol odstránený", "add_error": "Verejný kľúč sa nepodarilo pridať", @@ -2341,10 +2346,10 @@ "export_label": "Exportovať kontakty", "export_description": "Exportovať všetky kontakty ako súbor vCard (.vcf)", "manage_title": "Adresáre", - "manage_description": "Premenovať svoje adresáre", + "manage_description": "Premenovať alebo zmazať svoje adresáre", "no_address_books": "Neboli nájdené žiadne adresáre", "categories_title": "Kategórie", - "categories_description": "Premenovať kategórie kontaktov", + "categories_description": "Premenovať alebo zmazať kategórie kontaktov", "no_categories": "Neboli nájdené žiadne kategórie" }, "calendar_management": { @@ -2418,7 +2423,7 @@ "validation_error": "Skript obsahuje chyby", "save": "Uložiť", "saving": "Ukladanie...", - "saved": "Filtre boli uložené", + "saved": "Filtre uložené", "save_failed": "Uloženie filtrov zlyhalo", "loading": "Načítavanie filtrov...", "not_supported": "Váš poštový server nepodporuje e-mailové filtre.", @@ -2486,6 +2491,7 @@ "label_name": "Názov štítku", "label_placeholder": "Vybrať štítok", "header_name": "Názov hlavičky", + "condition_value": "Hodnota podmienky", "header_placeholder": "napr. X-Mailing-List", "size_bytes": "Veľkosť v bajtoch", "size_placeholder": "napr. 1000000", @@ -2513,6 +2519,10 @@ "validation_empty_name": "Názov pravidla je povinný", "validation_empty_conditions": "Je potrebná aspoň jedna podmienka s hodnotou", "validation_empty_actions": "Je potrebná aspoň jedna akcia", + "validation_action_folder": "Vyberte priečinok pre túto akciu", + "validation_action_tag": "Vyberte štítok pre túto akciu", + "validation_action_forward": "Zadajte adresu, na ktorú sa má preposielať", + "validation_action_reject": "Zadajte správu o odmietnutí", "validation_header_name": "Názvy hlavičiek môžu obsahovať iba písmená A-Z, číslice, spojovníky a podčiarkovníky", "templates_section": "Začať zo šablóny", "template_newsletters": "Presunúť newslettery do priečinka", @@ -2525,12 +2535,12 @@ "script_content": "Skript Sieve", "valid": "Skript je platný", "invalid": "Skript obsahuje chyby", - "save_warning": "Uloženie prepíše všetky vizuálne pravidlá. Túto akciu nie je možné vrátiť. Potvrďte opätovným kliknutím na Uložiť.", "validating": "Overovanie...", "validate": "Overiť", "cancel": "Zrušiť", "save": "Uložiť", - "confirm_save": "Potvrdiť uloženie", + "overwrite_title": "Nahradiť vizuálne pravidlá?", + "overwrite_message": "Tento skript už neobsahuje pravidlá, ktoré ste vytvorili vo vizuálnom editore. Uložením ich nahradíte a nedá sa to vrátiť späť.", "validation_failed": "Požiadavka na overenie zlyhala" }, "rule_summary": { @@ -3258,8 +3268,8 @@ "empty_filtered_hint": "Skúste filtre upraviť alebo zrušiť", "clear_search": "Vymazať hľadanie", "import_vcard": "Importovať vCard", - "delete_confirm_title": "Odstrániť kontakt", - "delete_confirm": "Naozaj chcete odstrániť tento kontakt?", + "delete_named_title": "Odstrániť {name}?", + "delete_details_message": "Odstránia sa e-mailové adresy, telefónne čísla, poznámky a všetky ďalšie údaje. Túto akciu nie je možné vrátiť.", "local_mode": "Kontakty sú uložené lokálne (server nepodporuje JMAP Contacts)", "back_to_contacts": "Späť na kontakty", "back_to_email": "Späť na e-mail", @@ -3295,6 +3305,7 @@ "share": "Zdieľať adresár", "new_contact_in_book": "Nový kontakt v tomto adresári", "delete": "Zmazať adresár", + "delete_title": "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ť", @@ -3474,7 +3485,7 @@ "discard_title": "Zahodiť zmeny?", "discard_message": "Vaše zmeny neboli uložené.", "discard": "Zahodiť", - "keep_editing": "Pokračovať v úpravách", + "keep_editing": "Pokračovať v úprave", "remove_entry": "Odstrániť", "close": "Zavrieť", "remove_category": "Odstrániť {name}", @@ -3499,7 +3510,7 @@ "edit": "Upraviť skupinu", "empty": "Žiadne skupiny", "delete_confirm_title": "Odstrániť skupinu", - "delete_confirm": "Naozaj chcete odstrániť túto skupinu?", + "delete_message": "Skupina sa odstráni. Kontakty v nej zostanú zachované.", "name_label": "Názov skupiny", "name_placeholder": "napr. Tím, Rodina", "name_required": "Názov skupiny je povinný", @@ -3541,13 +3552,13 @@ "selected": "{count, plural, one {1 vybraný} other {# vybraných}}", "select_all": "Vybrať všetko", "delete": "Odstrániť", - "delete_confirm_title": "Odstrániť kontakty", "delete_confirm": "Odstrániť {count, plural, one {1 kontakt} other {# kontaktov}}?", "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}}", + "moving_contacts": "Presúvanie {count, plural, one {1 kontaktu} other {# kontaktov}}", "added_to_group": "Kontakty boli pridané do skupiny", "export": "Exportovať", "clear": "Zrušiť výber" @@ -3776,6 +3787,10 @@ "recurrence_scope": { "edit_title": "Upraviť opakujúcu sa udalosť", "delete_title": "Odstrániť opakujúcu sa udalosť", + "delete_question": "Odstrániť opakujúcu sa udalosť?", + "delete_this_message": "Odstráni sa iba tento výskyt udalosti \"{title}\". Túto akciu nemožno vrátiť.", + "delete_following_message": "Odstráni sa tento a všetky ďalšie výskyty udalosti \"{title}\". Túto akciu nemožno vrátiť.", + "delete_all_message": "Odstránia sa všetky výskyty udalosti \"{title}\". Túto akciu nemožno vrátiť.", "description": "Toto je opakujúca sa udalosť. Ktoré udalosti chcete upraviť?", "this_event": "Iba túto udalosť", "this_and_future": "Túto a všetky nasledujúce udalosti", @@ -4011,13 +4026,14 @@ "color": "Farba", "change_color": "Zmeniť farbu", "random_color": "Nová náhodná farba", - "add_calendar": "Pridať kalendár", + "add_calendar": "Nový kalendár", "edit": "Upraviť", "delete": "Odstrániť", "save": "Uložiť", "create": "Vytvoriť", "cancel": "Zrušiť", "default": "Predvolený", + "row_actions": "Akcie pre {name}", "confirm_delete": "Odstrániť \"{name}\"? Všetky udalosti v tomto kalendári budú odstránené.", "confirm_clear": "Vymazať všetky udalosti z \"{name}\"? Túto akciu nie je možné vrátiť.", "set_default": "Nastaviť ako predvolený", diff --git a/locales/th/common.json b/locales/th/common.json index f70a8f7a9..00c6c6531 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -434,8 +434,8 @@ "email_tagged": "เพิ่มป้ายกำกับแล้ว", "emails_tagged": "{count} อีเมลถูกเพิ่มป้ายกำกับแล้ว", "tag_failed": "เพิ่มป้ายกำกับไม่สำเร็จ", - "identity_created": "สร้างข้อมูลประจำตัวเรียบร้อยแล้ว", - "identity_updated": "อัปเดตข้อมูลประจำตัวเรียบร้อยแล้ว", + "identity_created": "สร้างข้อมูลประจำตัวแล้ว", + "identity_updated": "อัปเดตข้อมูลประจำตัวแล้ว", "identity_deleted": "ลบข้อมูลประจำตัวแล้ว", "identity_set_primary": "ตั้งเป็นข้อมูลประจำตัวหลักแล้ว", "identity_create_failed": "สร้างข้อมูลประจำตัวไม่สำเร็จ: {error}", @@ -445,10 +445,10 @@ "identity_not_found": "ไม่พบข้อมูลประจำตัว", "vacation_saved": "บันทึกการตั้งค่าตอบกลับอัตโนมัติแล้ว", "vacation_save_failed": "บันทึกการตั้งค่าตอบกลับอัตโนมัติไม่สำเร็จ", - "filters_saved": "บันทึกตัวกรองเรียบร้อยแล้ว", + "filters_saved": "บันทึกตัวกรองแล้ว", "filters_save_failed": "บันทึกตัวกรองไม่สำเร็จ", "filters_deleted": "ลบกฎตัวกรองแล้ว", - "templates_exported": "ส่งออกแม่แบบข้อความเรียบร้อยแล้ว", + "templates_exported": "ส่งออกแม่แบบข้อความแล้ว", "templates_imported": "{count, plural, other {นำเข้าแม่แบบข้อความแล้ว # รายการ}}", "templates_import_errors": "ไม่สามารถนำเข้าแม่แบบบางรายการได้", "templates_import_empty": "ไม่พบแม่แบบข้อความในไฟล์", @@ -1508,7 +1508,7 @@ "loading": "กำลังโหลดหน้าเว็บ...", "not_available_title": "หน้านี้ไม่พร้อมใช้งาน", "not_available_description": "ปลั๊กอินที่เพิ่มหน้านี้ถูกปิดใช้งานหรือไม่ได้ติดตั้งแล้ว", - "back_to_mail": "กลับไปหน้าจดหมาย" + "back_to_mail": "กลับไปยังอีเมล" }, "tour": { "step_counter": "ขั้นตอนที่ {current} จาก {total}", @@ -2382,8 +2382,8 @@ "empty_filtered_hint": "ลองปรับเปลี่ยนหรือล้างตัวกรอง", "clear_search": "ล้างการค้นหา", "import_vcard": "นำเข้า vCard", - "delete_confirm_title": "ลบผู้ติดต่อ", - "delete_confirm": "คุณแน่ใจหรือไม่ว่าต้องการลบผู้ติดต่อนี้?", + "delete_named_title": "ลบ {name} หรือไม่?", + "delete_details_message": "ที่อยู่อีเมล หมายเลขโทรศัพท์ บันทึก และรายละเอียดอื่นทั้งหมดจะถูกลบ คุณไม่สามารถเลิกทำได้", "local_mode": "ผู้ติดต่อจะถูกจัดเก็บไว้ในเครื่อง (เซิร์ฟเวอร์ไม่รองรับการซิงค์สมุดที่อยู่)", "back_to_contacts": "กลับไปยังผู้ติดต่อ", "back_to_email": "กลับไปยังอีเมล", @@ -2419,6 +2419,7 @@ "share": "แชร์สมุดที่อยู่", "new_contact_in_book": "เพิ่มผู้ติดต่อในสมุดที่อยู่นี้", "delete": "ลบสมุดที่อยู่", + "delete_title": "ลบสมุดที่อยู่ใช่ไหม", "confirm_delete": "ลบ “{name}” หรือไม่? ผู้ติดต่อทั้งหมดในสมุดที่อยู่นี้จะถูกลบ", "deleted": "ลบสมุดที่อยู่แล้ว", "delete_failed": "ลบสมุดที่อยู่ไม่สำเร็จ", @@ -2623,7 +2624,7 @@ "edit": "แก้ไขกลุ่ม", "empty": "ยังไม่มีกลุ่มผู้ติดต่อ", "delete_confirm_title": "ลบกลุ่ม", - "delete_confirm": "คุณแน่ใจหรือไม่ว่าต้องการลบกลุ่มนี้?", + "delete_message": "กลุ่มจะถูกลบ ผู้ติดต่อในกลุ่มจะยังคงอยู่", "name_label": "ชื่อกลุ่ม", "name_placeholder": "เช่น ทีมงาน, ครอบครัว", "name_required": "จำเป็นต้องระบุชื่อกลุ่ม", @@ -2665,13 +2666,13 @@ "selected": "{count, plural, one {เลือกไว้ 1 รายการ} other {เลือกไว้ # รายการ}}", "select_all": "เลือกทั้งหมด", "delete": "ลบ", - "delete_confirm_title": "ลบผู้ติดต่อ", "delete_confirm": "ลบผู้ติดต่อ {count, plural, one {1 รายการ} other {# รายการ}} หรือไม่?", "deleted": "{count, plural, one {ลบผู้ติดต่อแล้ว 1 รายการ} other {ลบผู้ติดต่อแล้ว # รายการ}}", "add_to_group": "เพิ่มในกลุ่ม", "choose_group": "เลือกกลุ่ม", "new_group": "กลุ่มใหม่…", "adding_contacts": "กำลังเพิ่มผู้ติดต่อ {count, plural, one {1 รายการ} other {# รายการ}}", + "moving_contacts": "กำลังย้ายผู้ติดต่อ {count, plural, one {1 รายการ} other {# รายการ}}", "added_to_group": "เพิ่มผู้ติดต่อในกลุ่มแล้ว", "export": "ส่งออก", "clear": "ล้างการเลือก" @@ -2900,6 +2901,10 @@ "recurrence_scope": { "edit_title": "แก้ไขกิจกรรมที่เกิดซ้ำ", "delete_title": "ลบกิจกรรมที่เกิดซ้ำ", + "delete_question": "ลบกิจกรรมที่เกิดซ้ำหรือไม่?", + "delete_this_message": "จะลบเฉพาะครั้งนี้ของ “{title}” และไม่สามารถเรียกคืนได้", + "delete_following_message": "จะลบครั้งนี้และทุกครั้งถัดไปของ “{title}” และไม่สามารถเรียกคืนได้", + "delete_all_message": "จะลบทุกครั้งของ “{title}” และไม่สามารถเรียกคืนได้", "description": "นี่คือกิจกรรมที่เกิดซ้ำ คุณต้องการปรับเปลี่ยนกิจกรรมใดบ้าง?", "this_event": "เฉพาะกิจกรรมนี้", "this_and_future": "กิจกรรมนี้และกิจกรรมที่ตามมา", @@ -3135,13 +3140,14 @@ "color": "สี", "change_color": "เปลี่ยนสี", "random_color": "สุ่มสีใหม่", - "add_calendar": "เพิ่มปฏิทิน", + "add_calendar": "ปฏิทินใหม่", "edit": "แก้ไข", "delete": "ลบ", "save": "บันทึก", "create": "สร้าง", "cancel": "ยกเลิก", "default": "ค่าเริ่มต้น", + "row_actions": "การทำงานสำหรับ {name}", "confirm_delete": "ลบ “{name}” หรือไม่? กิจกรรมทั้งหมดในปฏิทินนี้จะถูกลบ", "confirm_clear": "ล้างกิจกรรมทั้งหมดจาก “{name}” หรือไม่? การดำเนินการนี้ไม่สามารถยกเลิกได้", "set_default": "ตั้งเป็นค่าเริ่มต้น", @@ -3299,8 +3305,8 @@ "import_success": "นำเข้าการตั้งค่าเรียบร้อยแล้ว", "import_error": "นำเข้าการตั้งค่าไม่สำเร็จ", "reset_confirm": "คุณแน่ใจหรือไม่ว่าต้องการรีเซ็ตการตั้งค่าทั้งหมดกลับเป็นค่าเริ่มต้น?", - "unsaved_changes": "คุณมีการเปลี่ยนแปลงที่ยังไม่ได้บันทึก", - "discard_changes": "ละทิ้งการเปลี่ยนแปลงที่ยังไม่ได้บันทึกหรือไม่?", + "unsaved_changes": "การเปลี่ยนแปลงของคุณยังไม่ได้บันทึก", + "discard_changes": "ละทิ้งการเปลี่ยนแปลงหรือไม่?", "discard": "ละทิ้ง", "keep_editing": "แก้ไขต่อ", "search_placeholder": "ค้นหาการตั้งค่า", @@ -3534,6 +3540,8 @@ "id_exists": "รหัสแท็กนี้มีอยู่แล้ว", "edit": "แก้ไขแท็ก", "delete": "ลบแท็ก", + "edit_tag": "แก้ไข {name}", + "delete_tag": "ลบ {name}", "deleted": "ลบแท็กแล้ว", "save": "บันทึก", "add": "เพิ่ม", @@ -4273,7 +4281,7 @@ "app_passwords": { "title": "รหัสผ่านแอปพลิเคชัน", "description": "สร้างรหัสผ่านสำหรับแอปพลิเคชันที่ไม่รองรับการยืนยันตัวตนแบบสองขั้นตอน", - "add": "เพิ่ม", + "new": "รหัสผ่านแอปใหม่", "create": "สร้าง", "cancel": "ยกเลิก", "done": "เสร็จสิ้น", @@ -4298,6 +4306,7 @@ "api_keys": { "title": "คีย์ API", "description": "สร้างคีย์ API สำหรับสคริปต์และการผสานรวมที่สื่อสารกับเซิร์ฟเวอร์โดยตรง", + "new": "คีย์ API ใหม่", "name_label": "ชื่อคีย์", "name_placeholder": "เช่น สคริปต์สำรองข้อมูล, CI runner", "copy_now_warning": "คัดลอกคีย์ API นี้ทันที - คีย์จะไม่แสดงขึ้นมาอีก", @@ -4338,6 +4347,8 @@ "key_placeholder": "วางพับลิกคีย์ของคุณที่นี่", "none": "ไม่มีการกำหนดค่าพับลิกคีย์", "title": "คีย์สาธารณะ", + "add": "เพิ่มคีย์สาธารณะ", + "add_submit": "เพิ่ม", "added": "เพิ่มคีย์สาธารณะแล้ว", "removed": "ลบคีย์สาธารณะแล้ว", "add_error": "เพิ่มคีย์สาธารณะไม่ได้", @@ -4351,7 +4362,7 @@ "jmap_username_label": "ชื่อผู้ใช้ JMAP", "copy": "คัดลอก", "copied": "คัดลอกแล้ว", - "password_instructions": "ใช้ชื่อผู้ใช้ JMAP ด้านบนควบคู่ไปกับรหัสผ่านแอปเพื่อลงชื่อเข้าใช้โปรแกรมอีเมลของคุณ สร้างรหัสผ่านแอปในส่วนด้านบนหากคุณยังไม่ได้สร้าง" + "password_instructions": "ใช้ชื่อผู้ใช้ JMAP ด้านบนควบคู่ไปกับรหัสผ่านแอปเพื่อลงชื่อเข้าใช้โปรแกรมอีเมลของคุณ สร้างรหัสผ่านแอปในส่วนด้านล่างหากคุณยังไม่ได้สร้าง" }, "link_device": { "title": "เชื่อมโยงแอปบนมือถือ", @@ -4520,10 +4531,10 @@ "export_label": "ส่งออกผู้ติดต่อ", "export_description": "ส่งออกผู้ติดต่อทั้งหมดเป็นไฟล์ vCard (.vcf)", "manage_title": "สมุดที่อยู่", - "manage_description": "เปลี่ยนชื่อสมุดที่อยู่ของคุณ", + "manage_description": "เปลี่ยนชื่อหรือลบสมุดที่อยู่ของคุณ", "no_address_books": "ไม่พบสมุดที่อยู่", "categories_title": "หมวดหมู่", - "categories_description": "เปลี่ยนชื่อหมวดหมู่ผู้ติดต่อ", + "categories_description": "เปลี่ยนชื่อหรือลบหมวดหมู่ผู้ติดต่อ", "no_categories": "ไม่พบหมวดหมู่" }, "calendar_management": { @@ -4597,7 +4608,7 @@ "validation_error": "สคริปต์มีข้อผิดพลาด", "save": "บันทึก", "saving": "กำลังบันทึก...", - "saved": "บันทึกตัวกรองเรียบร้อยแล้ว", + "saved": "บันทึกตัวกรองแล้ว", "save_failed": "บันทึกตัวกรองไม่สำเร็จ", "loading": "กำลังโหลดตัวกรอง...", "not_supported": "เมลเซิร์ฟเวอร์ของคุณไม่รองรับตัวกรองอีเมล", @@ -4665,6 +4676,7 @@ "label_name": "ชื่อแท็ก", "label_placeholder": "เลือกแท็ก", "header_name": "ชื่อส่วนหัว", + "condition_value": "ค่าของเงื่อนไข", "header_placeholder": "เช่น X-Mailing-List", "size_bytes": "ขนาดเป็นไบต์", "size_placeholder": "เช่น 1000000", @@ -4692,6 +4704,10 @@ "validation_empty_name": "จำเป็นต้องระบุชื่อกฎ", "validation_empty_conditions": "จำเป็นต้องมีเงื่อนไขพร้อมค่าอย่างน้อยหนึ่งข้อ", "validation_empty_actions": "จำเป็นต้องมีการดำเนินการอย่างน้อยหนึ่งอย่าง", + "validation_action_folder": "เลือกโฟลเดอร์สำหรับการดำเนินการนี้", + "validation_action_tag": "เลือกแท็กสำหรับการดำเนินการนี้", + "validation_action_forward": "ป้อนที่อยู่ที่จะส่งต่อไป", + "validation_action_reject": "ป้อนข้อความปฏิเสธ", "validation_header_name": "ชื่อส่วนหัวต้องประกอบด้วยตัวอักษร A-Z, ตัวเลข, เครื่องหมายยัติภังค์ และขีดล่างเท่านั้น", "templates_section": "เริ่มต้นจากเทมเพลต", "template_newsletters": "ย้ายจดหมายข่าวไปยังโฟลเดอร์", @@ -4704,12 +4720,12 @@ "script_content": "สคริปต์ Sieve", "valid": "สคริปต์ถูกต้อง", "invalid": "สคริปต์มีข้อผิดพลาด", - "save_warning": "การบันทึกจะเขียนทับกฎแบบภาพทั้งหมด การดำเนินการนี้ไม่สามารถยกเลิกได้ คลิกบันทึกอีกครั้งเพื่อยืนยัน", "validating": "กำลังตรวจสอบความถูกต้อง...", "validate": "ตรวจสอบความถูกต้อง", "cancel": "ยกเลิก", "save": "บันทึก", - "confirm_save": "ยืนยันการบันทึก", + "overwrite_title": "แทนที่กฎแบบภาพใช่ไหม", + "overwrite_message": "สคริปต์นี้ไม่มีกฎที่คุณสร้างไว้ในตัวแก้ไขแบบภาพแล้ว การบันทึกจะแทนที่กฎเหล่านั้น และเลิกทำไม่ได้", "validation_failed": "คำขอตรวจสอบความถูกต้องล้มเหลว" }, "rule_summary": { diff --git a/locales/tr/common.json b/locales/tr/common.json index f2c27229a..2bb1bdee2 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -119,7 +119,7 @@ "loading_mailboxes": "Posta kutuları yükleniyor...", "push_connected": "Anlık güncellemeler aktif", "push_disconnected": "Anlık güncellemeler devre dışı", - "keyboard_shortcuts": "Klavye Kısayolları", + "keyboard_shortcuts": "Klavye kısayolları", "theme": { "light": "Açık tema", "dark": "Koyu tema", @@ -232,9 +232,9 @@ "switching_account": "Hesap değiştiriliyor..." }, "sidebar_apps": { - "modal_title": "Kenar Çubuğu Uygulamaları", - "add_new": "Uygulama Ekle", - "edit_app": "Uygulamayı Düzenle", + "modal_title": "Kenar çubuğu uygulamaları", + "add_new": "Uygulama ekle", + "edit_app": "Uygulamayı düzenle", "name_label": "Ad", "name_placeholder": "Uygulamam", "name_required": "Ad zorunludur", @@ -243,14 +243,14 @@ "url_invalid": "Geçerli bir http veya https URL girin", "icon_label": "Simge", "icon_required": "Simge zorunludur", - "open_mode_label": "Açılış Modu", - "open_new_tab": "Yeni Sekme", + "open_mode_label": "Açılış modu", + "open_new_tab": "Yeni sekme", "open_inline": "Yerleşik", "cancel": "İptal", "add": "Ekle", "update": "Güncelle", "delete": "Sil", - "delete_confirm_title": "Uygulamayı Sil", + "delete_confirm_title": "Uygulamayı sil", "delete_confirm": "\"{name}\" uygulamasını silmek istediğinizden emin misiniz?", "no_apps": "Henüz uygulama eklenmedi", "no_apps_hint": "Kenar çubuğunuza özel uygulamalar ve bağlantılar ekleyin", @@ -263,7 +263,7 @@ "no_icons_found": "Simge bulunamadı", "inline_badge": "Yerleşik", "tab_badge": "Sekme", - "show_on_mobile": "Mobilde Göster" + "show_on_mobile": "Mobilde göster" }, "email_list": { "sections": { @@ -1013,8 +1013,8 @@ "email_tagged": "E-posta etiketlendi", "emails_tagged": "{count} e-posta etiketlendi", "tag_failed": "Etiketleme başarısız", - "identity_created": "Kimlik başarıyla oluşturuldu", - "identity_updated": "Kimlik başarıyla güncellendi", + "identity_created": "Kimlik oluşturuldu", + "identity_updated": "Kimlik güncellendi", "identity_deleted": "Kimlik silindi", "identity_set_primary": "Birincil kimlik güncellendi", "identity_create_failed": "Kimlik oluşturulamadı: {error}", @@ -1024,10 +1024,10 @@ "identity_not_found": "Kimlik bulunamadı", "vacation_saved": "Ofis dışında ayarları kaydedildi", "vacation_save_failed": "Ofis dışında ayarları kaydedilemedi", - "filters_saved": "Filtreler başarıyla kaydedildi", + "filters_saved": "Filtreler kaydedildi", "filters_save_failed": "Filtreler kaydedilemedi", "filters_deleted": "Filtre kuralı silindi", - "templates_exported": "Şablonlar başarıyla dışa aktarıldı", + "templates_exported": "Şablonlar dışa aktarıldı", "templates_imported": "{count, plural, one {# şablon} other {# şablon}} içe aktarıldı", "templates_import_errors": "Bazı şablonlar içe aktarılamadı", "templates_import_empty": "Dosyada şablon bulunamadı", @@ -1120,9 +1120,9 @@ "import_success": "Ayarlar başarıyla içe aktarıldı", "import_error": "Ayarlar içe aktarılamadı", "reset_confirm": "Tüm ayarları varsayılanlara sıfırlamak istediğinizden emin misiniz?", - "unsaved_changes": "Kaydedilmemiş değişiklikleriniz var", - "discard_changes": "Kaydedilmemiş değişiklikler atılsın mı?", - "discard": "Atla", + "unsaved_changes": "Değişiklikleriniz kaydedilmedi.", + "discard_changes": "Değişiklikler atılsın mı?", + "discard": "Değişiklikleri at", "keep_editing": "Düzenlemeye devam et", "tabs": { "appearance": "Görünüm", @@ -1352,6 +1352,8 @@ "id_exists": "Bu etiket kimliği zaten mevcut", "edit": "Etiketi düzenle", "delete": "Etiketi sil", + "edit_tag": "{name} düzenle", + "delete_tag": "{name} sil", "deleted": "Etiket silindi", "save": "Kaydet", "add": "Ekle", @@ -1941,7 +1943,7 @@ "app_passwords": { "title": "Uygulama Parolaları", "description": "İki faktörlü kimlik doğrulamayı desteklemeyen uygulamalar için parola oluşturun", - "add": "Ekle", + "new": "Yeni uygulama parolası", "create": "Oluştur", "cancel": "İptal", "done": "Tamam", @@ -1966,6 +1968,7 @@ "api_keys": { "title": "API Anahtarları", "description": "Sunucuyla doğrudan iletişim kuran betikler ve entegrasyonlar için API anahtarları oluşturun", + "new": "Yeni API anahtarı", "name_label": "Anahtar Adı", "name_placeholder": "ör. Yedek betiği, CI koşucusu", "copy_now_warning": "Bu API anahtarını şimdi kopyalayın - bir daha gösterilmeyecek.", @@ -2005,7 +2008,7 @@ "jmap_username_label": "JMAP Kullanıcı Adı", "copy": "Kopyala", "copied": "Kopyalandı", - "password_instructions": "E-posta istemcinizde oturum açmak için yukarıdaki JMAP kullanıcı adı ile bir uygulama parolasını birlikte kullanın. Henüz oluşturmadıysanız yukarıdaki bölümden bir uygulama parolası oluşturun." + "password_instructions": "E-posta istemcinizde oturum açmak için yukarıdaki JMAP kullanıcı adı ile bir uygulama parolasını birlikte kullanın. Henüz oluşturmadıysanız aşağıdaki bölümden bir uygulama parolası oluşturun." }, "link_device": { "title": "Mobil uygulamayı bağla", @@ -2047,6 +2050,8 @@ "key_placeholder": "Ortak anahtarınızı buraya yapıştırın", "none": "Yapılandırılmış ortak anahtar yok", "title": "Açık anahtarlar", + "add": "Açık anahtar ekle", + "add_submit": "Ekle", "added": "Açık anahtar eklendi", "removed": "Açık anahtar silindi", "add_error": "Açık anahtar eklenemedi", @@ -2248,7 +2253,7 @@ "description": "İstem dışı klasöründeki göndericiler için profil resimleri ve favorikonları göster. Kimlik avı girişimlerine görsel meşruiyet kazandırmamak için varsayılan olarak kapalıdır." }, "keyboard_shortcuts": { - "label": "Klavye Kısayolları", + "label": "Klavye kısayolları", "description": "Mevcut klavye kısayollarını görüntüleyin", "button": "Kısayolları Görüntüle" }, @@ -2334,10 +2339,10 @@ "export_label": "Kişileri Dışa Aktar", "export_description": "Tüm kişileri vCard (.vcf) dosyası olarak dışa aktarın", "manage_title": "Adres Defterleri", - "manage_description": "Adres defterlerinizi yeniden adlandırın", + "manage_description": "Adres defterlerinizi yeniden adlandırın veya silin", "no_address_books": "Adres defteri bulunamadı", "categories_title": "Kategoriler", - "categories_description": "Kişi kategorilerini yeniden adlandırın", + "categories_description": "Kişi kategorilerini yeniden adlandırın veya silin", "no_categories": "Kategori bulunamadı" }, "calendar_management": { @@ -2411,7 +2416,7 @@ "validation_error": "Betikte hatalar var", "save": "Kaydet", "saving": "Kaydediliyor...", - "saved": "Filtreler başarıyla kaydedildi", + "saved": "Filtreler kaydedildi", "save_failed": "Filtreler kaydedilemedi", "loading": "Filtreler yükleniyor...", "not_supported": "Posta sunucunuz e-posta filtrelerini desteklemiyor.", @@ -2479,6 +2484,7 @@ "label_name": "Etiket adı", "label_placeholder": "Etiket seçin", "header_name": "Başlık adı", + "condition_value": "Koşul değeri", "header_placeholder": "ör. X-Mailing-List", "size_bytes": "Bayt cinsinden boyut", "size_placeholder": "ör. 1000000", @@ -2506,6 +2512,10 @@ "validation_empty_name": "Kural adı gerekli", "validation_empty_conditions": "En az bir değeri olan koşul gerekli", "validation_empty_actions": "En az bir işlem gerekli", + "validation_action_folder": "Bu işlem için bir klasör seçin", + "validation_action_tag": "Bu işlem için bir etiket seçin", + "validation_action_forward": "İletilecek bir adres girin", + "validation_action_reject": "Ret mesajını girin", "validation_header_name": "Başlık adları yalnızca A-Z harflerini, rakamları, kısa çizgileri ve alt çizgileri içerebilir", "templates_section": "Şablondan başla", "template_newsletters": "Bültenleri klasöre taşı", @@ -2518,12 +2528,12 @@ "script_content": "Sieve betiği", "valid": "Betik geçerli", "invalid": "Betikte hatalar var", - "save_warning": "Kaydetme işlemi tüm görsel kuralların üzerine yazacak. Bu geri alınamaz. Onaylamak için Kaydet'e tekrar tıklayın.", "validating": "Doğrulanıyor...", "validate": "Doğrula", "cancel": "İptal", "save": "Kaydet", - "confirm_save": "Kaydetmeyi Onayla", + "overwrite_title": "Görsel kurallar değiştirilsin mi?", + "overwrite_message": "Bu betik artık görsel düzenleyicide oluşturduğunuz kuralları içermiyor. Kaydederseniz bu kuralların yerini alır ve bu geri alınamaz.", "validation_failed": "Doğrulama isteği başarısız" }, "rule_summary": { @@ -3022,7 +3032,7 @@ "delete_with_contents_confirm": "Klasörü ve iletileri sil" }, "shortcuts": { - "title": "Klavye Kısayolları", + "title": "Klavye kısayolları", "tip": "Bu yardımı görmek için istediğiniz zaman ? tuşuna basın", "or": "veya", "sections": { @@ -3258,8 +3268,8 @@ "empty_filtered_hint": "Filtreleri ayarlamayı veya temizlemeyi deneyin", "clear_search": "Aramayı temizle", "import_vcard": "vCard İçe Aktar", - "delete_confirm_title": "Kişiyi sil", - "delete_confirm": "Bu kişiyi silmek istediğinizden emin misiniz?", + "delete_named_title": "{name} silinsin mi?", + "delete_details_message": "E-posta adresleri, telefon numaraları, notlar ve diğer tüm bilgiler silinecek. Bu işlem geri alınamaz.", "local_mode": "Kişiler yerel olarak saklanıyor (sunucu JMAP Kişilerini desteklemiyor)", "back_to_contacts": "Kişilere geri dön", "back_to_email": "E-postaya geri dön", @@ -3293,6 +3303,7 @@ "share": "Adres defterini paylaş", "new_contact_in_book": "Bu adres defterinde yeni kişi", "delete": "Adres defterini sil", + "delete_title": "Adres defteri silinsin mi?", "confirm_delete": "\"{name}\" silinsin mi? Bu adres defterindeki tüm kişiler kaldırılacak.", "deleted": "Adres defteri silindi", "delete_failed": "Adres defteri silinemedi", @@ -3471,7 +3482,7 @@ "cancel": "İptal", "discard_title": "Değişiklikler atılsın mı?", "discard_message": "Değişiklikleriniz kaydedilmedi.", - "discard": "At", + "discard": "Değişiklikleri at", "keep_editing": "Düzenlemeye devam et", "remove_entry": "Kaldır", "close": "Kapat", @@ -3497,7 +3508,7 @@ "edit": "Grubu Düzenle", "empty": "Henüz grup yok", "delete_confirm_title": "Grubu sil", - "delete_confirm": "Bu grubu silmek istediğinizden emin misiniz?", + "delete_message": "Grup silinecek. İçindeki kişiler korunur.", "name_label": "Grup Adı", "name_placeholder": "ör. Takım, Aile", "name_required": "Grup adı gerekli", @@ -3539,13 +3550,13 @@ "selected": "{count, plural, one {1 seçildi} other {# seçildi}}", "select_all": "Tümünü seç", "delete": "Sil", - "delete_confirm_title": "Kişileri sil", "delete_confirm": "{count, plural, one {1 kişi} other {# kişi}} silinsin mi?", "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", + "moving_contacts": "{count, plural, one {1 kişi} other {# kişi}} taşınıyor", "added_to_group": "Kişiler gruba eklendi", "export": "Dışa Aktar", "clear": "Seçimi temizle" @@ -3776,6 +3787,10 @@ "recurrence_scope": { "edit_title": "Yinelenen etkinliği düzenle", "delete_title": "Yinelenen etkinliği sil", + "delete_question": "Yinelenen etkinlik silinsin mi?", + "delete_this_message": "\"{title}\" etkinliğinin yalnızca bu tekrarı silinecek. Bu işlem geri alınamaz.", + "delete_following_message": "\"{title}\" etkinliğinin bu ve sonraki tüm tekrarları silinecek. Bu işlem geri alınamaz.", + "delete_all_message": "\"{title}\" etkinliğinin tüm tekrarları silinecek. Bu işlem geri alınamaz.", "description": "Bu yinelenen bir etkinlik. Hangi etkinlikleri değiştirmek istersiniz?", "this_event": "Yalnızca bu etkinlik", "this_and_future": "Bu ve sonraki etkinlikler", @@ -4008,13 +4023,14 @@ "color": "Renk", "change_color": "Rengi değiştir", "random_color": "Yeni rastgele renk", - "add_calendar": "Takvim ekle", + "add_calendar": "Yeni takvim", "edit": "Düzenle", "delete": "Sil", "save": "Kaydet", "create": "Oluştur", "cancel": "İptal", "default": "Varsayılan", + "row_actions": "{name} için işlemler", "confirm_delete": "\"{name}\" silinsin mi? Bu takvimdeki tüm etkinlikler kaldırılacak.", "confirm_clear": "\"{name}\" takvimdeki tüm etkinlikler temizlensin mi? Bu geri alınamaz.", "set_default": "Varsayılan olarak ayarla", @@ -5044,7 +5060,7 @@ "loading": "Sayfa yükleniyor", "not_available_title": "Bu sayfa kullanılamıyor", "not_available_description": "Bu sayfayı ekleyen eklenti kapalı veya artık yüklü değil.", - "back_to_mail": "Postaya dön" + "back_to_mail": "Postaya Dön" }, "command_palette": { "title": "Komut paleti", diff --git a/locales/uk/common.json b/locales/uk/common.json index d774af90b..a9c7b5a32 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1013,8 +1013,8 @@ "email_tagged": "Електронна пошта позначена тегами", "emails_tagged": "{count} електронних листів позначено тегами", "tag_failed": "Помилка тегування", - "identity_created": "Ідентичність створено успішно", - "identity_updated": "Ідентичність успішно оновлено", + "identity_created": "Ідентичність створено", + "identity_updated": "Ідентичність оновлено", "identity_deleted": "Ідентичність видалено", "identity_set_primary": "Основну особу оновлено", "identity_create_failed": "Не вдалося створити ідентифікатор: {error}", @@ -1024,10 +1024,10 @@ "identity_not_found": "Особа не знайдена", "vacation_saved": "Налаштування відсутності збережено", "vacation_save_failed": "Не вдалося зберегти налаштування відсутності", - "filters_saved": "Фільтри успішно збережено", + "filters_saved": "Фільтри збережено", "filters_save_failed": "Не вдалося зберегти фільтри", "filters_deleted": "Правило фільтра видалено", - "templates_exported": "Шаблони успішно експортовано", + "templates_exported": "Шаблони експортовано", "templates_imported": "{count, plural, one {# template} other {# templates}} імпортовано", "templates_import_errors": "Деякі шаблони не вдалося імпортувати", "templates_import_empty": "У файлі не знайдено шаблонів", @@ -1120,10 +1120,10 @@ "import_success": "Налаштування успішно імпортовано", "import_error": "Не вдалося імпортувати налаштування", "reset_confirm": "Ви впевнені, що бажаєте скинути всі налаштування до стандартних?", - "unsaved_changes": "У вас є незбережені зміни", - "discard_changes": "Скасувати незбережені зміни?", + "unsaved_changes": "Ваші зміни не збережено.", + "discard_changes": "Відкинути зміни?", "discard": "Відкинути", - "keep_editing": "Продовжуйте редагувати", + "keep_editing": "Продовжити редагування", "tabs": { "appearance": "Зовнішній вигляд", "account": "Обліковий запис", @@ -1352,6 +1352,8 @@ "id_exists": "Цей ідентифікатор ключового слова вже існує", "edit": "Редагувати ключове слово", "delete": "Видалити ключове слово", + "edit_tag": "Редагувати «{name}»", + "delete_tag": "Видалити «{name}»", "deleted": "Ключове слово видалено", "save": "зберегти", "add": "додати", @@ -1941,7 +1943,7 @@ "app_passwords": { "title": "Паролі програм", "description": "Створюйте паролі для програм, які не підтримують двофакторну автентифікацію", - "add": "додати", + "new": "Новий пароль програми", "create": "Створити", "cancel": "Скасувати", "generate": "Генерувати", @@ -1966,6 +1968,7 @@ "api_keys": { "title": "API-ключі", "description": "Створюйте API-ключі для скриптів та інтеграцій, які звертаються до сервера напряму", + "new": "Новий API-ключ", "name_label": "Назва ключа", "name_placeholder": "напр. Скрипт резервної копії, CI runner", "copy_now_warning": "Скопіюйте цей API-ключ зараз – він більше не буде показаний.", @@ -2005,7 +2008,7 @@ "jmap_username_label": "Ім'я користувача JMAP", "copy": "Копія", "copied": "Скопійовано", - "password_instructions": "Використовуйте своє ім’я користувача JMAP вище разом із паролем програми, щоб увійти у свій поштовий клієнт. Створіть пароль програми в розділі вище, якщо ви ще цього не зробили." + "password_instructions": "Використовуйте своє ім’я користувача JMAP вище разом із паролем програми, щоб увійти у свій поштовий клієнт. Створіть пароль програми в розділі нижче, якщо ви ще цього не зробили." }, "link_device": { "title": "Прив'язати мобільний застосунок", @@ -2047,6 +2050,8 @@ "key_placeholder": "Вставте сюди свій відкритий ключ", "none": "Відкриті ключі не налаштовано", "title": "Відкриті ключі", + "add": "Додати відкритий ключ", + "add_submit": "Додати", "added": "Відкритий ключ додано", "removed": "Відкритий ключ видалено", "add_error": "Не вдалося додати відкритий ключ", @@ -2330,10 +2335,10 @@ "export_label": "Експортувати контакти", "export_description": "Експортуйте всі контакти як файл vCard (.vcf).", "manage_title": "Адресні книги", - "manage_description": "Перейменуйте свої адресні книги", + "manage_description": "Перейменуйте або видаліть свої адресні книги", "no_address_books": "Адресних книг не знайдено", "categories_title": "Категорії", - "categories_description": "Перейменувати категорії контактів", + "categories_description": "Перейменувати або видалити категорії контактів", "no_categories": "Категорії не знайдено", "group_by_letter_description": "Показувати алфавітні заголовки розділів у списку контактів", "group_by_letter_label": "Групувати за першою літерою", @@ -2411,7 +2416,7 @@ "validation_error": "Скрипт містить помилки", "save": "зберегти", "saving": "Збереження...", - "saved": "Фільтри успішно збережено", + "saved": "Фільтри збережено", "save_failed": "Не вдалося зберегти фільтри", "loading": "Завантаження фільтрів...", "not_supported": "Ваш поштовий сервер не підтримує фільтри електронної пошти.", @@ -2479,6 +2484,7 @@ "label_name": "Назва мітки", "label_placeholder": "наприклад, важливий", "header_name": "Назва заголовка", + "condition_value": "Значення умови", "header_placeholder": "напр., X-Mailing-List", "size_bytes": "Розмір у байтах", "size_placeholder": "напр., 1000000", @@ -2506,6 +2512,10 @@ "validation_empty_name": "Потрібно вказати назву правила", "validation_empty_conditions": "Потрібна принаймні одна умова зі значенням", "validation_empty_actions": "Необхідна принаймні одна дія", + "validation_action_folder": "Виберіть теку для цієї дії", + "validation_action_tag": "Виберіть мітку для цієї дії", + "validation_action_forward": "Введіть адресу для пересилання", + "validation_action_reject": "Введіть повідомлення про відхилення", "validation_header_name": "Назва заголовка може містити лише літери A-Z, цифри, дефіси та підкреслення", "templates_section": "Почніть із шаблону", "template_newsletters": "Перемістити інформаційні бюлетені в папку", @@ -2518,12 +2528,12 @@ "script_content": "Сито сценарій", "valid": "Сценарій дійсний", "invalid": "Скрипт містить помилки", - "save_warning": "Збереження перезапише будь-які візуальні правила. Це неможливо скасувати. Натисніть кнопку Зберегти ще раз для підтвердження.", "validating": "Перевірка...", "validate": "Перевірити", "cancel": "Скасувати", "save": "зберегти", - "confirm_save": "Підтвердити збереження", + "overwrite_title": "Замінити візуальні правила?", + "overwrite_message": "Цей скрипт більше не містить правил, створених у візуальному редакторі. Збереження замінить їх, і цю дію не можна скасувати.", "validation_failed": "Помилка запиту перевірки" }, "rule_summary": { @@ -3258,8 +3268,8 @@ "empty_filtered_hint": "Змініть або очистіть фільтри", "clear_search": "Очистити пошук", "import_vcard": "Імпорт vCard", - "delete_confirm_title": "Видалити контакт", - "delete_confirm": "Ви впевнені, що хочете видалити цей контакт?", + "delete_named_title": "Видалити {name}?", + "delete_details_message": "Адреси електронної пошти, номери телефонів, нотатки та всі інші дані буде видалено. Цю дію не можна скасувати.", "local_mode": "Контакти зберігаються локально (сервер не підтримує контакти JMAP)", "back_to_contacts": "Назад до контактів", "back_to_email": "Назад до листа", @@ -3293,6 +3303,7 @@ "share": "Поділитися адресною книгою", "new_contact_in_book": "Новий контакт у цій адресній книзі", "delete": "Видалити адресну книгу", + "delete_title": "Видалити адресну книгу?", "confirm_delete": "Видалити «{name}»? Усі контакти в цій адресній книзі будуть видалені.", "deleted": "Адресну книгу видалено", "delete_failed": "Не вдалося видалити адресну книгу", @@ -3497,7 +3508,7 @@ "edit": "Редагувати групу", "empty": "Груп ще немає", "delete_confirm_title": "Видалити групу", - "delete_confirm": "Ви впевнені, що хочете видалити цю групу?", + "delete_message": "Групу буде видалено. Контакти в ній збережуться.", "name_label": "Назва групи", "name_placeholder": "наприклад, Команда, Сім’я", "name_required": "Потрібно вказати назву групи", @@ -3539,13 +3550,13 @@ "selected": "{count, plural, one {1 вибрано} few {# вибрано} many {# вибрано} other {# вибрано}}", "select_all": "Вибрати все", "delete": "Видалити", - "delete_confirm_title": "Видалити контакти", - "delete_confirm": "Видалити {count, plural, one {1 contact} other {# contacts}}?", + "delete_confirm": "Видалити {count, plural, one {# контакт} few {# контакти} many {# контактів} other {# контакту}}?", "deleted": "{count, plural, one {1 контакт видалено} few {# контакти видалено} many {# контактів видалено} other {# контактів видалено}}", "add_to_group": "Додати до групи", "choose_group": "Виберіть групу", "new_group": "Нова група…", - "adding_contacts": "Додавання {count, plural, one {1 contact} other {# contacts}}", + "adding_contacts": "Додавання {count, plural, one {# контакту} other {# контактів}}", + "moving_contacts": "Переміщення {count, plural, one {# контакту} other {# контактів}}", "added_to_group": "Контакти додано до групи", "export": "Експорт", "clear": "Очистити вибір" @@ -3776,6 +3787,10 @@ "recurrence_scope": { "edit_title": "Редагувати повторювану подію", "delete_title": "Видалити повторювану подію", + "delete_question": "Видалити повторювану подію?", + "delete_this_message": "Буде видалено лише це повторення «{title}». Цю дію не можна скасувати.", + "delete_following_message": "Буде видалено це й усі наступні повторення «{title}». Цю дію не можна скасувати.", + "delete_all_message": "Буде видалено всі повторення «{title}». Цю дію не можна скасувати.", "description": "Це повторювана подія. Які події ви б хотіли змінити?", "this_event": "Тільки ця подія", "this_and_future": "Ця та наступні події", @@ -3982,13 +3997,14 @@ "color": "колір", "change_color": "Змінити колір", "random_color": "Новий випадковий колір", - "add_calendar": "Додати календар", + "add_calendar": "Новий календар", "edit": "Редагувати", "delete": "Видалити", "save": "зберегти", "create": "Створити", "cancel": "Скасувати", "default": "За замовчуванням", + "row_actions": "Дії для «{name}»", "confirm_delete": "Видалити \"{name}\"? Усі події в цьому календарі буде видалено.", "confirm_clear": "Очистити всі події з \"{name}\"? Це неможливо скасувати.", "set_default": "Зробити типовим", @@ -5044,7 +5060,7 @@ "loading": "Завантаження сторінки", "not_available_title": "Ця сторінка недоступна", "not_available_description": "Плагін, який її додав, вимкнено або більше не встановлено.", - "back_to_mail": "Повернутися до пошти" + "back_to_mail": "Назад до пошти" }, "command_palette": { "title": "Палітра команд", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 2fb6c8f18..367e342c5 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -1013,8 +1013,8 @@ "email_tagged": "郵件已加標籤", "emails_tagged": "{count} 封郵件已加標籤", "tag_failed": "加上標籤失敗", - "identity_created": "已成功建立寄件身分", - "identity_updated": "已成功更新寄件身分", + "identity_created": "已建立寄件身分", + "identity_updated": "已更新寄件身分", "identity_deleted": "已刪除寄件身分", "identity_set_primary": "已更新主要寄件身分", "identity_create_failed": "無法建立寄件身分:{error}", @@ -1024,10 +1024,10 @@ "identity_not_found": "找不到寄件身分", "vacation_saved": "外出設定已儲存", "vacation_save_failed": "無法儲存外出設定", - "filters_saved": "篩選規則已成功儲存", + "filters_saved": "篩選規則已儲存", "filters_save_failed": "無法儲存篩選規則", "filters_deleted": "篩選規則已刪除", - "templates_exported": "範本匯出成功", + "templates_exported": "範本已匯出", "templates_imported": "已匯入{count, plural, one {# 個範本} other {# 個範本}}", "templates_import_errors": "部分範本無法匯入", "templates_import_empty": "檔案中找不到範本", @@ -1120,8 +1120,8 @@ "import_success": "已成功匯入設定", "import_error": "無法匯入設定", "reset_confirm": "您確定要將所有設定重設為預設值嗎?", - "unsaved_changes": "您有尚未儲存的變更", - "discard_changes": "要捨棄尚未儲存的變更嗎?", + "unsaved_changes": "你的變更尚未儲存。", + "discard_changes": "要捨棄變更嗎?", "discard": "捨棄", "keep_editing": "繼續編輯", "search_placeholder": "搜尋設定", @@ -1355,6 +1355,8 @@ "id_exists": "此標籤 ID 已存在", "edit": "編輯標籤", "delete": "刪除標籤", + "edit_tag": "編輯 {name}", + "delete_tag": "刪除 {name}", "deleted": "標籤已刪除", "save": "儲存", "add": "新增", @@ -1948,7 +1950,7 @@ "app_passwords": { "title": "應用程式密碼", "description": "為不支援雙因素驗證的應用程式建立密碼", - "add": "新增", + "new": "新增應用程式密碼", "create": "建立", "cancel": "取消", "done": "完成", @@ -1973,6 +1975,7 @@ "api_keys": { "title": "API 金鑰", "description": "為直接與伺服器通訊的指令碼和整合建立 API 金鑰", + "new": "新增 API 金鑰", "name_label": "金鑰名稱", "name_placeholder": "例如:備份指令碼、CI 執行器", "copy_now_warning": "請立即複製此 API 金鑰 — 之後將不再顯示。", @@ -2013,6 +2016,8 @@ "key_placeholder": "在此貼上您的公鑰", "none": "未設定公鑰", "title": "公開金鑰", + "add": "新增公開金鑰", + "add_submit": "新增", "added": "已新增公開金鑰", "removed": "已刪除公開金鑰", "add_error": "無法新增公開金鑰", @@ -2026,7 +2031,7 @@ "jmap_username_label": "JMAP 使用者名稱", "copy": "複製", "copied": "已複製", - "password_instructions": "使用上方的 JMAP 使用者名稱與應用程式密碼登入電子郵件用戶端。如果尚未建立應用程式密碼,請在上方區段建立一組。" + "password_instructions": "使用上方的 JMAP 使用者名稱與應用程式密碼登入電子郵件用戶端。如果尚未建立應用程式密碼,請在下方區段建立一組。" }, "link_device": { "title": "連結行動應用程式", @@ -2341,10 +2346,10 @@ "export_label": "匯出聯絡人", "export_description": "將所有聯絡人匯出為 vCard(.vcf)檔案", "manage_title": "通訊錄", - "manage_description": "重新命名您的通訊錄", + "manage_description": "重新命名或刪除您的通訊錄", "no_address_books": "找不到通訊錄", "categories_title": "類別", - "categories_description": "重新命名聯絡人類別", + "categories_description": "重新命名或刪除聯絡人類別", "no_categories": "找不到類別" }, "calendar_management": { @@ -2418,7 +2423,7 @@ "validation_error": "指令碼有錯誤", "save": "儲存", "saving": "正在儲存…", - "saved": "篩選規則已成功儲存", + "saved": "篩選規則已儲存", "save_failed": "無法儲存篩選規則", "loading": "正在載入篩選規則…", "not_supported": "您的郵件伺服器不支援郵件篩選規則。", @@ -2486,6 +2491,7 @@ "label_name": "標籤名稱", "label_placeholder": "選取標籤", "header_name": "標頭名稱", + "condition_value": "條件值", "header_placeholder": "例如,X-Mailing-List", "size_bytes": "大小(以位元組為單位)", "size_placeholder": "例如,1000000", @@ -2513,6 +2519,10 @@ "validation_empty_name": "規則名稱為必填欄位", "validation_empty_conditions": "至少需要一個具有值的條件", "validation_empty_actions": "至少需要一個動作", + "validation_action_folder": "請為此動作選擇資料夾", + "validation_action_tag": "請為此動作選擇標籤", + "validation_action_forward": "請輸入要轉寄到的地址", + "validation_action_reject": "請輸入退信訊息", "validation_header_name": "標頭名稱只能包含字母 A-Z、數字、連字號和底線", "templates_section": "從範本開始", "template_newsletters": "將訂閱郵件移至信件匣", @@ -2525,12 +2535,12 @@ "script_content": "Sieve 指令碼", "valid": "指令碼有效", "invalid": "指令碼有錯誤", - "save_warning": "儲存會覆寫所有視覺化規則,且無法復原。請再次點擊「儲存」以確認。", "validating": "正在驗證…", "validate": "驗證", "cancel": "取消", "save": "儲存", - "confirm_save": "確認儲存", + "overwrite_title": "要取代視覺化規則嗎?", + "overwrite_message": "此指令碼已不包含您在視覺化編輯器中建立的規則。儲存後這些規則將被取代,且無法復原。", "validation_failed": "驗證請求失敗" }, "rule_summary": { @@ -3258,8 +3268,8 @@ "empty_filtered_hint": "請調整或清除篩選條件", "clear_search": "清除搜尋", "import_vcard": "匯入 vCard", - "delete_confirm_title": "刪除聯絡人", - "delete_confirm": "您確定要刪除此聯絡人嗎?", + "delete_named_title": "刪除「{name}」?", + "delete_details_message": "電子郵件地址、電話號碼、備註及所有其他資訊都將被刪除。此動作無法復原。", "local_mode": "聯絡人儲存在本機(伺服器不支援 JMAP 聯絡人)", "back_to_contacts": "回到聯絡人", "back_to_email": "回到郵件", @@ -3295,6 +3305,7 @@ "share": "分享通訊錄", "new_contact_in_book": "在此通訊錄中新增聯絡人", "delete": "刪除通訊錄", + "delete_title": "要刪除通訊錄嗎?", "confirm_delete": "要刪除「{name}」嗎?此通訊錄中的所有聯絡人都會移除。", "deleted": "通訊錄已刪除", "delete_failed": "刪除通訊錄失敗", @@ -3472,7 +3483,7 @@ "save": "儲存", "cancel": "取消", "discard_title": "要捨棄變更嗎?", - "discard_message": "您的變更尚未儲存。", + "discard_message": "你的變更尚未儲存。", "discard": "捨棄", "keep_editing": "繼續編輯", "remove_entry": "移除", @@ -3499,7 +3510,7 @@ "edit": "編輯群組", "empty": "尚無群組", "delete_confirm_title": "刪除群組", - "delete_confirm": "您確定要刪除該群組嗎?", + "delete_message": "此群組將被刪除。其中的聯絡人會保留。", "name_label": "群組名稱", "name_placeholder": "例如,團隊、家庭", "name_required": "群組名稱為必填欄位", @@ -3541,13 +3552,13 @@ "selected": "{count, plural, one {已選取 1 項} other {已選取 # 項}}", "select_all": "全部選取", "delete": "刪除", - "delete_confirm_title": "刪除聯絡人", "delete_confirm": "刪除{count, plural, one {1 位聯絡人} other {# 位聯絡人}}?", "deleted": "{count, plural, one {已刪除 1 位聯絡人} other {已刪除 # 位聯絡人}}", "add_to_group": "新增至群組", "choose_group": "選擇群組", "new_group": "新增群組…", "adding_contacts": "正在新增{count, plural, one {1 位聯絡人} other {# 位聯絡人}}", + "moving_contacts": "正在移動{count, plural, one {1 位聯絡人} other {# 位聯絡人}}", "added_to_group": "聯絡人已新增至群組", "export": "匯出", "clear": "清除選取" @@ -3776,6 +3787,10 @@ "recurrence_scope": { "edit_title": "編輯重複活動", "delete_title": "刪除重複活動", + "delete_question": "要刪除重複活動嗎?", + "delete_this_message": "僅刪除「{title}」的這一次,且無法復原。", + "delete_following_message": "將刪除「{title}」的這一次及之後的所有重複,且無法復原。", + "delete_all_message": "將刪除「{title}」的所有重複,且無法復原。", "description": "這是重複活動。您要修改哪些活動?", "this_event": "僅限本次活動", "this_and_future": "本次及後續活動", @@ -4018,6 +4033,7 @@ "create": "建立", "cancel": "取消", "default": "預設", + "row_actions": "{name} 的動作", "confirm_delete": "要刪除「{name}」嗎?此行事曆中的所有活動都會移除。", "confirm_clear": "要清除「{name}」中的所有活動嗎?此動作無法復原。", "set_default": "設為預設", @@ -5044,7 +5060,7 @@ "loading": "正在載入頁面", "not_available_title": "此頁面無法使用", "not_available_description": "新增此頁面的外掛程式已關閉或已不再安裝。", - "back_to_mail": "返回郵件" + "back_to_mail": "回到郵件" }, "command_palette": { "title": "命令面板", diff --git a/locales/zh/common.json b/locales/zh/common.json index 507bc4616..dcd06a167 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1013,8 +1013,8 @@ "email_tagged": "邮件已加标签", "emails_tagged": "{count} 封邮件已加标签", "tag_failed": "标记失败", - "identity_created": "身份创建成功", - "identity_updated": "身份更新成功", + "identity_created": "身份已创建", + "identity_updated": "身份已更新", "identity_deleted": "身份已删除", "identity_set_primary": "主要身份已更新", "identity_create_failed": "无法创建身份:{error}", @@ -1024,10 +1024,10 @@ "identity_not_found": "未找到身份", "vacation_saved": "外出设置已保存", "vacation_save_failed": "无法保存外出设置", - "filters_saved": "过滤规则保存成功", + "filters_saved": "过滤规则已保存", "filters_save_failed": "无法保存过滤器", "filters_deleted": "过滤规则已删除", - "templates_exported": "模板导出成功", + "templates_exported": "模板已导出", "templates_imported": "已导入{count, plural, one {# 个模板} other {# 个模板}}", "templates_import_errors": "部分模板无法导入", "templates_import_empty": "文件中未找到模板", @@ -1120,8 +1120,8 @@ "import_success": "设置导入成功", "import_error": "导入设置失败", "reset_confirm": "您确定要将所有设置重置为默认值吗?", - "unsaved_changes": "您有未保存的更改", - "discard_changes": "放弃未保存的更改?", + "unsaved_changes": "你的更改尚未保存。", + "discard_changes": "放弃更改?", "discard": "放弃", "keep_editing": "继续编辑", "tabs": { @@ -1352,6 +1352,8 @@ "id_exists": "此标签ID已存在", "edit": "编辑标签", "delete": "删除标签", + "edit_tag": "编辑 {name}", + "delete_tag": "删除 {name}", "deleted": "标签已删除", "save": "保存", "add": "添加", @@ -1941,7 +1943,7 @@ "app_passwords": { "title": "应用密码", "description": "为不支持双因素身份验证的应用创建密码", - "add": "添加", + "new": "新建应用密码", "create": "创建", "cancel": "取消", "generate": "生成", @@ -1966,6 +1968,7 @@ "api_keys": { "title": "API 密钥", "description": "为直接与服务器通信的脚本和集成创建 API 密钥", + "new": "新建 API 密钥", "name_label": "密钥名称", "name_placeholder": "如:备份脚本、CI runner", "copy_now_warning": "请立即复制此 API 密钥 - 它将不会再次显示。", @@ -2005,7 +2008,7 @@ "jmap_username_label": "JMAP 用户名", "copy": "复制", "copied": "已复制", - "password_instructions": "使用上面的 JMAP 用户名和应用密码登录您的邮件客户端。如果您还没有创建应用密码,请在上面的部分中创建。" + "password_instructions": "使用上面的 JMAP 用户名和应用密码登录您的邮件客户端。如果您还没有创建应用密码,请在下面的部分中创建。" }, "link_device": { "title": "关联移动应用", @@ -2047,6 +2050,8 @@ "key_placeholder": "在此粘贴您的公钥", "none": "未配置公钥", "title": "公钥", + "add": "添加公钥", + "add_submit": "添加", "added": "已添加公钥", "removed": "已删除公钥", "add_error": "无法添加公钥", @@ -2330,10 +2335,10 @@ "export_label": "导出联系人", "export_description": "将所有联系人导出为 vCard (.vcf) 文件", "manage_title": "地址簿", - "manage_description": "重命名您的地址簿", + "manage_description": "重命名或删除您的地址簿", "no_address_books": "未找到地址簿", "categories_title": "类别", - "categories_description": "重命名联系人类别", + "categories_description": "重命名或删除联系人类别", "no_categories": "未找到类别", "group_by_letter_description": "在联系人列表中显示按字母顺序排列的分节标题", "group_by_letter_label": "按首字母分组", @@ -2411,7 +2416,7 @@ "validation_error": "脚本有错误", "save": "保存", "saving": "保存中...", - "saved": "过滤器保存成功", + "saved": "过滤规则已保存", "save_failed": "无法保存过滤器", "loading": "正在加载过滤器...", "not_supported": "您的邮件服务器不支持邮件过滤器。", @@ -2479,6 +2484,7 @@ "label_name": "标签名称", "label_placeholder": "选择标签", "header_name": "标头名称", + "condition_value": "条件值", "header_placeholder": "例如,X-Mailing-List", "size_bytes": "大小(以字节为单位)", "size_placeholder": "例如,1000000", @@ -2506,6 +2512,10 @@ "validation_empty_name": "规则名称为必填项", "validation_empty_conditions": "至少需要一个具有值的条件", "validation_empty_actions": "至少需要执行一项操作", + "validation_action_folder": "请为此操作选择文件夹", + "validation_action_tag": "请为此操作选择标签", + "validation_action_forward": "请输入要转发到的地址", + "validation_action_reject": "请输入拒收消息", "validation_header_name": "标头名称只能包含字母 A-Z、数字、连字符和下划线", "templates_section": "从模板开始", "template_newsletters": "将订阅邮件移至文件夹", @@ -2518,12 +2528,12 @@ "script_content": "Sieve 脚本", "valid": "脚本有效", "invalid": "脚本有错误", - "save_warning": "保存将覆盖所有可视化规则,此操作无法撤销。再次点击\"保存\"以确认。", "validating": "正在验证...", "validate": "验证", "cancel": "取消", "save": "保存", - "confirm_save": "确认保存", + "overwrite_title": "替换可视化规则?", + "overwrite_message": "此脚本已不包含你在可视化编辑器中创建的规则。保存后它们将被替换,且无法撤销。", "validation_failed": "验证请求失败" }, "rule_summary": { @@ -3258,8 +3268,8 @@ "empty_filtered_hint": "请调整或清除筛选条件", "clear_search": "清除搜索", "import_vcard": "导入 vCard", - "delete_confirm_title": "删除联系人", - "delete_confirm": "您确定要删除此联系人吗?", + "delete_named_title": "删除“{name}”?", + "delete_details_message": "电子邮件地址、电话号码、备注及所有其他信息都将被删除。此操作无法撤销。", "local_mode": "联系人存储在本地(服务器不支持 JMAP 联系人)", "back_to_contacts": "返回联系人", "back_to_email": "返回邮件", @@ -3293,6 +3303,7 @@ "share": "共享通讯录", "new_contact_in_book": "在此通讯录中新建联系人", "delete": "删除通讯录", + "delete_title": "删除通讯录?", "confirm_delete": "删除「{name}」?此通讯录中的所有联系人将被移除。", "deleted": "通讯录已删除", "delete_failed": "删除通讯录失败", @@ -3470,7 +3481,7 @@ "save": "保存", "cancel": "取消", "discard_title": "放弃更改?", - "discard_message": "您的更改尚未保存。", + "discard_message": "你的更改尚未保存。", "discard": "放弃", "keep_editing": "继续编辑", "remove_entry": "移除", @@ -3497,7 +3508,7 @@ "edit": "编辑群组", "empty": "还没有群组", "delete_confirm_title": "删除群组", - "delete_confirm": "您确定要删除该群组吗?", + "delete_message": "该群组将被删除。其中的联系人会保留。", "name_label": "群组名称", "name_placeholder": "例如,团队、家庭", "name_required": "群组名称为必填项", @@ -3539,13 +3550,13 @@ "selected": "{count, plural, one {已选择 1 项} other {已选择 # 项}}", "select_all": "全选", "delete": "删除", - "delete_confirm_title": "删除联系人", "delete_confirm": "删除{count, plural, one {1 位联系人} other {# 位联系人}}?", "deleted": "{count, plural, one {已删除 1 位联系人} other {已删除 # 位联系人}}", "add_to_group": "添加到群组", "choose_group": "选择一个组", "new_group": "新建群组…", "adding_contacts": "正在添加{count, plural, one {1 位联系人} other {# 位联系人}}", + "moving_contacts": "正在移动{count, plural, one {1 位联系人} other {# 位联系人}}", "added_to_group": "联系人已添加至群组", "export": "导出", "clear": "清除选择" @@ -3776,6 +3787,10 @@ "recurrence_scope": { "edit_title": "编辑重复事件", "delete_title": "删除重复事件", + "delete_question": "删除重复事件?", + "delete_this_message": "仅删除“{title}”的这一次,且无法撤销。", + "delete_following_message": "将删除“{title}”的这一次及之后的所有重复,且无法撤销。", + "delete_all_message": "将删除“{title}”的所有重复,且无法撤销。", "description": "这是一个重复事件。您想修改哪些事件?", "this_event": "仅限本次活动", "this_and_future": "本次及后续活动", @@ -3982,13 +3997,14 @@ "color": "颜色", "change_color": "更改颜色", "random_color": "随机新颜色", - "add_calendar": "添加日历", + "add_calendar": "新建日历", "edit": "编辑", "delete": "删除", "save": "保存", "create": "创建", "cancel": "取消", "default": "默认", + "row_actions": "{name} 的操作", "confirm_delete": "删除\"{name}\"?此日历中的所有活动都将被删除。", "confirm_clear": "清除\"{name}\"中的所有活动吗?此操作无法撤销。", "set_default": "设为默认", diff --git a/stores/__tests__/file-store.test.ts b/stores/__tests__/file-store.test.ts index ec499372f..f79954b91 100644 --- a/stores/__tests__/file-store.test.ts +++ b/stores/__tests__/file-store.test.ts @@ -4,6 +4,7 @@ import type { IJMAPClient } from '@/lib/jmap/client-interface'; import type { FileNode } from '@/lib/jmap/types'; import { jmapError } from '@/lib/jmap/jmap-error'; import { fileErrorKind } from '@/lib/file-errors'; +import { TRASH_LOCATION } from '@/lib/files-trash'; // Minimal in-memory FileNode backend that models the JMAP hierarchy via // parentId links (the behaviour issue #379 asks for). @@ -854,6 +855,46 @@ describe('file-store folder tree and moves', () => { }); }); + describe('breadcrumb trail', () => { + const tree = () => makeMockClient([ + dir('docs', 'Documents', null), + dir('photos', 'Photos', null), + dir('trips', 'Trips', 'photos'), + dir('trash', '.Trash', null), + ]); + const trail = () => useFileStore.getState().pathStack.map(s => s.name); + + it('follows the real ancestors when a folder is picked in the tree, not the folder open before', async () => { + useFileStore.getState().initClient(tree()); + await useFileStore.getState().navigate(null); + await useFileStore.getState().navigate('docs', 'Documents'); + await useFileStore.getState().navigate('trips', 'Trips'); + expect(useFileStore.getState().currentPath).toBe('/Photos/Trips'); + expect(trail()).toEqual(['', 'Photos', 'Trips']); + + // Alt+Up goes to the real parent. + await useFileStore.getState().navigateUp(); + expect(useFileStore.getState().currentParentId).toBe('photos'); + expect(useFileStore.getState().currentPath).toBe('/Photos'); + }); + + it('leaves the Trash out of the trail of a folder opened from it', async () => { + useFileStore.getState().initClient(tree()); + await useFileStore.getState().navigate(null); + await useFileStore.getState().navigate(TRASH_LOCATION, '.Trash'); + await useFileStore.getState().navigate('photos', 'Photos'); + expect(useFileStore.getState().currentPath).toBe('/Photos'); + expect(trail()).toEqual(['', 'Photos']); + }); + + it('sets the full trail once the listing arrives, when the folder list was not loaded yet', async () => { + useFileStore.getState().initClient(tree()); + await useFileStore.getState().navigate('trips', 'Trips'); + expect(useFileStore.getState().currentPath).toBe('/Photos/Trips'); + expect(useFileStore.getState().pathStack.map(s => s.id)).toEqual([null, 'photos', 'trips']); + }); + }); + it('keeps every folder from the listing for the tree, at any depth', async () => { const client = makeMockClient([ dir('a', 'A', null), diff --git a/stores/file-store.ts b/stores/file-store.ts index a9834448e..ce5b1256b 100644 --- a/stores/file-store.ts +++ b/stores/file-store.ts @@ -501,6 +501,37 @@ function isUnder(path: string, base: string): boolean { return path === base || path.startsWith(base + '/'); } +type PathStep = { id: string | null; name: string }; + +/** + * The breadcrumb trail of an own-drive folder from its real ancestors: Home, + * then each folder down to `id`. null when the chain can't be followed (a + * parent isn't in `folders`) or runs through the hidden trash folder, which + * never shows in the breadcrumb. + */ +function ancestorStack( + folders: readonly { id: string; name: string; parentId?: string | null }[], + id: string, + trashId?: string, +): PathStep[] | null { + const byId = new Map(folders.map(f => [f.id, f])); + const trail: PathStep[] = []; + const seen = new Set(); + let at = byId.get(id); + while (at) { + if (seen.has(at.id) || at.id === trashId) return null; + seen.add(at.id); + trail.unshift({ id: at.id, name: at.name }); + if (at.parentId == null) return [{ id: null, name: '' }, ...trail]; + at = byId.get(at.parentId); + } + return null; +} + +function sameStack(a: PathStep[], b: PathStep[]): boolean { + return a.length === b.length && a.every((s, i) => s.id === b[i].id && s.name === b[i].name); +} + function buildPathFromStack(stack: { id: string | null; name: string }[]): string { if (stack.length <= 1) return '/'; return '/' + stack.slice(1).map(s => s.name).join('/'); @@ -842,7 +873,7 @@ export const useFileStore = create((set, get) => ({ }, navigate: async (parentId: string | null, name?: string) => { - const { client, pathStack, currentParentId: previousParentId, hasListed } = get(); + const { client, pathStack, currentParentId: previousParentId, hasListed, allFolders: knownFolders } = get(); if (!client) return; // A refresh lists the same folder again; plugins only hear about a new one. const opensFolder = !hasListed || parentId !== previousParentId; @@ -859,16 +890,23 @@ export const useFileStore = create((set, get) => ({ } 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 + // An own-drive folder: the trail is its real ancestors, wherever it was + // opened from (the tree, a favorite, the Trash). A folder of a shared + // drive builds on the trail openSharedNode set up. + const known = isCrossAccountId(parentId) ? null : ancestorStack(knownFolders, parentId); const existingIdx = pathStack.findIndex(s => s.id === parentId); - if (existingIdx >= 0) { + if (known) { + newStack = known; + } else if (existingIdx >= 0) { newStack = pathStack.slice(0, existingIdx + 1); } else { - newStack = [...pathStack, { id: parentId, name: name || parentId }]; + // The Trash and shared-files pages are no folder to be inside of. + const base = pathStack.filter(s => s.id === null || (!isTrashLocation(s.id) && sharedFilesOwner(s.id) === null)); + newStack = [...base, { id: parentId, name: name || parentId }]; } } - const newPath = buildPathFromStack(newStack); + let newPath = buildPathFromStack(newStack); set({ pathStack: newStack, currentPath: newPath }); try { localStorage.setItem('files-last-parent-id', parentId || ''); } catch { /* ignore */ } @@ -907,6 +945,18 @@ export const useFileStore = create((set, get) => ({ // The owner's folders above a shared one stay out of the tree as well. const allFolders = allNodes.filter(n => isFolder(n) && !n.discoverableOnly && !inTrash.has(n.id)).map(nodeToResource); + // The folder list was stale or empty (first visit, a link): set the + // trail again from the fresh one, unless another folder opened since. + if (parentId !== null && !isCrossAccountId(parentId) && !isTrashLocation(parentId) + && sharedFilesOwner(parentId) === null && get().currentParentId === parentId) { + const fresh = ancestorStack(allNodes.filter(n => isFolder(n)), parentId, trash?.id); + if (fresh && !sameStack(fresh, get().pathStack)) { + newPath = buildPathFromStack(fresh); + set({ pathStack: fresh, currentPath: newPath }); + try { localStorage.setItem('files-path-stack', JSON.stringify(fresh)); } catch { /* ignore */ } + } + } + // Prune recent files whose backing node no longer exists on the server // or is in the trash. const { recentFiles } = get();