feat: upload files dropped on the Files screen without asking, and add several .ics or .vcf in one import

This commit is contained in:
Linus Rath
2026-11-04 13:56:09 +01:00
parent a0165cdcd5
commit b70ab8a50c
40 changed files with 902 additions and 18 deletions
@@ -146,16 +146,22 @@ describe('a drag from outside', () => {
clear();
});
it('on the Files screen, "Upload to <folder>" hands the files to that folder', async () => {
// pim-p2-6: on the Files screen a drop uploads where it lands, with no question; the
// list takes it (components/files). A file pasted there goes up into the folder in view.
it('on the Files screen, shows no choices and leaves the drop to the list', async () => {
const take = vi.fn();
const clear = setFilesDropTarget({ folder: 'Reports', take, visible: () => true });
render(<FileDropHost />);
dragIn([file]);
expect(zone('upload').textContent).toContain('zone_upload_folder:{"folder":"Reports"}');
fireEvent.drop(zone('upload'), { dataTransfer: transfer([file]) });
await flush();
expect(take).toHaveBeenCalledWith({ files: [file], asFolder: false });
clear();
try {
render(<FileDropHost />);
dragIn([file]);
expect(screen.queryByTestId('file-drop-overlay')).toBeNull();
act(() => requestFileChoice({ files: [file] }));
await flush();
expect(screen.queryByTestId('file-drop-choice')).toBeNull();
expect(take).toHaveBeenCalledWith({ files: [file], asFolder: false });
} finally {
clear();
}
});
it('from another screen, Files gets the files after the app goes there', async () => {
+4
View File
@@ -19,6 +19,7 @@ import { readDroppedFiles, type DroppedFiles } from "@/lib/file-drop/read";
import {
defaultZone,
fileDropZones,
filesTakeDropsAsIs,
filesWithinLimits,
kindForName,
type DropFiles,
@@ -72,6 +73,7 @@ function currentPlace(pathname: string, translate: (key: string) => string): { p
popout: isPopoutPath(window.location.pathname),
files: !!client?.supportsFiles?.() && features.isFeatureEnabled("filesEnabled"),
filesFolder: filesDropTarget()?.folder ?? null,
onFiles: filesDropTarget() !== null,
calendar: useCalendarStore.getState().supportsCalendar && features.isFeatureEnabled("calendarEnabled"),
contacts: !!client?.supportsContacts?.() && features.isFeatureEnabled("contactsEnabled"),
contactsScreen: matchSurface(pathname)?.surface === "contacts",
@@ -179,6 +181,8 @@ export function FileDropHost() {
enter: (files) => {
if (overlayBlocked()) return false;
const { place, folder } = currentPlace(ctx.current.pathname, translateMailbox);
// The Files screen takes the drop itself: the folder in view, or the folder row it lands on.
if (filesTakeDropsAsIs(place)) return false;
const zones = fileDropZones(files, place);
if (zones.length === 0) return false;
setActive(null);
+3 -2
View File
@@ -19,14 +19,15 @@ const ICONS: Record<FileDropZoneId, ComponentType<{ className?: string }>> = {
export function useZoneText() {
const t = useTranslations("fileDrop");
const tAll = useTranslations("ux_p2_pim_files");
const label = (zone: FileDropZone): string => {
switch (zone.id) {
case "attach-here": return t("zone_attach_here");
case "insert-inline": return t("zone_insert_inline");
case "new-message": return t("zone_new_message");
case "upload": return zone.folder ? t("zone_upload_folder", { folder: zone.folder }) : t("zone_upload_files");
case "calendar": return t("zone_calendar");
case "contacts": return t("zone_contacts");
case "calendar": return zone.several ? tAll("zone_calendar_all") : t("zone_calendar");
case "contacts": return zone.several ? tAll("zone_contacts_all") : t("zone_contacts");
case "open-eml": return t("zone_open_eml");
case "import-eml": return t("zone_import_eml", { folder: zone.folder ?? "" });
}
@@ -0,0 +1,64 @@
import { afterEach, describe, expect, it } from 'vitest';
import { act, renderHook, waitFor } from '@testing-library/react';
import { useHandoffFiles } from '../use-handoff-files';
import { handOffFiles, takeHandoff } from '@/lib/launch-handoff';
import { mergeCalendarTexts, mergeVCardTexts } from '@/lib/file-drop/merge-imports';
// pim-p2-10: several .ics or .vcf dropped together go into one import list.
const ics = (uid: string, title: string) => [
'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Test//EN',
'BEGIN:VEVENT', `UID:${uid}`, `SUMMARY:${title}`, 'DTSTART:20261110T090000Z', 'END:VEVENT',
'END:VCALENDAR', '',
].join('\r\n');
const vcf = (name: string) => ['BEGIN:VCARD', 'VERSION:3.0', `FN:${name}`, 'END:VCARD', ''].join('\r\n');
afterEach(() => {
for (const kind of ['calendar', 'contacts'] as const) while (takeHandoff(kind)) { /* drain */ }
});
describe('merging calendars and cards', () => {
it('puts every event of two calendars into one VCALENDAR', () => {
const merged = mergeCalendarTexts([ics('a', 'First'), ics('b', 'Second')]);
expect(merged.match(/BEGIN:VCALENDAR/g)).toHaveLength(1);
expect(merged.match(/END:VCALENDAR/g)).toHaveLength(1);
expect(merged.match(/BEGIN:VEVENT/g)).toHaveLength(2);
expect(merged).toContain('SUMMARY:First');
expect(merged).toContain('SUMMARY:Second');
expect(merged.match(/VERSION:2.0/g)).toHaveLength(1);
});
it('puts the cards one after the other', () => {
const merged = mergeVCardTexts([vcf('Anna'), vcf('Ben')]);
expect(merged.match(/BEGIN:VCARD/g)).toHaveLength(2);
});
});
describe('useHandoffFiles', () => {
it('gives two dropped .ics as one file to the import', async () => {
const { result } = renderHook(() => useHandoffFiles('calendar', true));
act(() => {
handOffFiles([
new File([ics('a', 'First')], 'first.ics', { type: 'text/calendar' }),
new File([ics('b', 'Second')], 'second.ics', { type: 'text/calendar' }),
], 'calendar');
});
await waitFor(() => expect(result.current.file).not.toBeNull());
const text = await result.current.file!.text();
expect(text).toContain('SUMMARY:First');
expect(text).toContain('SUMMARY:Second');
act(() => result.current.done());
expect(result.current.file).toBeNull();
});
it('gives three .vcf as one file', async () => {
const { result } = renderHook(() => useHandoffFiles('contacts', true));
act(() => {
handOffFiles(['Anna', 'Ben', 'Cleo'].map((n) => new File([vcf(n)], `${n}.vcf`, { type: 'text/vcard' })), 'contacts');
});
await waitFor(() => expect(result.current.file).not.toBeNull());
expect((await result.current.file!.text()).match(/BEGIN:VCARD/g)).toHaveLength(3);
act(() => result.current.done());
expect(result.current.file).toBeNull();
});
});
+12 -2
View File
@@ -2,11 +2,14 @@
import { useCallback, useEffect, useState } from "react";
import { subscribeHandoff, takeHandoff, type HandoffKind } from "@/lib/launch-handoff";
import { mergeImportFiles } from "@/lib/file-drop/merge-imports";
/**
* The files handed to this part of the app (lib/launch-handoff.ts), one at a
* time. Nothing is taken while `active` is false, for a surface that isn't
* ready for them yet. `done` moves on to the next file.
* ready for them yet. `done` moves on to the next file. Several calendars or
* address cards handed over together come as one file, so one import lists
* them all (pim-p2-10).
*/
export function useHandoffFiles(kind: HandoffKind, active: boolean): { file: File | null; done: () => void } {
const [files, setFiles] = useState<File[]>([]);
@@ -16,7 +19,14 @@ export function useHandoffFiles(kind: HandoffKind, active: boolean): { file: Fil
const take = () => {
for (let handoff = takeHandoff(kind); handoff; handoff = takeHandoff(kind)) {
const received = handoff.files;
if (received.length > 0) setFiles((waiting) => [...waiting, ...received]);
if (received.length > 1 && (kind === "calendar" || kind === "contacts")) {
void mergeImportFiles(kind, received).then(
(merged) => setFiles((waiting) => [...waiting, merged]),
() => setFiles((waiting) => [...waiting, ...received]),
);
} else if (received.length > 0) {
setFiles((waiting) => [...waiting, ...received]);
}
}
};
take();
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { fileDropZones, filesTakeDropsAsIs, type DropFiles, type DropPlace } from '../zones';
// Usability pass 2 (pim-p2-6, pim-p2-10).
const place = (patch: Partial<DropPlace> = {}): DropPlace => ({
composer: false,
inlineImages: false,
popout: false,
files: true,
filesFolder: null,
calendar: true,
contacts: true,
importFolder: null,
offline: false,
keepsOffline: false,
maxUploadBytes: 0,
maxMessageBytes: 0,
...patch,
});
const drag = (kinds: DropFiles['kinds']): DropFiles => ({ count: kinds?.length ?? null, kinds, bytes: null });
describe('on the Files screen a drop uploads where you are, with no question (pim-p2-6)', () => {
it('has only the upload into the folder in view, for any kind of file', () => {
for (const kinds of [['other'], ['calendar'], ['eml', 'eml'], ['image']] as DropFiles['kinds'][]) {
expect(fileDropZones(drag(kinds), place({ onFiles: true, filesFolder: 'Documents' }))).toEqual([{ id: 'upload', folder: 'Documents' }]);
}
expect(filesTakeDropsAsIs(place({ onFiles: true }))).toBe(true);
});
it('still asks with a composer open, and on every other screen', () => {
expect(filesTakeDropsAsIs(place({ onFiles: true, composer: true }))).toBe(false);
expect(fileDropZones(drag(['other']), place({ onFiles: true, composer: true })).map((z) => z.id)).toEqual(['attach-here', 'new-message', 'upload']);
expect(filesTakeDropsAsIs(place())).toBe(false);
expect(fileDropZones(drag(['other']), place()).map((z) => z.id)).toEqual(['new-message', 'upload']);
});
});
describe('several .ics or .vcf can be added together (pim-p2-10)', () => {
it('offers "Add all to calendar" for two .ics', () => {
const zones = fileDropZones(drag(['calendar', 'calendar']), place());
expect(zones[0]).toEqual({ id: 'calendar', several: true });
});
it('offers "Add all to contacts" for three .vcf', () => {
const zones = fileDropZones(drag(['contacts', 'contacts', 'contacts']), place());
expect(zones[0]).toEqual({ id: 'contacts', several: true });
});
it('keeps the plain choice for one file', () => {
expect(fileDropZones(drag(['calendar']), place())[0]).toEqual({ id: 'calendar' });
});
});
+48
View File
@@ -0,0 +1,48 @@
/**
* Several .ics or .vcf files added at once go into one import list (pim-p2-10): the import
* dialogs read one file, so the files become one. vCards follow each other in a file as they
* are. Calendars become one VCALENDAR: the first file's own lines (VERSION, PRODID, …), then
* every file's components (VEVENT, VTODO, VTIMEZONE), in order.
*/
const CRLF = '\r\n';
function lines(text: string): string[] {
return text.replace(/\r\n?/g, '\n').split('\n');
}
/** The component blocks of one VCALENDAR, and the calendar's own lines before the first of them. */
function calendarParts(text: string): { head: string[]; components: string[] } {
const all = lines(text);
const begin = all.findIndex((line) => /^BEGIN:VCALENDAR\s*$/i.test(line));
let end = -1;
for (let i = all.length - 1; i >= 0; i--) {
if (/^END:VCALENDAR\s*$/i.test(all[i])) { end = i; break; }
}
if (begin === -1 || end <= begin) return { head: [], components: [] };
const inner = all.slice(begin + 1, end);
const first = inner.findIndex((line) => /^BEGIN:/i.test(line));
return first === -1
? { head: inner.filter(Boolean), components: [] }
: { head: inner.slice(0, first).filter(Boolean), components: inner.slice(first).filter(Boolean) };
}
export function mergeCalendarTexts(texts: string[]): string {
const parts = texts.map(calendarParts);
const head = parts.find((part) => part.head.length > 0)?.head ?? ['VERSION:2.0'];
return ['BEGIN:VCALENDAR', ...head, ...parts.flatMap((part) => part.components), 'END:VCALENDAR', ''].join(CRLF);
}
export function mergeVCardTexts(texts: string[]): string {
return texts.map((text) => text.replace(/\r\n?/g, '\n').trim()).filter(Boolean).join('\n').replace(/\n/g, CRLF) + CRLF;
}
/** One file holding them all, named after the first; a single file stays as it is. */
export async function mergeImportFiles(kind: 'calendar' | 'contacts', files: File[]): Promise<File> {
if (files.length === 1) return files[0];
const texts = await Promise.all(files.map((file) => file.text()));
const [first] = files;
return kind === 'calendar'
? new File([mergeCalendarTexts(texts)], first.name, { type: 'text/calendar' })
: new File([mergeVCardTexts(texts)], first.name, { type: 'text/vcard' });
}
+21 -2
View File
@@ -38,6 +38,12 @@ export interface DropPlace {
files: boolean;
/** On the Files screen, the folder in view, when files can go there. */
filesFolder: string | null;
/**
* The Files screen is on view and takes files where it is. With no composer open a drop
* there uploads to the folder in view (or the folder row it lands on) without asking,
* as Explorer and Finder do; the choices are for the other screens.
*/
onFiles?: boolean;
calendar: boolean;
contacts: boolean;
/** The Contacts screen is open: a CSV dropped there is a contacts import. */
@@ -61,6 +67,8 @@ export type FileDropNote =
export interface FileDropZone {
id: FileDropZoneId;
/** Calendar and contacts: several files, all added in one import ("Add all to calendar"). */
several?: boolean;
/** Names the folder for `upload` on the Files screen and for `import-eml`. */
folder?: string;
note?: FileDropNote;
@@ -116,6 +124,11 @@ function serverOnly(place: DropPlace, zone: FileDropZone): FileDropZone {
export function fileDropZones(files: DropFiles, place: DropPlace): FileDropZone[] {
const kind = sharedKind(files);
const one = files.count === 1;
// Files dropped or pasted on the Files screen go up into the folder in view, no question.
if (filesTakeDropsAsIs(place)) {
const note: FileDropNote | undefined = place.maxUploadBytes > 0 ? { key: 'note_limit_file', size: place.maxUploadBytes } : undefined;
return [serverOnly(place, { id: 'upload', ...(place.filesFolder ? { folder: place.filesFolder } : {}), ...(note ? { note } : {}) })];
}
const main: FileDropZone[] = [];
if (place.composer) main.push({ id: 'attach-here', ...mailNote(place) });
if (!place.popout) main.push({ id: 'new-message', ...mailNote(place) });
@@ -131,8 +144,9 @@ export function fileDropZones(files: DropFiles, place: DropPlace): FileDropZone[
if (!place.popout) {
if (kind === 'eml' && one) special.push({ id: 'open-eml' });
if (kind === 'eml' && place.importFolder) special.push(serverOnly(place, { id: 'import-eml', folder: place.importFolder }));
if (kind === 'calendar' && one && place.calendar) special.push({ id: 'calendar' });
if (kind === 'contacts' && one && place.contacts) special.push({ id: 'contacts' });
// Several at once go into one import list (pim-p2-10).
if (kind === 'calendar' && place.calendar) special.push({ id: 'calendar', ...(one || files.count === null ? {} : { several: true }) });
if (kind === 'contacts' && place.contacts) special.push({ id: 'contacts', ...(one || files.count === null ? {} : { several: true }) });
if (kind === 'csv' && one && place.contacts && place.contactsScreen) special.push({ id: 'contacts' });
}
@@ -143,6 +157,11 @@ export function fileDropZones(files: DropFiles, place: DropPlace): FileDropZone[
return [...attachHere, ...kept, ...rest];
}
/** The Files screen takes a drop as it is: on view, a folder that takes files, no composer open, not a pop-out. */
export function filesTakeDropsAsIs(place: Pick<DropPlace, 'onFiles' | 'composer' | 'popout' | 'files'>): boolean {
return !!place.onFiles && place.files && !place.composer && !place.popout;
}
/** What the files may still do, once they're in hand: those too big for the zone stay out. */
export function filesWithinLimits(
zone: FileDropZoneId,
+20
View File
@@ -5780,6 +5780,26 @@
"share_mail_subject": "شاركت {name} معك",
"share_mail_body": "مرحبًا،\n\nشاركت «{name}» معك. يمكنك فتحه من هنا:\n{link}"
},
"ux_p2_pim_files": {
"zone_calendar_all": "إضافة الكل إلى التقويم",
"zone_contacts_all": "إضافة الكل إلى جهات الاتصال",
"save_to_files": "حفظ في الملفات",
"saved": "تم حفظ {name} في الملفات.",
"saved_copy": "تم الحفظ في الملفات باسم {name}.",
"save_failed": "تعذّر حفظ {name} في الملفات.",
"changed_on_server": "تم تغيير {name} في الملفات بعد أن فتحته.",
"gone_on_server": "لم يعد {name} موجودًا في الملفات.",
"keep_both": "الاحتفاظ بالاثنين",
"replace": "استبدال",
"cancel": "إلغاء",
"save_as_new": "حفظ كملف جديد",
"import_to_mail": "استيراد إلى مجلد بريد…",
"import_to_mail_title": "استيراد إلى مجلد بريد",
"imported_to_mail": "تم الاستيراد إلى {folder}.",
"import_to_mail_failed": "تعذّر استيراد {name}.",
"import_contacts": "استيراد إلى جهات الاتصال…",
"add_to_calendar": "إضافة إلى التقويم…"
},
"smime": {
"your_certificates": "شهاداتك",
"your_certificates_desc": "استيراد وإدارة شهادات S/MIME الخاصة بك لتوقيع الرسائل وتشفيرها",
+20
View File
@@ -5780,6 +5780,26 @@
"share_mail_subject": "He compartit {name} amb tu",
"share_mail_body": "Hola,\n\nHe compartit «{name}» amb tu. El pots obrir aquí:\n{link}"
},
"ux_p2_pim_files": {
"zone_calendar_all": "Afegeix-ho tot al calendari",
"zone_contacts_all": "Afegeix-ho tot als contactes",
"save_to_files": "Desa a Fitxers",
"saved": "{name} s'ha desat a Fitxers.",
"saved_copy": "S'ha desat a Fitxers com a {name}.",
"save_failed": "No s'ha pogut desar {name} a Fitxers.",
"changed_on_server": "{name} ha canviat a Fitxers després que l'obrissis.",
"gone_on_server": "{name} ja no és a Fitxers.",
"keep_both": "Conserva’ls tots dos",
"replace": "Substitueix",
"cancel": "Cancel·la",
"save_as_new": "Desa com a fitxer nou",
"import_to_mail": "Importa a una carpeta de correu…",
"import_to_mail_title": "Importa a una carpeta de correu",
"imported_to_mail": "S'ha importat a {folder}.",
"import_to_mail_failed": "No s'ha pogut importar {name}.",
"import_contacts": "Importa als contactes…",
"add_to_calendar": "Afegeix al calendari…"
},
"smime": {
"your_certificates": "Els vostres certificats",
"your_certificates_desc": "Importeu i gestioneu els certificats S/MIME per signar i xifrar correus",
+20
View File
@@ -5518,6 +5518,26 @@
"share_mail_subject": "Sdílel jsem s vámi {name}",
"share_mail_body": "Dobrý den,\n\nsdílel jsem s vámi „{name}“. Otevřete to zde:\n{link}"
},
"ux_p2_pim_files": {
"zone_calendar_all": "Přidat vše do kalendáře",
"zone_contacts_all": "Přidat vše do kontaktů",
"save_to_files": "Uložit do Souborů",
"saved": "{name} uloženo do Souborů.",
"saved_copy": "Uloženo do Souborů jako {name}.",
"save_failed": "{name} se nepodařilo uložit do Souborů.",
"changed_on_server": "{name} se v Souborech změnil poté, co jste ho otevřeli.",
"gone_on_server": "{name} už v Souborech není.",
"keep_both": "Ponechat obojí",
"replace": "Nahradit",
"cancel": "Zrušit",
"save_as_new": "Uložit jako nový soubor",
"import_to_mail": "Importovat do složky pošty…",
"import_to_mail_title": "Importovat do složky pošty",
"imported_to_mail": "Importováno do {folder}.",
"import_to_mail_failed": "{name} se nepodařilo importovat.",
"import_contacts": "Importovat do kontaktů…",
"add_to_calendar": "Přidat do kalendáře…"
},
"smime": {
"your_certificates": "Vaše certifikáty",
"your_certificates_desc": "Importujte a spravujte své certifikáty S/MIME pro podepisování a šifrování e-mailů",
+20
View File
@@ -5780,6 +5780,26 @@
"share_mail_subject": "Jeg har delt {name} med dig",
"share_mail_body": "Hej\n\nJeg har delt \"{name}\" med dig. Du kan åbne den her:\n{link}"
},
"ux_p2_pim_files": {
"zone_calendar_all": "Føj alle til kalender",
"zone_contacts_all": "Føj alle til kontakter",
"save_to_files": "Gem i Filer",
"saved": "{name} er gemt i Filer.",
"saved_copy": "Gemt i Filer som {name}.",
"save_failed": "{name} kunne ikke gemmes i Filer.",
"changed_on_server": "{name} blev ændret i Filer, efter du åbnede den.",
"gone_on_server": "{name} findes ikke længere i Filer.",
"keep_both": "Behold begge",
"replace": "Erstat",
"cancel": "Annuller",
"save_as_new": "Gem som ny fil",
"import_to_mail": "Importér til en mailmappe…",
"import_to_mail_title": "Importér til en mailmappe",
"imported_to_mail": "Importeret til {folder}.",
"import_to_mail_failed": "{name} kunne ikke importeres.",
"import_contacts": "Importér til kontakter…",
"add_to_calendar": "Føj til kalender…"
},
"smime": {
"your_certificates": "Dine certifikater",
"your_certificates_desc": "Importér og administrér dine S/MIME-certifikater til signering og kryptering af e-mails",
+20
View File
@@ -5518,6 +5518,26 @@
"share_mail_subject": "Ich habe {name} für Sie freigegeben",
"share_mail_body": "Hallo,\n\nich habe „{name}“ für Sie freigegeben. Sie können es hier öffnen:\n{link}"
},
"ux_p2_pim_files": {
"zone_calendar_all": "Alle zum Kalender hinzufügen",
"zone_contacts_all": "Alle zu Kontakten hinzufügen",
"save_to_files": "In Dateien speichern",
"saved": "{name} in Dateien gespeichert.",
"saved_copy": "In Dateien gespeichert als {name}.",
"save_failed": "{name} konnte nicht in Dateien gespeichert werden.",
"changed_on_server": "{name} wurde in Dateien geändert, nachdem Sie die Datei geöffnet haben.",
"gone_on_server": "{name} ist nicht mehr in Dateien.",
"keep_both": "Beide behalten",
"replace": "Ersetzen",
"cancel": "Abbrechen",
"save_as_new": "Als neue Datei speichern",
"import_to_mail": "In einen Mailordner importieren…",
"import_to_mail_title": "In einen Mailordner importieren",
"imported_to_mail": "In {folder} importiert.",
"import_to_mail_failed": "{name} konnte nicht importiert werden.",
"import_contacts": "In Kontakte importieren…",
"add_to_calendar": "Zum Kalender hinzufügen…"
},
"smime": {
"your_certificates": "Ihre Zertifikate",
"your_certificates_desc": "Importieren und verwalten Sie Ihre S/MIME-Zertifikate zum Signieren und Verschlüsseln von E-Mails",
+20
View File
@@ -5780,6 +5780,26 @@
"share_mail_subject": "I shared {name} with you",
"share_mail_body": "Hi,\n\nI shared \"{name}\" with you. You can open it here:\n{link}"
},
"ux_p2_pim_files": {
"zone_calendar_all": "Add all to calendar",
"zone_contacts_all": "Add all to contacts",
"save_to_files": "Save to Files",
"saved": "{name} saved to Files.",
"saved_copy": "Saved to Files as {name}.",
"save_failed": "{name} could not be saved to Files.",
"changed_on_server": "{name} was changed in Files after you opened it.",
"gone_on_server": "{name} is no longer in Files.",
"keep_both": "Keep both",
"replace": "Replace",
"cancel": "Cancel",
"save_as_new": "Save as a new file",
"import_to_mail": "Import to a mail folder…",
"import_to_mail_title": "Import to a mail folder",
"imported_to_mail": "Imported into {folder}.",
"import_to_mail_failed": "{name} could not be imported.",
"import_contacts": "Import into contacts…",
"add_to_calendar": "Add to calendar…"
},
"smime": {
"your_certificates": "Your Certificates",
"your_certificates_desc": "Import and manage your S/MIME certificates for signing and encrypting emails",
+20
View File
@@ -5518,6 +5518,26 @@
"share_mail_subject": "He compartido {name} contigo",
"share_mail_body": "Hola:\n\nHe compartido «{name}» contigo. Puedes abrirlo aquí:\n{link}"
},
"ux_p2_pim_files": {
"zone_calendar_all": "Añadir todo al calendario",
"zone_contacts_all": "Añadir todo a contactos",
"save_to_files": "Guardar en Archivos",
"saved": "{name} se guardó en Archivos.",
"saved_copy": "Guardado en Archivos como {name}.",
"save_failed": "No se pudo guardar {name} en Archivos.",
"changed_on_server": "{name} cambió en Archivos después de que lo abrieras.",
"gone_on_server": "{name} ya no está en Archivos.",
"keep_both": "Conservar ambos",
"replace": "Reemplazar",
"cancel": "Cancelar",
"save_as_new": "Guardar como archivo nuevo",
"import_to_mail": "Importar a una carpeta de correo…",
"import_to_mail_title": "Importar a una carpeta de correo",
"imported_to_mail": "Importado en {folder}.",
"import_to_mail_failed": "No se pudo importar {name}.",
"import_contacts": "Importar a contactos…",
"add_to_calendar": "Añadir al calendario…"
},
"smime": {
"your_certificates": "Tus certificados",
"your_certificates_desc": "Importa y administra tus certificados S/MIME para firmar y cifrar correos",
+20
View File
@@ -5780,6 +5780,26 @@
"share_mail_subject": "Jagasin sinuga faili {name}",
"share_mail_body": "Tere\n\nJagasin sinuga „{name}“. Saad selle avada siin:\n{link}"
},
"ux_p2_pim_files": {
"zone_calendar_all": "Lisa kõik kalendrisse",
"zone_contacts_all": "Lisa kõik kontaktidesse",
"save_to_files": "Salvesta kausta Failid",
"saved": "{name} salvestati kausta Failid.",
"saved_copy": "Salvestati kausta Failid nimega {name}.",
"save_failed": "Faili {name} ei saanud kausta Failid salvestada.",
"changed_on_server": "Faili {name} muudeti kaustas Failid pärast seda, kui selle avasid.",
"gone_on_server": "Faili {name} pole enam kaustas Failid.",
"keep_both": "Jäta mõlemad",
"replace": "Asenda",
"cancel": "Tühista",
"save_as_new": "Salvesta uue failina",
"import_to_mail": "Impordi meilikausta…",
"import_to_mail_title": "Impordi meilikausta",
"imported_to_mail": "Imporditi kausta {folder}.",
"import_to_mail_failed": "Faili {name} ei saanud importida.",
"import_contacts": "Impordi kontaktidesse…",
"add_to_calendar": "Lisa kalendrisse…"
},
"smime": {
"your_certificates": "Sinu sertifikaadid",
"your_certificates_desc": "Impordi ja halda oma S/MIME sertifikaate e-kirjade allkirjastamiseks ja krüpteerimiseks",
+20
View File
@@ -5780,6 +5780,26 @@
"share_mail_subject": "{name} را با شما به اشتراک گذاشتم",
"share_mail_body": "سلام،\n\n«{name}» را با شما به اشتراک گذاشتم. می‌توانید آن را اینجا باز کنید:\n{link}"
},
"ux_p2_pim_files": {
"zone_calendar_all": "افزودن همه به تقویم",
"zone_contacts_all": "افزودن همه به مخاطبان",
"save_to_files": "ذخیره در فایل‌ها",
"saved": "{name} در فایل‌ها ذخیره شد.",
"saved_copy": "با نام {name} در فایل‌ها ذخیره شد.",
"save_failed": "ذخیرهٔ {name} در فایل‌ها ممکن نشد.",
"changed_on_server": "{name} پس از اینکه آن را باز کردید در فایل‌ها تغییر کرد.",
"gone_on_server": "{name} دیگر در فایل‌ها نیست.",
"keep_both": "نگه‌داشتن هر دو",
"replace": "جایگزینی",
"cancel": "انصراف",
"save_as_new": "ذخیره به‌عنوان فایل جدید",
"import_to_mail": "وارد کردن به یک پوشهٔ ایمیل…",
"import_to_mail_title": "وارد کردن به یک پوشهٔ ایمیل",
"imported_to_mail": "به {folder} وارد شد.",
"import_to_mail_failed": "وارد کردن {name} ممکن نشد.",
"import_contacts": "وارد کردن به مخاطبان…",
"add_to_calendar": "افزودن به تقویم…"
},
"smime": {
"your_certificates": "گواهی‌های شما",
"your_certificates_desc": "مدیریت گواهی‌های S/MIME",
+20
View File
@@ -5518,6 +5518,26 @@
"share_mail_subject": "J’ai partagé {name} avec vous",
"share_mail_body": "Bonjour,\n\nJ’ai partagé « {name} » avec vous. Vous pouvez l’ouvrir ici :\n{link}"
},
"ux_p2_pim_files": {
"zone_calendar_all": "Tout ajouter au calendrier",
"zone_contacts_all": "Tout ajouter aux contacts",
"save_to_files": "Enregistrer dans Fichiers",
"saved": "{name} enregistré dans Fichiers.",
"saved_copy": "Enregistré dans Fichiers sous {name}.",
"save_failed": "Impossible d'enregistrer {name} dans Fichiers.",
"changed_on_server": "{name} a été modifié dans Fichiers après que vous l'avez ouvert.",
"gone_on_server": "{name} n'est plus dans Fichiers.",
"keep_both": "Conserver les deux",
"replace": "Remplacer",
"cancel": "Annuler",
"save_as_new": "Enregistrer comme nouveau fichier",
"import_to_mail": "Importer dans un dossier de messagerie…",
"import_to_mail_title": "Importer dans un dossier de messagerie",
"imported_to_mail": "Importé dans {folder}.",
"import_to_mail_failed": "Impossible d'importer {name}.",
"import_contacts": "Importer dans les contacts…",
"add_to_calendar": "Ajouter au calendrier…"
},
"smime": {
"your_certificates": "Vos certificats",
"your_certificates_desc": "Importez et gérez vos certificats S/MIME pour signer et chiffrer les e-mails",
+20
View File
@@ -5412,6 +5412,26 @@
"share_mail_subject": "שיתפתי איתך את {name}",
"share_mail_body": "שלום,\n\nשיתפתי איתך את \"{name}\". אפשר לפתוח אותו כאן:\n{link}"
},
"ux_p2_pim_files": {
"zone_calendar_all": "הוספת הכול ללוח השנה",
"zone_contacts_all": "הוספת הכול לאנשי הקשר",
"save_to_files": "שמירה בקבצים",
"saved": "{name} נשמר בקבצים.",
"saved_copy": "נשמר בקבצים בשם {name}.",
"save_failed": "לא ניתן היה לשמור את {name} בקבצים.",
"changed_on_server": "{name} השתנה בקבצים אחרי שפתחת אותו.",
"gone_on_server": "{name} כבר לא נמצא בקבצים.",
"keep_both": "שמירת שניהם",
"replace": "החלפה",
"cancel": "ביטול",
"save_as_new": "שמירה כקובץ חדש",
"import_to_mail": "ייבוא לתיקיית דואר…",
"import_to_mail_title": "ייבוא לתיקיית דואר",
"imported_to_mail": "יובא אל {folder}.",
"import_to_mail_failed": "לא ניתן היה לייבא את {name}.",
"import_contacts": "ייבוא לאנשי הקשר…",
"add_to_calendar": "הוספה ללוח השנה…"
},
"smime": {
"your_certificates": "התעודות שלך",
"your_certificates_desc": "ייבא ונהל את אישורי ה-S/MIME שלך עבור חתימה והצפנה של הודעות דוא\"ל",
+20
View File
@@ -5780,6 +5780,26 @@
"share_mail_subject": "Megosztottam veled: {name}",
"share_mail_body": "Szia!\n\nMegosztottam veled ezt: „{name}”. Itt tudod megnyitni:\n{link}"
},
"ux_p2_pim_files": {
"zone_calendar_all": "Összes hozzáadása a naptárhoz",
"zone_contacts_all": "Összes hozzáadása a névjegyekhez",
"save_to_files": "Mentés a Fájlokba",
"saved": "{name} mentve a Fájlokba.",
"saved_copy": "Mentve a Fájlokba {name} néven.",
"save_failed": "{name} nem menthető a Fájlokba.",
"changed_on_server": "{name} megváltozott a Fájlokban, miután megnyitottad.",
"gone_on_server": "{name} már nincs a Fájlokban.",
"keep_both": "Mindkettő megtartása",
"replace": "Csere",
"cancel": "Mégse",
"save_as_new": "Mentés új fájlként",
"import_to_mail": "Importálás levélmappába…",
"import_to_mail_title": "Importálás levélmappába",
"imported_to_mail": "Importálva ide: {folder}.",
"import_to_mail_failed": "{name} nem importálható.",
"import_contacts": "Importálás a névjegyekbe…",
"add_to_calendar": "Hozzáadás a naptárhoz…"
},
"smime": {
"your_certificates": "Tanúsítványaid",
"your_certificates_desc": "S/MIME tanúsítványok importálása és kezelése e-mailek aláírásához és titkosításához",
+20
View File
@@ -5518,6 +5518,26 @@
"share_mail_subject": "Ho condiviso {name} con te",
"share_mail_body": "Ciao,\n\nho condiviso «{name}» con te. Puoi aprirlo qui:\n{link}"
},
"ux_p2_pim_files": {
"zone_calendar_all": "Aggiungi tutto al calendario",
"zone_contacts_all": "Aggiungi tutto ai contatti",
"save_to_files": "Salva in File",
"saved": "{name} salvato in File.",
"saved_copy": "Salvato in File come {name}.",
"save_failed": "Impossibile salvare {name} in File.",
"changed_on_server": "{name} è stato modificato in File dopo che lo hai aperto.",
"gone_on_server": "{name} non è più in File.",
"keep_both": "Mantieni entrambi",
"replace": "Sostituisci",
"cancel": "Annulla",
"save_as_new": "Salva come nuovo file",
"import_to_mail": "Importa in una cartella di posta…",
"import_to_mail_title": "Importa in una cartella di posta",
"imported_to_mail": "Importato in {folder}.",
"import_to_mail_failed": "Impossibile importare {name}.",
"import_contacts": "Importa nei contatti…",
"add_to_calendar": "Aggiungi al calendario…"
},
"smime": {
"your_certificates": "I tuoi certificati",
"your_certificates_desc": "Importa e gestisci i tuoi certificati S/MIME per firmare e cifrare le email",
+20
View File
@@ -5518,6 +5518,26 @@
"share_mail_subject": "{name} を共有しました",
"share_mail_body": "こんにちは。\n\n「{name}」を共有しました。こちらから開けます:\n{link}"
},
"ux_p2_pim_files": {
"zone_calendar_all": "すべてカレンダーに追加",
"zone_contacts_all": "すべて連絡先に追加",
"save_to_files": "ファイルに保存",
"saved": "{name} をファイルに保存しました。",
"saved_copy": "{name} としてファイルに保存しました。",
"save_failed": "{name} をファイルに保存できませんでした。",
"changed_on_server": "{name} は開いた後にファイルで変更されました。",
"gone_on_server": "{name} はファイルにもうありません。",
"keep_both": "両方残す",
"replace": "置き換える",
"cancel": "キャンセル",
"save_as_new": "新しいファイルとして保存",
"import_to_mail": "メールフォルダーにインポート…",
"import_to_mail_title": "メールフォルダーにインポート",
"imported_to_mail": "{folder} にインポートしました。",
"import_to_mail_failed": "{name} をインポートできませんでした。",
"import_contacts": "連絡先にインポート…",
"add_to_calendar": "カレンダーに追加…"
},
"smime": {
"your_certificates": "あなたの証明書",
"your_certificates_desc": "メールの署名と暗号化のために S/MIME 証明書をインポートして管理します",
+20
View File
@@ -5518,6 +5518,26 @@
"share_mail_subject": "{name}을(를) 공유했습니다",
"share_mail_body": "안녕하세요.\n\n\"{name}\"을(를) 공유했습니다. 여기에서 열 수 있습니다:\n{link}"
},
"ux_p2_pim_files": {
"zone_calendar_all": "모두 캘린더에 추가",
"zone_contacts_all": "모두 연락처에 추가",
"save_to_files": "파일에 저장",
"saved": "{name}을(를) 파일에 저장했습니다.",
"saved_copy": "파일에 {name}(으)로 저장했습니다.",
"save_failed": "{name}을(를) 파일에 저장하지 못했습니다.",
"changed_on_server": "{name}이(가) 연 뒤에 파일에서 변경되었습니다.",
"gone_on_server": "{name}이(가) 더 이상 파일에 없습니다.",
"keep_both": "둘 다 유지",
"replace": "바꾸기",
"cancel": "취소",
"save_as_new": "새 파일로 저장",
"import_to_mail": "메일 폴더로 가져오기…",
"import_to_mail_title": "메일 폴더로 가져오기",
"imported_to_mail": "{folder}(으)로 가져왔습니다.",
"import_to_mail_failed": "{name}을(를) 가져오지 못했습니다.",
"import_contacts": "연락처로 가져오기…",
"add_to_calendar": "캘린더에 추가…"
},
"smime": {
"your_certificates": "내 인증서",
"your_certificates_desc": "이메일 서명과 암호화를 위한 S/MIME 인증서를 가져오고 관리해요",
+20
View File
@@ -5518,6 +5518,26 @@
"share_mail_subject": "Es koplietoju ar jums {name}",
"share_mail_body": "Sveiki!\n\nEs koplietoju ar jums “{name}”. To var atvērt šeit:\n{link}"
},
"ux_p2_pim_files": {
"zone_calendar_all": "Pievienot visus kalendāram",
"zone_contacts_all": "Pievienot visus kontaktiem",
"save_to_files": "Saglabāt failos",
"saved": "{name} saglabāts failos.",
"saved_copy": "Saglabāts failos kā {name}.",
"save_failed": "{name} neizdevās saglabāt failos.",
"changed_on_server": "{name} tika mainīts failos pēc tam, kad to atvērāt.",
"gone_on_server": "{name} vairs nav failos.",
"keep_both": "Paturēt abus",
"replace": "Aizstāt",
"cancel": "Atcelt",
"save_as_new": "Saglabāt kā jaunu failu",
"import_to_mail": "Importēt pasta mapē…",
"import_to_mail_title": "Importēt pasta mapē",
"imported_to_mail": "Importēts mapē {folder}.",
"import_to_mail_failed": "{name} neizdevās importēt.",
"import_contacts": "Importēt kontaktos…",
"add_to_calendar": "Pievienot kalendāram…"
},
"smime": {
"your_certificates": "Jūsu sertifikāti",
"your_certificates_desc": "Importējiet un pārvaldiet savus S/MIME sertifikātus parakstīšanai un šifrēšanai",
+20
View File
@@ -5780,6 +5780,26 @@
"share_mail_subject": "Би {name}-г тантай хуваалцлаа",
"share_mail_body": "Сайн байна уу,\n\nБи \"{name}\"-г тантай хуваалцлаа. Эндээс нээж болно:\n{link}"
},
"ux_p2_pim_files": {
"zone_calendar_all": "Бүгдийг хуанлид нэмэх",
"zone_contacts_all": "Бүгдийг харилцагчид нэмэх",
"save_to_files": "Файлд хадгалах",
"saved": "{name}-г Файлд хадгаллаа.",
"saved_copy": "Файлд {name} нэрээр хадгаллаа.",
"save_failed": "{name}-г Файлд хадгалж чадсангүй.",
"changed_on_server": "{name}-г нээсний дараа Файлд өөрчилсөн байна.",
"gone_on_server": "{name} Файлд байхгүй болсон.",
"keep_both": "Хоёуланг нь хадгалах",
"replace": "Солих",
"cancel": "Цуцлах",
"save_as_new": "Шинэ файлаар хадгалах",
"import_to_mail": "Шуудангийн хавтас руу импортлох…",
"import_to_mail_title": "Шуудангийн хавтас руу импортлох",
"imported_to_mail": "{folder} руу импортлолоо.",
"import_to_mail_failed": "{name}-г импортлож чадсангүй.",
"import_contacts": "Харилцагчид импортлох…",
"add_to_calendar": "Хуанлид нэмэх…"
},
"smime": {
"your_certificates": "Таны сертификатууд",
"your_certificates_desc": "Имэйлд гарын үсэг зурах болон шифрлэхэд ашиглах S/MIME сертификатаа импортолж, удирдана",
+20
View File
@@ -5780,6 +5780,26 @@
"share_mail_subject": "Jeg har delt {name} med deg",
"share_mail_body": "Hei,\n\nJeg har delt «{name}» med deg. Du kan åpne den her:\n{link}"
},
"ux_p2_pim_files": {
"zone_calendar_all": "Legg alle til i kalenderen",
"zone_contacts_all": "Legg alle til i kontakter",
"save_to_files": "Lagre i Filer",
"saved": "{name} er lagret i Filer.",
"saved_copy": "Lagret i Filer som {name}.",
"save_failed": "Kunne ikke lagre {name} i Filer.",
"changed_on_server": "{name} ble endret i Filer etter at du åpnet den.",
"gone_on_server": "{name} finnes ikke lenger i Filer.",
"keep_both": "Behold begge",
"replace": "Erstatt",
"cancel": "Avbryt",
"save_as_new": "Lagre som ny fil",
"import_to_mail": "Importer til en e-postmappe…",
"import_to_mail_title": "Importer til en e-postmappe",
"imported_to_mail": "Importert til {folder}.",
"import_to_mail_failed": "Kunne ikke importere {name}.",
"import_contacts": "Importer til kontakter…",
"add_to_calendar": "Legg til i kalenderen…"
},
"smime": {
"your_certificates": "Dine sertifikater",
"your_certificates_desc": "Importer og administrer S/MIME-sertifikatene dine for signering og kryptering av e-post",
+20
View File
@@ -5518,6 +5518,26 @@
"share_mail_subject": "Ik heb {name} met je gedeeld",
"share_mail_body": "Hoi,\n\nIk heb \"{name}\" met je gedeeld. Je kunt het hier openen:\n{link}"
},
"ux_p2_pim_files": {
"zone_calendar_all": "Alles aan agenda toevoegen",
"zone_contacts_all": "Alles aan contacten toevoegen",
"save_to_files": "Opslaan in Bestanden",
"saved": "{name} opgeslagen in Bestanden.",
"saved_copy": "Opgeslagen in Bestanden als {name}.",
"save_failed": "{name} kon niet in Bestanden worden opgeslagen.",
"changed_on_server": "{name} is in Bestanden gewijzigd nadat je het opende.",
"gone_on_server": "{name} staat niet meer in Bestanden.",
"keep_both": "Beide behouden",
"replace": "Vervangen",
"cancel": "Annuleren",
"save_as_new": "Opslaan als nieuw bestand",
"import_to_mail": "Importeren in een mailmap…",
"import_to_mail_title": "Importeren in een mailmap",
"imported_to_mail": "Geïmporteerd in {folder}.",
"import_to_mail_failed": "{name} kon niet worden geïmporteerd.",
"import_contacts": "Importeren in contacten…",
"add_to_calendar": "Aan agenda toevoegen…"
},
"smime": {
"your_certificates": "Uw certificaten",
"your_certificates_desc": "Importeer en beheer uw S/MIME-certificaten voor het ondertekenen en versleutelen van e-mails",
+20
View File
@@ -5518,6 +5518,26 @@
"share_mail_subject": "Udostępniłem Ci {name}",
"share_mail_body": "Cześć,\n\nUdostępniłem Ci „{name}”. Możesz to otworzyć tutaj:\n{link}"
},
"ux_p2_pim_files": {
"zone_calendar_all": "Dodaj wszystko do kalendarza",
"zone_contacts_all": "Dodaj wszystko do kontaktów",
"save_to_files": "Zapisz w Plikach",
"saved": "Zapisano {name} w Plikach.",
"saved_copy": "Zapisano w Plikach jako {name}.",
"save_failed": "Nie udało się zapisać {name} w Plikach.",
"changed_on_server": "{name} zmieniono w Plikach po tym, jak go otworzono.",
"gone_on_server": "{name} nie ma już w Plikach.",
"keep_both": "Zachowaj oba",
"replace": "Zastąp",
"cancel": "Anuluj",
"save_as_new": "Zapisz jako nowy plik",
"import_to_mail": "Importuj do folderu poczty…",
"import_to_mail_title": "Importuj do folderu poczty",
"imported_to_mail": "Zaimportowano do {folder}.",
"import_to_mail_failed": "Nie udało się zaimportować {name}.",
"import_contacts": "Importuj do kontaktów…",
"add_to_calendar": "Dodaj do kalendarza…"
},
"smime": {
"your_certificates": "Twoje certyfikaty",
"your_certificates_desc": "Importuj i zarządzaj swoimi certyfikatami S/MIME do podpisywania i szyfrowania wiadomości e-mail",
+20
View File
@@ -5518,6 +5518,26 @@
"share_mail_subject": "Compartilhei {name} com você",
"share_mail_body": "Olá,\n\nCompartilhei \"{name}\" com você. Você pode abrir aqui:\n{link}"
},
"ux_p2_pim_files": {
"zone_calendar_all": "Adicionar tudo ao calendário",
"zone_contacts_all": "Adicionar tudo aos contactos",
"save_to_files": "Guardar em Arquivos",
"saved": "{name} guardado em Arquivos.",
"saved_copy": "Guardado em Arquivos como {name}.",
"save_failed": "Não foi possível guardar {name} em Arquivos.",
"changed_on_server": "{name} foi alterado em Arquivos depois de o abrir.",
"gone_on_server": "{name} já não está em Arquivos.",
"keep_both": "Manter ambos",
"replace": "Substituir",
"cancel": "Cancelar",
"save_as_new": "Guardar como novo ficheiro",
"import_to_mail": "Importar para uma pasta de correio…",
"import_to_mail_title": "Importar para uma pasta de correio",
"imported_to_mail": "Importado para {folder}.",
"import_to_mail_failed": "Não foi possível importar {name}.",
"import_contacts": "Importar para os contactos…",
"add_to_calendar": "Adicionar ao calendário…"
},
"smime": {
"your_certificates": "Seus certificados",
"your_certificates_desc": "Importe e gerencie seus certificados S/MIME para assinar e criptografar e-mails",
+20
View File
@@ -5780,6 +5780,26 @@
"share_mail_subject": "Am partajat {name} cu tine",
"share_mail_body": "Salut,\n\nAm partajat „{name}” cu tine. Îl poți deschide aici:\n{link}"
},
"ux_p2_pim_files": {
"zone_calendar_all": "Adăugați tot în calendar",
"zone_contacts_all": "Adăugați tot la contacte",
"save_to_files": "Salvați în Fișiere",
"saved": "{name} a fost salvat în Fișiere.",
"saved_copy": "Salvat în Fișiere ca {name}.",
"save_failed": "{name} nu a putut fi salvat în Fișiere.",
"changed_on_server": "{name} a fost modificat în Fișiere după ce l-ați deschis.",
"gone_on_server": "{name} nu mai este în Fișiere.",
"keep_both": "Păstrează-le pe ambele",
"replace": "Înlocuiește",
"cancel": "Anulează",
"save_as_new": "Salvați ca fișier nou",
"import_to_mail": "Importați într-un dosar de e-mail…",
"import_to_mail_title": "Importați într-un dosar de e-mail",
"imported_to_mail": "Importat în {folder}.",
"import_to_mail_failed": "{name} nu a putut fi importat.",
"import_contacts": "Importați în contacte…",
"add_to_calendar": "Adăugați în calendar…"
},
"smime": {
"your_certificates": "Certificatele dvs.",
"your_certificates_desc": "Importați și gestionați certificatele S/MIME pentru semnarea și criptarea e-mailurilor",
+20
View File
@@ -5518,6 +5518,26 @@
"share_mail_subject": "Я открыл вам доступ к {name}",
"share_mail_body": "Здравствуйте!\n\nЯ открыл вам доступ к «{name}». Открыть можно здесь:\n{link}"
},
"ux_p2_pim_files": {
"zone_calendar_all": "Добавить все в календарь",
"zone_contacts_all": "Добавить все в контакты",
"save_to_files": "Сохранить в Файлы",
"saved": "{name} сохранён в Файлы.",
"saved_copy": "Сохранено в Файлы как {name}.",
"save_failed": "Не удалось сохранить {name} в Файлы.",
"changed_on_server": "{name} изменили в Файлах после того, как вы его открыли.",
"gone_on_server": "{name} больше нет в Файлах.",
"keep_both": "Оставить оба",
"replace": "Заменить",
"cancel": "Отмена",
"save_as_new": "Сохранить как новый файл",
"import_to_mail": "Импортировать в почтовую папку…",
"import_to_mail_title": "Импортировать в почтовую папку",
"imported_to_mail": "Импортировано в {folder}.",
"import_to_mail_failed": "Не удалось импортировать {name}.",
"import_contacts": "Импортировать в контакты…",
"add_to_calendar": "Добавить в календарь…"
},
"smime": {
"your_certificates": "Ваши сертификаты",
"your_certificates_desc": "Импортируйте и управляйте своими сертификатами S/MIME для подписи и шифрования писем",
+20
View File
@@ -5780,6 +5780,26 @@
"share_mail_subject": "Zdieľal som s vami {name}",
"share_mail_body": "Dobrý deň,\n\nzdieľal som s vami „{name}“. Otvoríte to tu:\n{link}"
},
"ux_p2_pim_files": {
"zone_calendar_all": "Pridať všetko do kalendára",
"zone_contacts_all": "Pridať všetko do kontaktov",
"save_to_files": "Uložiť do Súborov",
"saved": "{name} sa uložil do Súborov.",
"saved_copy": "Uložené do Súborov ako {name}.",
"save_failed": "{name} sa nepodarilo uložiť do Súborov.",
"changed_on_server": "{name} sa v Súboroch zmenil potom, ako ste ho otvorili.",
"gone_on_server": "{name} už v Súboroch nie je.",
"keep_both": "Ponechať oboje",
"replace": "Nahradiť",
"cancel": "Zrušiť",
"save_as_new": "Uložiť ako nový súbor",
"import_to_mail": "Importovať do priečinka pošty…",
"import_to_mail_title": "Importovať do priečinka pošty",
"imported_to_mail": "Importované do {folder}.",
"import_to_mail_failed": "{name} sa nepodarilo importovať.",
"import_contacts": "Importovať do kontaktov…",
"add_to_calendar": "Pridať do kalendára…"
},
"smime": {
"your_certificates": "Vaše certifikáty",
"your_certificates_desc": "Importujte a spravujte svoje certifikáty S/MIME na podpisovanie a šifrovanie e-mailov",
+20
View File
@@ -2694,6 +2694,26 @@
"share_mail_subject": "ฉันแชร์ {name} กับคุณ",
"share_mail_body": "สวัสดี\n\nฉันแชร์ \"{name}\" กับคุณแล้ว เปิดได้ที่นี่:\n{link}"
},
"ux_p2_pim_files": {
"zone_calendar_all": "เพิ่มทั้งหมดในปฏิทิน",
"zone_contacts_all": "เพิ่มทั้งหมดในรายชื่อติดต่อ",
"save_to_files": "บันทึกลงในไฟล์",
"saved": "บันทึก {name} ลงในไฟล์แล้ว",
"saved_copy": "บันทึกลงในไฟล์เป็น {name} แล้ว",
"save_failed": "บันทึก {name} ลงในไฟล์ไม่ได้",
"changed_on_server": "{name} ถูกเปลี่ยนในไฟล์หลังจากที่คุณเปิด",
"gone_on_server": "{name} ไม่อยู่ในไฟล์แล้ว",
"keep_both": "เก็บทั้งสอง",
"replace": "แทนที่",
"cancel": "ยกเลิก",
"save_as_new": "บันทึกเป็นไฟล์ใหม่",
"import_to_mail": "นำเข้าไปยังโฟลเดอร์อีเมล…",
"import_to_mail_title": "นำเข้าไปยังโฟลเดอร์อีเมล",
"imported_to_mail": "นำเข้าไปยัง {folder} แล้ว",
"import_to_mail_failed": "นำเข้า {name} ไม่ได้",
"import_contacts": "นำเข้าไปยังรายชื่อติดต่อ…",
"add_to_calendar": "เพิ่มในปฏิทิน…"
},
"protocol_handlers": {
"title": "แอปพลิเคชันเริ่มต้น",
"description": "เลือกว่าจะเปิดลิงก์อีเมลและปฏิทินใน {appName} หรือไม่",
+20
View File
@@ -5780,6 +5780,26 @@
"share_mail_subject": "{name} dosyasını seninle paylaştım",
"share_mail_body": "Merhaba,\n\n\"{name}\" dosyasını seninle paylaştım. Buradan açabilirsin:\n{link}"
},
"ux_p2_pim_files": {
"zone_calendar_all": "Tümünü takvime ekle",
"zone_contacts_all": "Tümünü kişilere ekle",
"save_to_files": "Dosyalar'a kaydet",
"saved": "{name}, Dosyalar'a kaydedildi.",
"saved_copy": "Dosyalar'a {name} olarak kaydedildi.",
"save_failed": "{name}, Dosyalar'a kaydedilemedi.",
"changed_on_server": "{name}, siz açtıktan sonra Dosyalar'da değiştirildi.",
"gone_on_server": "{name} artık Dosyalar'da yok.",
"keep_both": "İkisini de tut",
"replace": "Değiştir",
"cancel": "İptal",
"save_as_new": "Yeni dosya olarak kaydet",
"import_to_mail": "Bir posta klasörüne aktar…",
"import_to_mail_title": "Bir posta klasörüne aktar",
"imported_to_mail": "{folder} klasörüne aktarıldı.",
"import_to_mail_failed": "{name} aktarılamadı.",
"import_contacts": "Kişilere aktar…",
"add_to_calendar": "Takvime ekle…"
},
"smime": {
"your_certificates": "Sertifikalarınız",
"your_certificates_desc": "E-postaları imzalamak ve şifrelemek için S/MIME sertifikalarınızı içe aktarın ve yönetin",
+20
View File
@@ -5518,6 +5518,26 @@
"share_mail_subject": "Я надав вам доступ до {name}",
"share_mail_body": "Вітаю!\n\nЯ надав вам доступ до «{name}». Відкрити можна тут:\n{link}"
},
"ux_p2_pim_files": {
"zone_calendar_all": "Додати все до календаря",
"zone_contacts_all": "Додати все до контактів",
"save_to_files": "Зберегти у Файли",
"saved": "{name} збережено у Файли.",
"saved_copy": "Збережено у Файли як {name}.",
"save_failed": "Не вдалося зберегти {name} у Файли.",
"changed_on_server": "{name} змінили у Файлах після того, як ви його відкрили.",
"gone_on_server": "{name} більше немає у Файлах.",
"keep_both": "Залишити обидва",
"replace": "Замінити",
"cancel": "Скасувати",
"save_as_new": "Зберегти як новий файл",
"import_to_mail": "Імпортувати в поштову теку…",
"import_to_mail_title": "Імпортувати в поштову теку",
"imported_to_mail": "Імпортовано в {folder}.",
"import_to_mail_failed": "Не вдалося імпортувати {name}.",
"import_contacts": "Імпортувати в контакти…",
"add_to_calendar": "Додати до календаря…"
},
"smime": {
"your_certificates": "Ваші сертифікати",
"your_certificates_desc": "Імпортуйте та керуйте своїми сертифікатами S/MIME для підпису та шифрування електронних листів",
+20
View File
@@ -5792,6 +5792,26 @@
"share_mail_subject": "Tôi đã chia sẻ {name} với bạn",
"share_mail_body": "Chào bạn,\n\nTôi đã chia sẻ \"{name}\" với bạn. Bạn có thể mở tại đây:\n{link}"
},
"ux_p2_pim_files": {
"zone_calendar_all": "Thêm tất cả vào lịch",
"zone_contacts_all": "Thêm tất cả vào danh bạ",
"save_to_files": "Lưu vào Tệp",
"saved": "Đã lưu {name} vào Tệp.",
"saved_copy": "Đã lưu vào Tệp với tên {name}.",
"save_failed": "Không thể lưu {name} vào Tệp.",
"changed_on_server": "{name} đã bị thay đổi trong Tệp sau khi bạn mở.",
"gone_on_server": "{name} không còn trong Tệp.",
"keep_both": "Giữ cả hai",
"replace": "Thay thế",
"cancel": "Hủy",
"save_as_new": "Lưu thành tệp mới",
"import_to_mail": "Nhập vào thư mục thư…",
"import_to_mail_title": "Nhập vào thư mục thư",
"imported_to_mail": "Đã nhập vào {folder}.",
"import_to_mail_failed": "Không thể nhập {name}.",
"import_contacts": "Nhập vào danh bạ…",
"add_to_calendar": "Thêm vào lịch…"
},
"smime": {
"your_certificates": "Chứng chỉ của bạn",
"your_certificates_desc": "Nhập và quản lý chứng chỉ S/MIME của bạn để ký và mã hóa email",
+20
View File
@@ -5780,6 +5780,26 @@
"share_mail_subject": "我與你共用了 {name}",
"share_mail_body": "你好,\n\n我與你共用了「{name}」。可以在這裡開啟:\n{link}"
},
"ux_p2_pim_files": {
"zone_calendar_all": "全部加入行事曆",
"zone_contacts_all": "全部加入聯絡人",
"save_to_files": "儲存到檔案",
"saved": "已將 {name} 儲存到檔案。",
"saved_copy": "已以 {name} 儲存到檔案。",
"save_failed": "無法將 {name} 儲存到檔案。",
"changed_on_server": "你開啟 {name} 後,它在檔案中被變更了。",
"gone_on_server": "{name} 已不在檔案中。",
"keep_both": "保留兩者",
"replace": "取代",
"cancel": "取消",
"save_as_new": "另存為新檔案",
"import_to_mail": "匯入到郵件資料夾…",
"import_to_mail_title": "匯入到郵件資料夾",
"imported_to_mail": "已匯入到 {folder}。",
"import_to_mail_failed": "無法匯入 {name}。",
"import_contacts": "匯入到聯絡人…",
"add_to_calendar": "加入行事曆…"
},
"smime": {
"your_certificates": "您的憑證",
"your_certificates_desc": "匯入與管理用於郵件簽章與加密的 S/MIME 憑證",
+20
View File
@@ -5518,6 +5518,26 @@
"share_mail_subject": "我与你共享了 {name}",
"share_mail_body": "你好,\n\n我与你共享了“{name}”。可以在这里打开:\n{link}"
},
"ux_p2_pim_files": {
"zone_calendar_all": "全部添加到日历",
"zone_contacts_all": "全部添加到联系人",
"save_to_files": "保存到文件",
"saved": "已将 {name} 保存到文件。",
"saved_copy": "已作为 {name} 保存到文件。",
"save_failed": "无法将 {name} 保存到文件。",
"changed_on_server": "你打开 {name} 后,它在文件中被更改了。",
"gone_on_server": "{name} 已不在文件中。",
"keep_both": "保留两者",
"replace": "替换",
"cancel": "取消",
"save_as_new": "另存为新文件",
"import_to_mail": "导入到邮件文件夹…",
"import_to_mail_title": "导入到邮件文件夹",
"imported_to_mail": "已导入到 {folder}。",
"import_to_mail_failed": "无法导入 {name}。",
"import_contacts": "导入到联系人…",
"add_to_calendar": "添加到日历…"
},
"smime": {
"your_certificates": "您的证书",
"your_certificates_desc": "导入和管理您的 S/MIME 证书以对邮件进行签名和加密",
@@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useFileStore } from '../file-store';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { FileNode } from '@/lib/jmap/types';
import type { UploadConflict } from '@/lib/files-upload-conflicts';
// pim-p2-6: files dropped on a folder's row go into that folder, and a name
// taken there (not in the folder in view) asks.
function makeClient(initial: FileNode[]) {
const nodes: FileNode[] = initial.map(n => ({ ...n }));
let seq = 0;
const client = {
getCapabilities: () => ({}),
getFilesAccountId: () => 'acct',
async listAllFileNodes() { return nodes.map(n => ({ ...n })); },
async listFileNodes(parentId: string | null) { return nodes.filter(n => (n.parentId ?? null) === parentId).map(n => ({ ...n })); },
async createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null) {
const node: FileNode = { id: `n${++seq}`, parentId, name, type, blobId, size, created: '', modified: '' };
nodes.push(node);
return { ...node };
},
replaceFileNodeBlob: vi.fn(async () => {}),
async uploadBlob() { return { blobId: `blob${++seq}`, type: 'text/plain' }; },
_nodes: () => nodes,
};
return client as unknown as IJMAPClient & { _nodes: () => FileNode[] };
}
const folder = (id: string, name: string, parentId: string | null): FileNode => ({
id, parentId, name, type: '', blobId: null, size: 0, created: '', modified: '',
});
const file = (id: string, name: string, parentId: string | null): FileNode => ({
id, parentId, name, type: 'text/plain', blobId: `old-${id}`, size: 3, created: '', modified: '',
});
beforeEach(() => {
useFileStore.setState({
client: null, currentParentId: null, currentPath: '/', pathStack: [{ id: null, name: '' }],
resources: [], selectedResources: new Set(), clipboard: null, lastAction: null,
});
});
describe('uploading into a folder of the one in view (pim-p2-6)', () => {
it('creates the files in that folder and asks about names taken there', async () => {
const client = makeClient([folder('d', 'Documents', null), file('r', 'report.txt', 'd'), file('t', 'top.txt', null)]);
useFileStore.getState().initClient(client);
await useFileStore.getState().navigate(null);
const asked: UploadConflict[][] = [];
const resolveConflicts = vi.fn(async (conflicts: UploadConflict[]) => {
asked.push(conflicts);
return new Map(conflicts.map(c => [c.name, 'keep' as const]));
});
await useFileStore.getState().uploadFiles(
[new File(['x'], 'report.txt'), new File(['y'], 'top.txt')],
{ resolveConflicts, into: { id: 'd', name: 'Documents' } },
);
// report.txt is taken in Documents, top.txt only in the folder in view.
expect(asked).toEqual([[{ name: 'report.txt', kind: 'file' }]]);
const added = client._nodes().filter(n => n.id.startsWith('n'));
expect(added.map(n => [n.name, n.parentId]).sort()).toEqual([['report (1).txt', 'd'], ['top.txt', 'd']]);
});
});
+19 -4
View File
@@ -69,6 +69,19 @@ export interface UploadReport {
export interface UploadOptions {
/** Asked when a name is taken in the target folder; without it, new ones are numbered. */
resolveConflicts?: UploadConflictResolver;
/** A folder of the one in view, as when files are dropped on its row. Default: the folder in view. */
into?: { id: string; name: string };
}
/** Where an upload goes: the folder in view, or one of its folders (`into`), with what that folder holds. */
async function uploadTarget(
state: Pick<FileState, 'currentParentId' | 'currentPath' | 'resources' | 'listByParentId'>,
into: UploadOptions['into'],
): Promise<{ parentId: string | null; path: string; existing: FileResource[] }> {
if (!into || into.id === state.currentParentId) {
return { parentId: state.currentParentId, path: state.currentPath, existing: state.resources };
}
return { parentId: into.id, path: joinPath(state.currentPath, into.name), existing: await state.listByParentId(into.id) };
}
interface ClipboardState {
@@ -1120,10 +1133,11 @@ export const useFileStore = create<FileState>((set, get) => ({
},
uploadFiles: async (allFiles: File[], options?: UploadOptions) => {
const { client, currentParentId, currentPath, resources } = get();
const { client } = get();
const report: UploadReport = { uploaded: 0, failed: [], canceled: false };
if (!client) return report;
assertCanAddTo(currentParentId);
assertCanAddTo(options?.into?.id ?? get().currentParentId);
const { parentId: currentParentId, path: currentPath, existing: resources } = await uploadTarget(get(), options?.into);
// Plugins may hold back single files; the rest go up.
const files: File[] = [];
@@ -1204,10 +1218,11 @@ export const useFileStore = create<FileState>((set, get) => ({
},
uploadFolder: async (allFiles: File[], options?: UploadOptions) => {
const { client, currentParentId, currentPath, resources } = get();
const { client } = get();
const report: UploadReport = { uploaded: 0, failed: [], canceled: false };
if (!client || allFiles.length === 0) return report;
assertCanAddTo(currentParentId);
assertCanAddTo(options?.into?.id ?? get().currentParentId);
const { parentId: currentParentId, path: currentPath, existing: resources } = await uploadTarget(get(), options?.into);
const relativePathOf = (file: File) => (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
const dirOf = (relativePath: string) => {