mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
feat: search the screen you're on and import calendars and contacts from the desktop app's menus
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
import { act, cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Calendar } from '@/lib/jmap/types';
|
||||
import { useCalendarStore } from '@/stores/calendar-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useTaskStore } from '@/stores/task-store';
|
||||
|
||||
// pim-15: the desktop app's File > Import > Calendar…, on the whole calendar page
|
||||
// with the shell around it stubbed out.
|
||||
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useIsMobile: () => false,
|
||||
useIsDesktop: () => true,
|
||||
useIsFullDesktop: () => true,
|
||||
useIsTablet: () => false,
|
||||
useMediaQuery: () => false,
|
||||
}));
|
||||
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
|
||||
vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
|
||||
vi.mock('@/components/layout/inline-app-view', () => ({ InlineAppView: () => null }));
|
||||
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
|
||||
vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null }));
|
||||
vi.mock('@/hooks/use-refresh-gesture', () => ({ useRefreshGesture: () => ({ indicator: null }) }));
|
||||
vi.mock('@/hooks/use-calendar-push-refresh', () => ({ useCalendarPushRefresh: () => {} }));
|
||||
vi.mock('@/hooks/use-account-principal-emails', () => ({ useAccountPrincipalEmails: () => [] }));
|
||||
vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null }));
|
||||
const config = vi.hoisted(() => ({ demoMode: false }));
|
||||
vi.mock('@/hooks/use-config', () => ({ useConfig: () => config }));
|
||||
|
||||
import { CalendarApp } from '../calendar-app';
|
||||
import { requestAppCommand } from '@/lib/app-commands';
|
||||
|
||||
vi.setConfig({ testTimeout: 30000 });
|
||||
|
||||
const RIGHTS = {
|
||||
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
|
||||
mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
|
||||
};
|
||||
|
||||
const calendars = [{
|
||||
id: 'cal', name: 'Personal', color: '#123456', isDefault: true, sortOrder: 0, description: null,
|
||||
isSubscribed: true, isVisible: true, includeInAvailability: 'all', defaultAlertsWithTime: null,
|
||||
defaultAlertsWithoutTime: null, timeZone: null, shareWith: null, myRights: RIGHTS,
|
||||
}] as unknown as Calendar[];
|
||||
|
||||
const client = {
|
||||
getAccountId: () => 'acc',
|
||||
getMaxParticipantsPerEvent: () => null,
|
||||
supportsCalendars: () => true,
|
||||
queryCalendarEvents: vi.fn().mockResolvedValue([]),
|
||||
getCalendarEvent: vi.fn().mockResolvedValue(null),
|
||||
getCalendarTasks: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
config.demoMode = false;
|
||||
useAuthStore.setState({ isAuthenticated: true, client: client as never, isLoading: false, activeAccountId: 'acc' });
|
||||
useCalendarStore.setState({
|
||||
calendars, events: [], supportsCalendar: true, selectedCalendarIds: ['cal'], viewMode: 'week',
|
||||
selectedDate: new Date(2026, 9, 12), isLoading: false, isLoadingEvents: false, error: null,
|
||||
fetchCalendars: vi.fn(), fetchEvents: vi.fn(), refreshAllSubscriptions: vi.fn(),
|
||||
} as never);
|
||||
useSettingsStore.setState({ enableCalendarTasks: true, proInterface: false } as never);
|
||||
useTaskStore.setState({ tasks: [] } as never);
|
||||
});
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('Calendar in the desktop menus', () => {
|
||||
it('File > Import > Calendar… opens the calendar import', () => {
|
||||
render(<CalendarApp />);
|
||||
expect(screen.queryByRole('dialog', { name: 'title' })).toBeNull();
|
||||
let taken = false;
|
||||
act(() => { taken = requestAppCommand('import-calendar'); });
|
||||
expect(taken).toBe(true);
|
||||
expect(screen.getByRole('dialog', { name: 'title' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens nothing in demo mode, where the toolbar has no import either', () => {
|
||||
config.demoMode = true;
|
||||
render(<CalendarApp />);
|
||||
act(() => { requestAppCommand('import-calendar'); });
|
||||
expect(screen.queryByRole('dialog', { name: 'title' })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1677,6 +1677,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
useAppCommands({
|
||||
"new-event": () => openCreateModal(),
|
||||
"new-task": () => { if (enableCalendarTasks) openCreateTaskModal(); },
|
||||
"import-calendar": () => { if (!demoMode) setShowImportModal(true); },
|
||||
}, true);
|
||||
// Its View menu's Day, Week, Month and Agenda, and Edit > Search events.
|
||||
const viewFromMenu = (mode: CalendarViewMode) => () => { setMobileReturnToMonth(false); leaveSearch(); setViewMode(mode); };
|
||||
@@ -1693,6 +1694,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
"view.calendar.agenda": { enabled: true, checked: normalizedViewMode === "agenda" },
|
||||
"edit.search": normalizedViewMode !== "tasks",
|
||||
"file.newTask": enableCalendarTasks,
|
||||
"file.importCalendar": !demoMode,
|
||||
});
|
||||
|
||||
useRegisterCommands(tSidebar("calendar"), [
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ContactCard } from '@/lib/jmap/types';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { BulwarkDesktopBridge } from '@/lib/desktop/types';
|
||||
|
||||
// pim-15: the desktop app's Edit > Search contacts and File > Import > Contacts…,
|
||||
// on the whole Contacts app with the shell around it stubbed out.
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn(), back: vi.fn() }),
|
||||
useParams: () => ({ locale: 'en' }),
|
||||
usePathname: () => '/en/contacts',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
vi.mock('@/components/contacts/contact-activity', () => ({ ContactActivity: () => null }));
|
||||
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => <nav data-testid="rail" /> }));
|
||||
vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
|
||||
vi.mock('@/components/layout/inline-app-view', () => ({ InlineAppView: () => null }));
|
||||
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
|
||||
vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null }));
|
||||
vi.mock('@/hooks/use-sidebar-apps', () => ({
|
||||
useSidebarApps: () => ({
|
||||
showAppsModal: false, inlineApp: null, loadedApps: [], handleManageApps: vi.fn(),
|
||||
handleInlineApp: vi.fn(), closeInlineApp: vi.fn(), closeAppsModal: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { ContactsApp } from '../contacts-app';
|
||||
import { requestAppCommand } from '@/lib/app-commands';
|
||||
import { resetDesktopCommands, runDesktopCommand } from '@/lib/desktop/commands';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useContactStore } from '@/stores/contact-store';
|
||||
|
||||
const alice: ContactCard = {
|
||||
id: 'c-alice',
|
||||
addressBookIds: {},
|
||||
name: { components: [{ kind: 'given', value: 'Alice' }, { kind: 'surname', value: 'Smith' }], isOrdered: true },
|
||||
emails: { e0: { address: 'alice@example.com' } },
|
||||
};
|
||||
|
||||
const FAKE_CLIENT = { supportsPrincipals: () => false } as unknown as IJMAPClient;
|
||||
|
||||
function renderContacts() {
|
||||
act(() => {
|
||||
useAuthStore.setState({ isAuthenticated: true, client: FAKE_CLIENT, isLoading: false });
|
||||
useContactStore.setState({
|
||||
contacts: [alice],
|
||||
addressBooks: [],
|
||||
selectedContactId: null,
|
||||
selectedContactIds: new Set(),
|
||||
searchQuery: '',
|
||||
supportsSync: false,
|
||||
contactsLoaded: true,
|
||||
});
|
||||
});
|
||||
return render(<ContactsApp />);
|
||||
}
|
||||
|
||||
function fakeBridge() {
|
||||
const setState = vi.fn();
|
||||
window.bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState, onCommand: () => () => {} } } as unknown as BulwarkDesktopBridge;
|
||||
return { setState };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.bulwarkDesktop = undefined;
|
||||
resetDesktopCommands();
|
||||
act(() => {
|
||||
useAuthStore.setState({ isAuthenticated: false, client: null });
|
||||
useContactStore.setState({ contacts: [], selectedContactId: null, selectedContactIds: new Set() });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Contacts in the desktop menus', () => {
|
||||
it('Edit > Search contacts is on offer and puts the cursor in the contacts search', async () => {
|
||||
const shell = fakeBridge();
|
||||
renderContacts();
|
||||
await waitFor(() => expect(shell.setState).toHaveBeenCalled());
|
||||
expect(shell.setState.mock.lastCall![0]).toMatchObject({ 'edit.search': true });
|
||||
let ran = false;
|
||||
act(() => { ran = runDesktopCommand('edit.search'); });
|
||||
expect(ran).toBe(true);
|
||||
expect(document.activeElement).toBe(screen.getAllByRole('searchbox', { name: 'search_placeholder' })[0]);
|
||||
});
|
||||
|
||||
it('File > Import > Contacts… opens the contacts import', () => {
|
||||
renderContacts();
|
||||
expect(screen.queryByRole('dialog', { name: 'import.title' })).toBeNull();
|
||||
let taken = false;
|
||||
act(() => { taken = requestAppCommand('import-contacts'); });
|
||||
expect(taken).toBe(true);
|
||||
expect(screen.getByRole('dialog', { name: 'import.title' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import { getContactDisplayName, getContactSortName, hasContactPhoto } from "@/st
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { isAppShortcutBlocked } from "@/lib/keyboard";
|
||||
import { isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts";
|
||||
import { useDesktopCommands } from "@/lib/desktop/commands";
|
||||
import { ContactRowsSkeleton } from "@/components/boot-skeleton/contacts-skeleton";
|
||||
|
||||
type TriState = boolean | null;
|
||||
@@ -389,6 +390,8 @@ export function ContactList({
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
// Edit > Search contacts in the desktop app's menus.
|
||||
useDesktopCommands({ "edit.search": () => searchRef.current?.focus() }, { "edit.search": true });
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full", className)}>
|
||||
|
||||
@@ -971,7 +971,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
};
|
||||
|
||||
// File > New contact in the desktop app's menus, from any app.
|
||||
useAppCommands({ "new-contact": () => guard(handleCreateNew)() }, contactsEnabled);
|
||||
useAppCommands({ "new-contact": () => guard(handleCreateNew)(), "import-contacts": () => setShowImportDialog(true) }, contactsEnabled);
|
||||
|
||||
// The command palette's Contacts group (Ctrl/⌘+K).
|
||||
useRegisterCommands(tSidebar("contacts"), [
|
||||
|
||||
@@ -136,6 +136,36 @@ describe('DesktopHost', () => {
|
||||
expect(state['section.mail']).toBeUndefined();
|
||||
});
|
||||
|
||||
// pim-15: File > Import has Mail…, Calendar… and Contacts….
|
||||
it('File > Import > Calendar… and Contacts… open that app with its import', async () => {
|
||||
const { peekParkedAppCommand } = await import('@/lib/app-commands');
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
await waitFor(() => expect(shell.setState).toHaveBeenCalled());
|
||||
expect(shell.setState.mock.lastCall![0]).toMatchObject({ 'file.import': true, 'file.importCalendar': true, 'file.importContacts': true });
|
||||
shell.run('file.importCalendar');
|
||||
expect(push).toHaveBeenLastCalledWith('/calendar');
|
||||
expect(peekParkedAppCommand()).toBe('import-calendar');
|
||||
shell.run('file.importContacts');
|
||||
expect(push).toHaveBeenLastCalledWith('/contacts');
|
||||
expect(peekParkedAppCommand()).toBe('import-contacts');
|
||||
});
|
||||
|
||||
// pim-15: Edit > Search searches what's on screen, so mail's search is only Mail's.
|
||||
it.each([
|
||||
['/', true],
|
||||
['/settings', true],
|
||||
['/calendar', false],
|
||||
['/contacts', false],
|
||||
['/files', false],
|
||||
])('on %s, the search in Edit goes to mail: %s', async (pathname, mail) => {
|
||||
route.pathname = pathname;
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
await waitFor(() => expect(shell.setState).toHaveBeenCalled());
|
||||
expect(shell.setState.mock.lastCall![0]['edit.search']).toBe(mail);
|
||||
});
|
||||
|
||||
it('runs a command from what\'s on the page first', () => {
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
|
||||
@@ -95,6 +95,17 @@ export function DesktopHost() {
|
||||
routerRef.current.push("/contacts");
|
||||
},
|
||||
"file.import": () => openMailImport(),
|
||||
// File > Import > Calendar… and Contacts…: that app's own import, once it's on screen.
|
||||
"file.importCalendar": () => {
|
||||
if (requestAppCommand("import-calendar")) return;
|
||||
parkAppCommand("import-calendar");
|
||||
routerRef.current.push("/calendar");
|
||||
},
|
||||
"file.importContacts": () => {
|
||||
if (requestAppCommand("import-contacts")) return;
|
||||
parkAppCommand("import-contacts");
|
||||
routerRef.current.push("/contacts");
|
||||
},
|
||||
"go.inbox": () => routerRef.current.push("/"),
|
||||
"go.mail": () => routerRef.current.push("/"),
|
||||
"go.calendar": () => routerRef.current.push("/calendar"),
|
||||
@@ -159,9 +170,12 @@ export function DesktopHost() {
|
||||
"file.newTask": signedIn,
|
||||
"file.newContact": signedIn,
|
||||
"file.import": signedIn && !popout,
|
||||
"file.importCalendar": signedIn && !popout,
|
||||
"file.importContacts": signedIn && !popout,
|
||||
"file.settings": signedIn,
|
||||
[`section.${section}`]: true,
|
||||
"edit.search": signedIn && !popout,
|
||||
// Search mail. Calendar, Contacts and Files search themselves, and say so while they can.
|
||||
"edit.search": signedIn && !popout && (section === "mail" || section === "settings"),
|
||||
"go.inbox": signedIn,
|
||||
"go.mail": signedIn,
|
||||
"go.calendar": signedIn,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import { FileBrowser } from '../file-browser';
|
||||
import { useFileStore, type FileResource } from '@/stores/file-store';
|
||||
import { resetDesktopCommands, runDesktopCommand } from '@/lib/desktop/commands';
|
||||
import type { BulwarkDesktopBridge } from '@/lib/desktop/types';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
// pim-15: the desktop app's Edit > Search files.
|
||||
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
vi.mock('@/components/settings/share-collection-dialog', () => ({ ShareCollectionDialog: () => null }));
|
||||
|
||||
const NOTES = {
|
||||
id: 'f-notes', name: 'notes.txt', serverName: 'notes.txt', isDirectory: false, contentType: 'text/plain',
|
||||
contentLength: 10, lastModified: '2026-10-01T00:00:00Z', blobId: 'b-notes', parentId: null,
|
||||
} as FileResource;
|
||||
|
||||
function renderBrowser() {
|
||||
const client = { listAllFileNodesAcrossAccounts: vi.fn(async () => []) } as unknown as IJMAPClient;
|
||||
act(() => {
|
||||
useFileStore.setState({ client, sharedRoots: [], allFolders: [], currentParentId: null, pathStack: [{ id: null, name: '' }], hasListed: true });
|
||||
});
|
||||
const noop = vi.fn(async () => {});
|
||||
render(
|
||||
<FileBrowser
|
||||
currentPath="/"
|
||||
resources={[NOTES]}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
selectedResources={new Set()}
|
||||
uploadProgress={null}
|
||||
clipboard={null}
|
||||
onCreateFolder={noop}
|
||||
onUploadFiles={noop}
|
||||
onUploadFolder={noop}
|
||||
onCancelUpload={vi.fn()}
|
||||
onDelete={noop}
|
||||
onBatchDelete={noop}
|
||||
onRename={noop}
|
||||
onBatchDownload={noop}
|
||||
onRefresh={noop}
|
||||
onClearSelection={vi.fn()}
|
||||
onCut={vi.fn()}
|
||||
onCopy={vi.fn()}
|
||||
onPaste={noop}
|
||||
onMoveTo={noop}
|
||||
onPreviewImage={vi.fn()}
|
||||
onCreateFile={noop}
|
||||
onDuplicate={noop}
|
||||
getImageUrl={async () => ''}
|
||||
favorites={[]}
|
||||
recentFiles={[]}
|
||||
onToggleFavorite={vi.fn()}
|
||||
onOpenRecent={vi.fn()}
|
||||
client={client}
|
||||
ownAccountId="a"
|
||||
onNavigate={vi.fn()}
|
||||
onSetSelection={vi.fn()}
|
||||
onToggleSelect={vi.fn()}
|
||||
onPreviewFile={vi.fn()}
|
||||
onDownload={noop}
|
||||
onRenameNode={noop}
|
||||
onDeleteNode={noop}
|
||||
onDownloadNode={noop}
|
||||
onRevealNode={noop}
|
||||
onRemoveRecent={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.bulwarkDesktop = undefined;
|
||||
resetDesktopCommands();
|
||||
});
|
||||
|
||||
describe('Files in the desktop menus', () => {
|
||||
it('Edit > Search files is on offer and puts the cursor in the files search', async () => {
|
||||
const setState = vi.fn();
|
||||
window.bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState, onCommand: () => () => {} } } as unknown as BulwarkDesktopBridge;
|
||||
renderBrowser();
|
||||
await waitFor(() => expect(setState).toHaveBeenCalled());
|
||||
expect(setState.mock.lastCall![0]).toMatchObject({ 'edit.search': true });
|
||||
let ran = false;
|
||||
act(() => { ran = runDesktopCommand('edit.search'); });
|
||||
expect(ran).toBe(true);
|
||||
expect(document.activeElement).toBe(screen.getByRole('searchbox', { name: 'search_placeholder' }));
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,7 @@ import type { FileNodeRights } from "@/lib/jmap/types";
|
||||
import type { FileResourceView } from "@/lib/plugin-types";
|
||||
import { isAppShortcutBlocked, isArrowKeyWidgetTarget } from "@/lib/keyboard";
|
||||
import { isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts";
|
||||
import { useDesktopCommands } from "@/lib/desktop/commands";
|
||||
import { FilesListingSkeleton } from "@/components/boot-skeleton/files-skeleton";
|
||||
|
||||
const NO_FORMATS: string[] = [];
|
||||
@@ -754,6 +755,9 @@ export function FileBrowser({
|
||||
return Math.max(1, tops.filter((top) => top === tops[0]).length);
|
||||
}, [focusedName]);
|
||||
|
||||
// Edit > Search files in the desktop app's menus.
|
||||
useDesktopCommands({ "edit.search": () => searchInputRef.current?.focus() }, { "edit.search": !accountPickerMode });
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (isTypingTarget(e.target) || isAppShortcutBlocked(e)) return;
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export type AppCommand = 'compose' | 'search' | 'all-inboxes' | 'show-list' | 'tour-end' | 'new-event' | 'new-task' | 'new-contact';
|
||||
export type AppCommand = 'compose' | 'search' | 'all-inboxes' | 'show-list' | 'tour-end' | 'new-event' | 'new-task' | 'new-contact' | 'import-calendar' | 'import-contacts';
|
||||
|
||||
export const COMMAND_PALETTE_OPEN_EVENT = 'bulwark:command-palette';
|
||||
|
||||
|
||||
@@ -126,6 +126,12 @@ export function shellLabels(t: (key: string, values?: Record<string, string>) =>
|
||||
menuNewContact: t('contacts.create_new'),
|
||||
menuImport: t('mail_import.menu_import'),
|
||||
menuSearchEvents: t('calendar.search.title'),
|
||||
menuSearchContacts: t('ux_pim_menu.search_contacts'),
|
||||
menuSearchFiles: t('ux_pim_menu.search_files'),
|
||||
menuImportMenu: t('ux_pim_menu.import'),
|
||||
menuImportMail: t('ux_pim_menu.import_mail'),
|
||||
menuImportCalendar: t('ux_pim_menu.import_calendar'),
|
||||
menuImportContacts: t('ux_pim_menu.import_contacts'),
|
||||
menuDay: t('calendar.views.day'),
|
||||
menuWeek: t('calendar.views.week'),
|
||||
menuMonth: t('calendar.views.month'),
|
||||
|
||||
@@ -7261,6 +7261,14 @@
|
||||
"copy_failed": "تعذّر نسخ الملف.",
|
||||
"reply_lost": "تعذّر الاحتفاظ بالرد الذي كتبته. افتح الرسالة وأجب من هناك."
|
||||
},
|
||||
"ux_pim_menu": {
|
||||
"search_contacts": "البحث في جهات الاتصال",
|
||||
"search_files": "البحث في الملفات",
|
||||
"import": "استيراد",
|
||||
"import_mail": "البريد…",
|
||||
"import_calendar": "التقويم…",
|
||||
"import_contacts": "جهات الاتصال…"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "تعديل",
|
||||
"edit_named": "تعديل {name}",
|
||||
|
||||
@@ -7261,6 +7261,14 @@
|
||||
"copy_failed": "No s'ha pogut copiar el fitxer.",
|
||||
"reply_lost": "No s'ha pogut conservar la resposta que heu escrit. Obriu el missatge i responeu des d'allà."
|
||||
},
|
||||
"ux_pim_menu": {
|
||||
"search_contacts": "Cerca contactes",
|
||||
"search_files": "Cerca fitxers",
|
||||
"import": "Importa",
|
||||
"import_mail": "Correu…",
|
||||
"import_calendar": "Calendari…",
|
||||
"import_contacts": "Contactes…"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Edita",
|
||||
"edit_named": "Edita {name}",
|
||||
|
||||
@@ -7261,6 +7261,14 @@
|
||||
"copy_failed": "Soubor se nepodařilo zkopírovat.",
|
||||
"reply_lost": "Napsanou odpověď se nepodařilo zachovat. Otevřete zprávu a odpovězte v ní."
|
||||
},
|
||||
"ux_pim_menu": {
|
||||
"search_contacts": "Hledat kontakty",
|
||||
"search_files": "Hledat soubory",
|
||||
"import": "Importovat",
|
||||
"import_mail": "Pošta…",
|
||||
"import_calendar": "Kalendář…",
|
||||
"import_contacts": "Kontakty…"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Upravit",
|
||||
"edit_named": "Upravit {name}",
|
||||
|
||||
@@ -7261,6 +7261,14 @@
|
||||
"copy_failed": "Filen kunne ikke kopieres.",
|
||||
"reply_lost": "Dit skrevne svar kunne ikke gemmes. Åbn beskeden, og svar der."
|
||||
},
|
||||
"ux_pim_menu": {
|
||||
"search_contacts": "Søg i kontakter",
|
||||
"search_files": "Søg i filer",
|
||||
"import": "Importér",
|
||||
"import_mail": "Mail…",
|
||||
"import_calendar": "Kalender…",
|
||||
"import_contacts": "Kontakter…"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Rediger",
|
||||
"edit_named": "Rediger {name}",
|
||||
|
||||
@@ -7261,6 +7261,14 @@
|
||||
"copy_failed": "Die Datei konnte nicht kopiert werden.",
|
||||
"reply_lost": "Ihre eingegebene Antwort konnte nicht übernommen werden. Öffnen Sie die Nachricht und antworten Sie dort."
|
||||
},
|
||||
"ux_pim_menu": {
|
||||
"search_contacts": "Kontakte durchsuchen",
|
||||
"search_files": "Dateien durchsuchen",
|
||||
"import": "Importieren",
|
||||
"import_mail": "E-Mails…",
|
||||
"import_calendar": "Kalender…",
|
||||
"import_contacts": "Kontakte…"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Bearbeiten",
|
||||
"edit_named": "{name} bearbeiten",
|
||||
|
||||
@@ -7261,6 +7261,14 @@
|
||||
"copy_failed": "The file could not be copied.",
|
||||
"reply_lost": "Your typed reply could not be kept. Open the message and reply there."
|
||||
},
|
||||
"ux_pim_menu": {
|
||||
"search_contacts": "Search contacts",
|
||||
"search_files": "Search files",
|
||||
"import": "Import",
|
||||
"import_mail": "Mail…",
|
||||
"import_calendar": "Calendar…",
|
||||
"import_contacts": "Contacts…"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Edit",
|
||||
"edit_named": "Edit {name}",
|
||||
|
||||
@@ -7261,6 +7261,14 @@
|
||||
"copy_failed": "No se pudo copiar el archivo.",
|
||||
"reply_lost": "No se pudo conservar la respuesta que escribiste. Abre el mensaje y responde desde allí."
|
||||
},
|
||||
"ux_pim_menu": {
|
||||
"search_contacts": "Buscar contactos",
|
||||
"search_files": "Buscar archivos",
|
||||
"import": "Importar",
|
||||
"import_mail": "Correo…",
|
||||
"import_calendar": "Calendario…",
|
||||
"import_contacts": "Contactos…"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Editar",
|
||||
"edit_named": "Editar {name}",
|
||||
|
||||
@@ -7261,6 +7261,14 @@
|
||||
"copy_failed": "Faili ei saanud kopeerida.",
|
||||
"reply_lost": "Kirjutatud vastust ei saanud säilitada. Ava kiri ja vasta seal."
|
||||
},
|
||||
"ux_pim_menu": {
|
||||
"search_contacts": "Otsi kontakte",
|
||||
"search_files": "Otsi faile",
|
||||
"import": "Impordi",
|
||||
"import_mail": "Post…",
|
||||
"import_calendar": "Kalender…",
|
||||
"import_contacts": "Kontaktid…"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Muuda",
|
||||
"edit_named": "Muuda faili {name}",
|
||||
|
||||
@@ -7261,6 +7261,14 @@
|
||||
"copy_failed": "رونوشت پرونده ممکن نشد.",
|
||||
"reply_lost": "پاسخی که نوشتید نگه داشته نشد. پیام را باز کنید و از آنجا پاسخ دهید."
|
||||
},
|
||||
"ux_pim_menu": {
|
||||
"search_contacts": "جستجوی مخاطبین",
|
||||
"search_files": "جستجوی پروندهها",
|
||||
"import": "وارد کردن",
|
||||
"import_mail": "نامه…",
|
||||
"import_calendar": "تقویم…",
|
||||
"import_contacts": "مخاطبین…"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "ویرایش",
|
||||
"edit_named": "ویرایش {name}",
|
||||
|
||||
@@ -7261,6 +7261,14 @@
|
||||
"copy_failed": "Le fichier n'a pas pu être copié.",
|
||||
"reply_lost": "Votre réponse saisie n'a pas pu être conservée. Ouvrez le message et répondez depuis celui-ci."
|
||||
},
|
||||
"ux_pim_menu": {
|
||||
"search_contacts": "Rechercher des contacts",
|
||||
"search_files": "Rechercher des fichiers",
|
||||
"import": "Importer",
|
||||
"import_mail": "Courrier…",
|
||||
"import_calendar": "Calendrier…",
|
||||
"import_contacts": "Contacts…"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Modifier",
|
||||
"edit_named": "Modifier {name}",
|
||||
|
||||
@@ -7262,6 +7262,14 @@
|
||||
"copy_failed": "לא ניתן להעתיק את הקובץ.",
|
||||
"reply_lost": "לא ניתן היה לשמור את התשובה שהקלדת. פתחו את ההודעה והשיבו משם."
|
||||
},
|
||||
"ux_pim_menu": {
|
||||
"search_contacts": "חיפוש אנשי קשר",
|
||||
"search_files": "חיפוש קבצים",
|
||||
"import": "ייבוא",
|
||||
"import_mail": "דואר…",
|
||||
"import_calendar": "לוח שנה…",
|
||||
"import_contacts": "אנשי קשר…"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "עריכה",
|
||||
"edit_named": "עריכת {name}",
|
||||
|
||||
@@ -7261,6 +7261,14 @@
|
||||
"copy_failed": "A fájlt nem sikerült másolni.",
|
||||
"reply_lost": "A beírt válasz nem maradt meg. Nyissa meg az üzenetet, és válaszoljon ott."
|
||||
},
|
||||
"ux_pim_menu": {
|
||||
"search_contacts": "Névjegyek keresése",
|
||||
"search_files": "Fájlok keresése",
|
||||
"import": "Importálás",
|
||||
"import_mail": "Levelek…",
|
||||
"import_calendar": "Naptár…",
|
||||
"import_contacts": "Névjegyek…"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Szerkesztés",
|
||||
"edit_named": "{name} szerkesztése",
|
||||
|
||||
@@ -7261,6 +7261,14 @@
|
||||
"copy_failed": "Impossibile copiare il file.",
|
||||
"reply_lost": "Non è stato possibile conservare la risposta digitata. Apri il messaggio e rispondi da lì."
|
||||
},
|
||||
"ux_pim_menu": {
|
||||
"search_contacts": "Cerca contatti",
|
||||
"search_files": "Cerca file",
|
||||
"import": "Importa",
|
||||
"import_mail": "Posta…",
|
||||
"import_calendar": "Calendario…",
|
||||
"import_contacts": "Contatti…"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Modifica",
|
||||
"edit_named": "Modifica {name}",
|
||||
|
||||
@@ -7261,6 +7261,14 @@
|
||||
"copy_failed": "ファイルをコピーできませんでした。",
|
||||
"reply_lost": "入力した返信を保持できませんでした。メッセージを開いて返信してください。"
|
||||
},
|
||||
"ux_pim_menu": {
|
||||
"search_contacts": "連絡先を検索",
|
||||
"search_files": "ファイルを検索",
|
||||
"import": "インポート",
|
||||
"import_mail": "メール…",
|
||||
"import_calendar": "カレンダー…",
|
||||
"import_contacts": "連絡先…"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "編集",
|
||||
"edit_named": "{name} を編集",
|
||||
|
||||
@@ -7261,6 +7261,14 @@
|
||||
"copy_failed": "파일을 복사할 수 없습니다.",
|
||||
"reply_lost": "입력한 답장을 유지할 수 없습니다. 메시지를 열어 답장하세요."
|
||||
},
|
||||
"ux_pim_menu": {
|
||||
"search_contacts": "연락처 검색",
|
||||
"search_files": "파일 검색",
|
||||
"import": "가져오기",
|
||||
"import_mail": "메일…",
|
||||
"import_calendar": "캘린더…",
|
||||
"import_contacts": "연락처…"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "편집",
|
||||
"edit_named": "{name} 편집",
|
||||
|
||||
@@ -7261,6 +7261,14 @@
|
||||
"copy_failed": "Failu neizdevās nokopēt.",
|
||||
"reply_lost": "Ievadīto atbildi neizdevās saglabāt. Atveriet ziņojumu un atbildiet tur."
|
||||
},
|
||||
"ux_pim_menu": {
|
||||
"search_contacts": "Meklēt kontaktus",
|
||||
"search_files": "Meklēt failus",
|
||||
"import": "Importēt",
|
||||
"import_mail": "Pasts…",
|
||||
"import_calendar": "Kalendārs…",
|
||||
"import_contacts": "Kontakti…"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Rediģēt",
|
||||
"edit_named": "Rediģēt {name}",
|
||||
|
||||
@@ -7261,6 +7261,14 @@
|
||||
"copy_failed": "Файлыг хуулж чадсангүй.",
|
||||
"reply_lost": "Бичсэн хариуг хадгалж чадсангүй. Захиаг нээгээд тэндээс хариулна уу."
|
||||
},
|
||||
"ux_pim_menu": {
|
||||
"search_contacts": "Харилцагч хайх",
|
||||
"search_files": "Файл хайх",
|
||||
"import": "Импортлох",
|
||||
"import_mail": "Шуудан…",
|
||||
"import_calendar": "Хуанли…",
|
||||
"import_contacts": "Харилцагчид…"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Засах",
|
||||
"edit_named": "{name} засах",
|
||||
|
||||
@@ -7261,6 +7261,14 @@
|
||||
"copy_failed": "Filen kunne ikke kopieres.",
|
||||
"reply_lost": "Svaret du skrev, kunne ikke beholdes. Åpne meldingen og svar der."
|
||||
},
|
||||
"ux_pim_menu": {
|
||||
"search_contacts": "Søk i kontakter",
|
||||
"search_files": "Søk i filer",
|
||||
"import": "Importer",
|
||||
"import_mail": "E-post…",
|
||||
"import_calendar": "Kalender…",
|
||||
"import_contacts": "Kontakter…"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Rediger",
|
||||
"edit_named": "Rediger {name}",
|
||||
|
||||
@@ -7261,6 +7261,14 @@
|
||||
"copy_failed": "Het bestand kon niet worden gekopieerd.",
|
||||
"reply_lost": "Je getypte antwoord kon niet worden bewaard. Open het bericht en antwoord daar."
|
||||
},
|
||||
"ux_pim_menu": {
|
||||
"search_contacts": "Contacten zoeken",
|
||||
"search_files": "Bestanden zoeken",
|
||||
"import": "Importeren",
|
||||
"import_mail": "E-mail…",
|
||||
"import_calendar": "Agenda…",
|
||||
"import_contacts": "Contacten…"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Bewerken",
|
||||
"edit_named": "{name} bewerken",
|
||||
|
||||
@@ -7261,6 +7261,14 @@
|
||||
"copy_failed": "Nie udało się skopiować pliku.",
|
||||
"reply_lost": "Nie udało się zachować wpisanej odpowiedzi. Otwórz wiadomość i odpowiedz w niej."
|
||||
},
|
||||
"ux_pim_menu": {
|
||||
"search_contacts": "Szukaj kontaktów",
|
||||
"search_files": "Szukaj plików",
|
||||
"import": "Importuj",
|
||||
"import_mail": "Poczta…",
|
||||
"import_calendar": "Kalendarz…",
|
||||
"import_contacts": "Kontakty…"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Edytuj",
|
||||
"edit_named": "Edytuj {name}",
|
||||
|
||||
@@ -7261,6 +7261,14 @@
|
||||
"copy_failed": "Não foi possível copiar o ficheiro.",
|
||||
"reply_lost": "Não foi possível guardar a resposta escrita. Abra a mensagem e responda a partir dela."
|
||||
},
|
||||
"ux_pim_menu": {
|
||||
"search_contacts": "Pesquisar contactos",
|
||||
"search_files": "Pesquisar ficheiros",
|
||||
"import": "Importar",
|
||||
"import_mail": "Correio…",
|
||||
"import_calendar": "Calendário…",
|
||||
"import_contacts": "Contactos…"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Editar",
|
||||
"edit_named": "Editar {name}",
|
||||
|
||||
@@ -7261,6 +7261,14 @@
|
||||
"copy_failed": "Fișierul nu a putut fi copiat.",
|
||||
"reply_lost": "Răspunsul scris nu a putut fi păstrat. Deschide mesajul și răspunde de acolo."
|
||||
},
|
||||
"ux_pim_menu": {
|
||||
"search_contacts": "Caută contacte",
|
||||
"search_files": "Caută fișiere",
|
||||
"import": "Importă",
|
||||
"import_mail": "E-mail…",
|
||||
"import_calendar": "Calendar…",
|
||||
"import_contacts": "Contacte…"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Editează",
|
||||
"edit_named": "Editează {name}",
|
||||
|
||||
@@ -7261,6 +7261,14 @@
|
||||
"copy_failed": "Не удалось скопировать файл.",
|
||||
"reply_lost": "Не удалось сохранить введённый ответ. Откройте письмо и ответьте из него."
|
||||
},
|
||||
"ux_pim_menu": {
|
||||
"search_contacts": "Поиск контактов",
|
||||
"search_files": "Поиск файлов",
|
||||
"import": "Импорт",
|
||||
"import_mail": "Почта…",
|
||||
"import_calendar": "Календарь…",
|
||||
"import_contacts": "Контакты…"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Изменить",
|
||||
"edit_named": "Изменить {name}",
|
||||
|
||||
@@ -7261,6 +7261,14 @@
|
||||
"copy_failed": "Súbor sa nepodarilo skopírovať.",
|
||||
"reply_lost": "Napísanú odpoveď sa nepodarilo zachovať. Otvorte správu a odpovedzte v nej."
|
||||
},
|
||||
"ux_pim_menu": {
|
||||
"search_contacts": "Hľadať kontakty",
|
||||
"search_files": "Hľadať súbory",
|
||||
"import": "Importovať",
|
||||
"import_mail": "Pošta…",
|
||||
"import_calendar": "Kalendár…",
|
||||
"import_contacts": "Kontakty…"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Upraviť",
|
||||
"edit_named": "Upraviť {name}",
|
||||
|
||||
@@ -7269,6 +7269,14 @@
|
||||
"copy_failed": "คัดลอกไฟล์ไม่ได้",
|
||||
"reply_lost": "ไม่สามารถเก็บคำตอบที่พิมพ์ไว้ได้ เปิดข้อความแล้วตอบจากที่นั่น"
|
||||
},
|
||||
"ux_pim_menu": {
|
||||
"search_contacts": "ค้นหาผู้ติดต่อ",
|
||||
"search_files": "ค้นหาไฟล์",
|
||||
"import": "นำเข้า",
|
||||
"import_mail": "อีเมล…",
|
||||
"import_calendar": "ปฏิทิน…",
|
||||
"import_contacts": "ผู้ติดต่อ…"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "แก้ไข",
|
||||
"edit_named": "แก้ไข {name}",
|
||||
|
||||
@@ -7261,6 +7261,14 @@
|
||||
"copy_failed": "Dosya kopyalanamadı.",
|
||||
"reply_lost": "Yazdığınız yanıt korunamadı. İletiyi açıp oradan yanıtlayın."
|
||||
},
|
||||
"ux_pim_menu": {
|
||||
"search_contacts": "Kişilerde ara",
|
||||
"search_files": "Dosyalarda ara",
|
||||
"import": "İçe aktar",
|
||||
"import_mail": "E-posta…",
|
||||
"import_calendar": "Takvim…",
|
||||
"import_contacts": "Kişiler…"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Düzenle",
|
||||
"edit_named": "{name} dosyasını düzenle",
|
||||
|
||||
@@ -7261,6 +7261,14 @@
|
||||
"copy_failed": "Не вдалося скопіювати файл.",
|
||||
"reply_lost": "Не вдалося зберегти введену відповідь. Відкрийте лист і дайте відповідь у ньому."
|
||||
},
|
||||
"ux_pim_menu": {
|
||||
"search_contacts": "Пошук контактів",
|
||||
"search_files": "Пошук файлів",
|
||||
"import": "Імпорт",
|
||||
"import_mail": "Пошта…",
|
||||
"import_calendar": "Календар…",
|
||||
"import_contacts": "Контакти…"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Редагувати",
|
||||
"edit_named": "Редагувати {name}",
|
||||
|
||||
@@ -7273,6 +7273,14 @@
|
||||
"copy_failed": "Không thể sao chép tệp.",
|
||||
"reply_lost": "Không thể giữ câu trả lời bạn đã nhập. Hãy mở thư và trả lời ở đó."
|
||||
},
|
||||
"ux_pim_menu": {
|
||||
"search_contacts": "Tìm liên hệ",
|
||||
"search_files": "Tìm tệp",
|
||||
"import": "Nhập",
|
||||
"import_mail": "Thư…",
|
||||
"import_calendar": "Lịch…",
|
||||
"import_contacts": "Danh bạ…"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Chỉnh sửa",
|
||||
"edit_named": "Chỉnh sửa {name}",
|
||||
|
||||
@@ -7261,6 +7261,14 @@
|
||||
"copy_failed": "無法複製檔案。",
|
||||
"reply_lost": "無法保留您輸入的回覆。請開啟郵件並在其中回覆。"
|
||||
},
|
||||
"ux_pim_menu": {
|
||||
"search_contacts": "搜尋聯絡人",
|
||||
"search_files": "搜尋檔案",
|
||||
"import": "匯入",
|
||||
"import_mail": "郵件…",
|
||||
"import_calendar": "行事曆…",
|
||||
"import_contacts": "聯絡人…"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "編輯",
|
||||
"edit_named": "編輯 {name}",
|
||||
|
||||
@@ -7261,6 +7261,14 @@
|
||||
"copy_failed": "无法复制文件。",
|
||||
"reply_lost": "无法保留你输入的回复。请打开邮件并在其中回复。"
|
||||
},
|
||||
"ux_pim_menu": {
|
||||
"search_contacts": "搜索联系人",
|
||||
"search_files": "搜索文件",
|
||||
"import": "导入",
|
||||
"import_mail": "邮件…",
|
||||
"import_calendar": "日历…",
|
||||
"import_contacts": "联系人…"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "编辑",
|
||||
"edit_named": "编辑 {name}",
|
||||
|
||||
Reference in New Issue
Block a user