feat: search the screen you're on and import calendars and contacts from the desktop app's menus

This commit is contained in:
Linus Rath
2026-11-04 10:51:19 +01:00
parent d783981373
commit 22903cdfc0
41 changed files with 574 additions and 3 deletions
@@ -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();
});
});
+2
View File
@@ -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();
});
});
+3
View File
@@ -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)}>
+1 -1
View File
@@ -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 />);
+15 -1
View File
@@ -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' }));
});
});
+4
View File
@@ -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
View File
@@ -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';
+6
View File
@@ -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'),
+8
View File
@@ -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}",
+8
View File
@@ -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}",
+8
View File
@@ -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}",
+8
View File
@@ -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}",
+8
View File
@@ -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",
+8
View File
@@ -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}",
+8
View File
@@ -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}",
+8
View File
@@ -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}",
+8
View File
@@ -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}",
+8
View File
@@ -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}",
+8
View File
@@ -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}",
+8
View File
@@ -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",
+8
View File
@@ -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}",
+8
View File
@@ -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} を編集",
+8
View File
@@ -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} 편집",
+8
View File
@@ -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}",
+8
View File
@@ -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} засах",
+8
View File
@@ -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}",
+8
View File
@@ -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",
+8
View File
@@ -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}",
+8
View File
@@ -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}",
+8
View File
@@ -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}",
+8
View File
@@ -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}",
+8
View File
@@ -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}",
+8
View File
@@ -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}",
+8
View File
@@ -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",
+8
View File
@@ -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}",
+8
View File
@@ -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}",
+8
View File
@@ -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}",
+8
View File
@@ -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}",